15 | {
16 | CP.map((cp, idx) => (
17 | e(window.Station, { stationProps: cp, stationId: idx })
18 | ))
19 | }
20 |
21 | )
22 | };
23 |
24 | const domContainer = document.querySelector('#app_container');
25 | ReactDOM.render(
124 | {e(
125 | window.Card,
126 | status,
127 | e(window.Button, { label: 'Boot', onClick: handleClick }),
128 | e(window.Button, { label: 'Authorize', onClick: handleClick }),
129 | e(window.Button, { label: 'Start', onClick: handleClick, disabled: !authorized }),
130 | e(window.Button, { label: 'Stop', onClick: handleClick, disabled: !authorized }),
131 | e(window.Button, { label: 'Data Transfer', onClick: handleClick }),
132 | e(window.Button, { label: 'Diagnostics', onClick: handleClick }),
133 | e(window.Button, { label: 'Firmware', onClick: handleClick }),
134 | e(window.Button, { label: 'Heartbeat', onClick: handleClick }),
135 | e(window.Button, { label: 'Meter', onClick: handleClick }),
136 | e(window.Button, { label: 'Status', onClick: handleClick }),
137 | )}
138 |
139 | {e(
140 | window.Logs,
141 | { logs: logs.map(log =>
142 | `${log[0]}------${log[1]}------${JSON.stringify(log[2])}`
143 | )
144 | }
145 | )}
146 |
147 | );
148 | }
149 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | const express = require('express');
2 | const path = require('path');
3 | const url = require('url');
4 | const util = require('util');
5 | const WebSocket = require('ws');
6 | const { partial, range } = require('lodash');
7 | const OCPPClient = require('./src/ocppClient');
8 | const requestHandler = require('./src/requestHandler');
9 | const CP = require('./data/chargepoints');
10 | const responseHandler = require('./src/responseHandler');
11 |
12 | const setTimeoutPromise = util.promisify(setTimeout);
13 |
14 | const app = express();
15 |
16 | app.use(express.static(path.join(__dirname, 'app')));
17 | app.use('/data/users.js', express.static(path.join(__dirname, './data/users.js')));
18 | app.use('/data/chargepoints.js', express.static(path.join(__dirname, './data/chargepoints.js')));
19 |
20 | app.listen(5000, () => {
21 | console.log('OCPP 1.6 client');
22 | });
23 |
24 | // server for ws connections from browser
25 | // one port for all connections
26 | const server = app.listen(5050);
27 |
28 | server.on('upgrade', function upgrade(request, socket, head) {
29 | const pathname = url.parse(request.url).pathname;
30 |
31 | if (pathnames.includes(pathname)) {
32 | wsDict[pathname].handleUpgrade(request, socket, head, function done(ws) {
33 | wsDict[pathname].emit('connection', ws, request);
34 | });
35 | } else {
36 | socket.destroy();
37 | }
38 | });
39 |
40 | const numOfCPs = CP.length;
41 | const wsDict = {};
42 | const ocppClients = []; // for cleanup only
43 | const pathnames = [];
44 |
45 | range(numOfCPs).forEach(function createClientForEachCP(idx) {
46 | let wss = spawnClient('/simulator', idx, setOcppClient);
47 | let name = `/simulator${idx}`;
48 | wsDict[name] = wss;
49 | pathnames.push(name);
50 | })
51 |
52 | function spawnClient(endpoint, stationId, setOcppClient) {
53 | // for ws communication with the UI
54 | const wss = new WebSocket.Server({ noServer: true });
55 |
56 | wss.on('connection', (ws) => {
57 | console.log(`connected to ${endpoint + stationId}`);
58 |
59 | // init response handler
60 | const resHandler = partial(responseHandler, stationId, ws);
61 |
62 | // create OCPP client
63 | const ocppClient = OCPPClient(CP[stationId], resHandler);
64 |
65 | // callback
66 | setOcppClient(ocppClient);
67 |
68 | ws.on('close', () => {
69 | console.log('closed');
70 | // close the ocpp client if the UI disconnects
71 | ocppClient.ws.close();
72 | });
73 |
74 | ws.on('error', (error) => console.log(error));
75 |
76 | ws.on('message', (raw) => {
77 | const msgFromUI = JSON.parse(raw);
78 | console.log(msgFromUI);
79 |
80 | // pass requests from UI to the handler
81 | requestHandler(stationId, msgFromUI, ocppClient, ws);
82 | });
83 | });
84 |
85 | return wss;
86 | }
87 |
88 | function setOcppClient(client) {
89 | ocppClients.push(client);
90 | }
91 |
92 | // handle SIGINT on Windows
93 | if (process.platform === "win32") {
94 | let rl = require("readline").createInterface({
95 | input: process.stdin,
96 | output: process.stdout
97 | });
98 |
99 | rl.on("SIGINT", function () {
100 | process.emit("SIGINT");
101 | });
102 | }
103 |
104 | process.on("SIGINT", async function () {
105 | await cleanup();
106 | process.exit();
107 | });
108 |
109 | process.on('uncaughtException', async (err) => {
110 | console.error('Error', err);
111 | await cleanup();
112 | process.exit(1);
113 | });
114 |
115 | /**
116 | * Stop active transactions on exit or error. Otherwise the transactions
117 | * will get stuck being active forever on the server.
118 | * In case a transaction is not stopped properly, you need to manualy send
119 | * a StopTransaction request with the `transactionId` of the prolematic tx.
120 | */
121 | async function cleanup() {
122 | console.log('cleaning up before exit...');
123 |
124 | const res = ocppClients.map(client => {
125 | return new Promise(async (resolve, reject) => {
126 | if (client) {
127 | let activeTransaction = client.getActiveTransaction();
128 |
129 | console.log('activeTransaction before exit', JSON.stringify(activeTransaction, null, 4));
130 |
131 | if (activeTransaction) {
132 | // create a dummy StopTransaction request to kill the tx
133 | let { messageId, transactionId, idTag } = activeTransaction;
134 | let payload = {
135 | meterStop: 0,
136 | timestamp: new Date().toISOString(),
137 | transactionId,
138 | idTag,
139 | reason: 'Local'
140 | };
141 | let message = [2, messageId, 'StopTransaction', payload];
142 |
143 | client.ws.send(JSON.stringify(message), () => {
144 | // add to queue for handling StopTransaction conf
145 | let pendingReq = { messageId, action: 'StopTransaction', ...payload };
146 | client.addToQueue(pendingReq);
147 | });
148 | }
149 | } else {
150 | console.error('client undefined');
151 | }
152 |
153 | // do not close ws until the client receives the StopTransaction conf
154 | await setTimeoutPromise(1000);
155 | resolve(client.ws.close());
156 | });
157 | });
158 |
159 | await Promise.all(res);
160 | console.log('Exited cleanly');
161 | }
162 |
--------------------------------------------------------------------------------
/src/ocppClient.js:
--------------------------------------------------------------------------------
1 | /**
2 | * OCPP client
3 | *
4 | * Requests to the server are handled in `requestHandler`.
5 | * Responses from the server are handled in `responseHandler`.
6 | *
7 | * States are stored in closures for testing purpose.
8 | */
9 |
10 | const WebSocket = require('ws');
11 | const { partial } = require('lodash');
12 | const config = require('../config');
13 | const { MESSAGE_TYPE } = require('./ocpp');
14 | const authorizationList = require('./authorizationList');
15 | const scheduler = require('./scheduler');
16 |
17 |
18 | function OCPPClient(CP, responseHandler) {
19 | const MAX_AMP = CP.ratings.amp;
20 | const VOLTAGE = CP.ratings.voltage;
21 |
22 | // init states
23 | let msgId = 1;
24 | let logs = [];
25 | let activeTransaction;
26 | let queue = [];
27 | const authCache = authorizationList({ type: 'cache' });
28 | const authList = authorizationList({ type: 'list' });
29 | let heartbeat = 3600;
30 | let chargingProfiles = {
31 | ChargePointMaxProfile: [],
32 | TxDefaultProfile: [],
33 | TxProfile: [],
34 | composite: []
35 | };
36 | let limit = MAX_AMP;
37 | let meter = []; // [{ start, end, kw }, ...]
38 |
39 | let profileScheduler = scheduler();
40 |
41 | const server = `${config.OCPPServer}/${CP['name']}`;
42 | const auth = "Basic " + Buffer.from(`${CP['user']}:${CP['pass']}`).toString('base64');
43 |
44 | // getters and setters
45 | function getMsgId() {
46 | return msgId.toString();
47 | }
48 |
49 | function incMsgId() {
50 | msgId += 1;
51 | }
52 |
53 | function getHeartbeat() {
54 | return heartbeat;
55 | }
56 |
57 | function setHeartbeat(interval) {
58 | heartbeat = interval || 3600;
59 | }
60 |
61 | function addLog(type, response) {
62 | logs.push([type, new Date(), response]);
63 | }
64 |
65 | function getLogs() {
66 | return logs;
67 | }
68 |
69 | function getActiveTransaction() {
70 | return activeTransaction;
71 | }
72 |
73 | function setActiveTransaction(transaction) {
74 | activeTransaction = transaction;
75 | }
76 |
77 | function getQueue() {
78 | return queue;
79 | }
80 |
81 | function addToQueue(job) {
82 | queue.push(job);
83 | }
84 |
85 | function popQueue(id) {
86 | queue = queue.filter(q => q.messageId !== id);
87 | }
88 |
89 | function getChargingProfiles() {
90 | return chargingProfiles;
91 | }
92 |
93 | function setChargingProfiles(type, profile) {
94 | chargingProfiles[type] = profile;
95 | }
96 |
97 | function getLimit() {
98 | return limit;
99 | }
100 |
101 | function setLimit(value=MAX_AMP) {
102 | limit = Math.max(0, Math.min(value, MAX_AMP));
103 | }
104 |
105 | function getMeter() {
106 | const kwhInTx = meter
107 | .filter(m => m.end)
108 | .reduce((accum, m) => {
109 | let duration = (m.end - m.start)/1000/3600; // hours
110 | let kwhThisSession = m.kw * duration;
111 | return accum + kwhThisSession;
112 | }, 0);
113 |
114 | return kwhInTx.toFixed(3);
115 | }
116 |
117 | function initNewMeterSession() {
118 | const now = Date.now();
119 | meter.push({
120 | start: now,
121 | end: undefined,
122 | kw: (limit * VOLTAGE / 1000).toFixed(3)
123 | })
124 | }
125 |
126 | function finishLastMeterSession() {
127 | const now = Date.now();
128 | const pendingIdx = meter.length - 1;
129 | if (pendingIdx > -1) {
130 | const session = {
131 | start: meter[pendingIdx].start,
132 | end: now,
133 | kw: meter[pendingIdx].kw
134 | };
135 |
136 | meter[pendingIdx] = session;
137 | }
138 | }
139 |
140 | function clearMeter() {
141 | meter = [];
142 | }
143 |
144 | function getRatings() {
145 | return { MAX_AMP, VOLTAGE };
146 | }
147 |
148 | // ws
149 | const ws = new WebSocket(
150 | server,
151 | 'ocpp1.6',
152 | { headers: { Authorization: auth }}
153 | );
154 |
155 | // ocpp client object passed to the handlers
156 | const ocppClient = {
157 | ws,
158 | authCache,
159 | authList,
160 | getMsgId,
161 | getLogs,
162 | addLog,
163 | getQueue,
164 | addToQueue,
165 | getActiveTransaction,
166 | setActiveTransaction,
167 | getChargingProfiles,
168 | setChargingProfiles,
169 | getLimit,
170 | setLimit,
171 | meter: {
172 | getMeter,
173 | initNewMeterSession,
174 | finishLastMeterSession,
175 | clearMeter
176 | },
177 | getRatings,
178 | scheduler: profileScheduler
179 | };
180 |
181 | const resHandler = partial(responseHandler, ocppClient);
182 |
183 | ws.on('open', function open() {
184 | console.log('ws client open');
185 | });
186 |
187 | ws.on("message", function incoming(data) {
188 | console.log('From OCPP server:', data);
189 | const response = JSON.parse(data);
190 | const [messageType] = response;
191 | const messageTypeText = MESSAGE_TYPE[`${messageType}`] || undefined;
192 |
193 | // log incoming messages from the server
194 | addLog('CONF', response);
195 |
196 | // handle incoming messages
197 | switch (messageTypeText) {
198 | case 'CALL':
199 | // handle requests from the server, e.g. SetChargingProfile
200 | resHandler(response).handleCall();
201 | break;
202 | case 'CALLRESULT':
203 | // handle responses from the server, e.g. StartTransaction
204 | incMsgId();
205 | resHandler(response).handleCallResult(
206 | { queue, activeTransaction },
207 | { popQueue, setActiveTransaction }
208 | );
209 | break;
210 | case 'CALLERROR':
211 | console.log('Error', response);
212 | incMsgId();
213 | resHandler(response).handleCallError();
214 | break;
215 | default:
216 | console.log('Unknown message type');
217 | }
218 | });
219 |
220 | ws.on('error', (error) => console.log(error));
221 |
222 | return ocppClient;
223 | }
224 |
225 | module.exports = OCPPClient;
226 |
--------------------------------------------------------------------------------
/src/requestHandler.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Handle requests sent to the OCPP server
3 | */
4 |
5 | const { VALID_ACTIONS } = require('./ocpp');
6 | const CP = require('../data/chargepoints');
7 | const authorize = require('../ocpp/authorize');
8 |
9 |
10 | const requestHandler = (
11 | stationId,
12 | messageFromUI,
13 | {
14 | ws,
15 | getMsgId,
16 | getQueue,
17 | addToQueue,
18 | getActiveTransaction,
19 | addLog,
20 | getLogs,
21 | authList,
22 | authCache,
23 | meter
24 | },
25 | wsUI
26 | ) => {
27 | const messageType = 2; // client to server
28 | const [action] = messageFromUI; // e.g. StartTransaction
29 | const messageId = getMsgId();
30 | const { transactionId } = getActiveTransaction() || {};
31 | const payload = getPayload(stationId, messageFromUI, { transactionId, meter });
32 | const req = [messageType, messageId, action, payload];
33 |
34 | const isValidAction = VALID_ACTIONS.includes(action);
35 | const isNewReq = !getQueue().some(q => q.action === action);
36 | const isValidPayload = true; // TODO: add validator
37 | let isAuthorized = true;
38 | const isValidRequest = isValidAction && isNewReq && isValidPayload;
39 |
40 | if (action === 'Authorize') {
41 | const { idTag } = messageFromUI[1];
42 | // check if `idTag` is valid in local authorization cache and list
43 | isAuthorized = authorize({ idTag, authList, authCache });
44 |
45 | if (isAuthorized && isValidRequest) {
46 | // authorized by local authorization cache/list
47 | console.log('Already authorized');
48 | wsUI.send(JSON.stringify([`${action}Conf`, isAuthorized]));
49 | } else if (!isAuthorized && isValidRequest) {
50 | // need to contact server for authorization
51 | sendMessage(ws, req, addToQueue, addLog, sendLogsToUI(wsUI, getLogs()));
52 | } else {
53 | console.log('Not authorized or invalid id tag');
54 | }
55 | } else {
56 | if (isValidRequest) {
57 | sendMessage(ws, req, addToQueue, addLog, sendLogsToUI(wsUI, getLogs()));
58 | } else {
59 | console.log('Invalid action or payload');
60 | }
61 | }
62 | };
63 |
64 | /**
65 | * Send message to the OCPP server
66 | *
67 | * @param {object} wsClient websocket
68 | * @param {array} req message to server
69 | * @param {function} addToQueue add outbound message pending response to queue
70 | * @param {function} addLog add request to log
71 | * @param {function} cb callback after successful request
72 | */
73 | function sendMessage(wsClient, req, addToQueue, addLog, cb) {
74 | // send to OCPP server
75 | wsClient.send(JSON.stringify(req), () => {
76 | console.log('Message sent: ' + JSON.stringify(req));
77 |
78 | let [_, messageId, action, payload] = req;
79 |
80 | let pendingReq = { messageId, action, ...payload };
81 |
82 | // requests await conf from server are added to queue
83 | addToQueue(pendingReq);
84 |
85 | addLog('REQ', req);
86 |
87 | cb();
88 | });
89 | }
90 |
91 | function sendLogsToUI(wsUI, logs) {
92 | return function() {
93 | wsUI.send(JSON.stringify(['OCPP', logs]));
94 | };
95 | }
96 |
97 | /**
98 | * Prepare payload for OCPP message.
99 | * For complete message definitions, see section 4, Operations Initiated
100 | * by Charge Point, in the specs.
101 | *
102 | * @param {number} stationId station id
103 | * @param {array} param1 partial ocpp message
104 | * @param {object} extras additional data needed for complete message
105 | */
106 | function getPayload(stationId, [action, payloadFromStation = {}], extras) {
107 | let payload = {}, timestamp;
108 | switch (action) {
109 | case 'Authorize':
110 | payload = { ...payloadFromStation };
111 | break;
112 | case 'BootNotification':
113 | payload = { ...CP[stationId].props, ...payloadFromStation };
114 | break;
115 | case 'DataTransfer':
116 | // mockup
117 | let vendorId = 'E8EAFB';
118 | let data = 'hello';
119 | payload = { vendorId, data, ...payloadFromStation };
120 | break;
121 | case 'DiagnosticsStatusNotification':
122 | // mockup
123 | payload = { status: 'Idle' };
124 | break;
125 | case 'FirmwareStatusNotification':
126 | // mockup
127 | payload = { status: 'Idle' };
128 | break;
129 | case 'Heartbeat':
130 | payload = {};
131 | break;
132 | case 'MeterValues': {
133 | // mockup
134 | let connectorId = 1;
135 | let meterValue = [{
136 | timestamp: new Date().toISOString(),
137 | sampledValue: [
138 | { value: '10', measurand: 'Energy.Active.Import.Register', unit: 'kWh' },
139 | //{ value: '18', measurand: 'Temperature', unit: 'Celcius' },
140 | { value: '356', measurand: 'Voltage', unit: 'V' }
141 | ]
142 | }];
143 | payload = { connectorId, meterValue };
144 | }
145 | break;
146 | case 'StartTransaction':
147 | timestamp = new Date().toISOString();
148 | // always set `meterStart` to 0 for simplicity
149 | payload = { meterStart: 0, timestamp, ...payloadFromStation };
150 | break;
151 | case 'StatusNotification': {
152 | // mockup
153 | let connectorId = 0;
154 | let errorCode = 'NoError'; // see section 7.6 in the 1.6 spec
155 | let info = 'Test';
156 | let status = 'Available'; // see section 7.7
157 | let vendorId = 'E8EAFB';
158 |
159 | payload = { connectorId, errorCode, info, status, vendorId };
160 | }
161 | break;
162 | case 'StopTransaction':
163 | timestamp = new Date().toISOString();
164 | const { transactionId, meter } = extras;
165 |
166 | // we need kwh in the payload so need to get meter value here
167 | meter.finishLastMeterSession();
168 | let kwh = meter.getMeter();
169 | meter.clearMeter();
170 |
171 | payload = {
172 | meterStop: parseInt(kwh*1000),
173 | timestamp,
174 | transactionId,
175 | idTag: payloadFromStation.idTag,
176 | reason: payloadFromStation.reason
177 | };
178 | break;
179 | default:
180 | console.log(`${action} not supported`);
181 | }
182 |
183 | // some info from the station, some from the ocpp client
184 | return payload;
185 | }
186 |
187 | module.exports = requestHandler;
188 |
--------------------------------------------------------------------------------
/test/testAuthorize.js:
--------------------------------------------------------------------------------
1 | const assert = require('assert');
2 | const authorizationList = require('../src/authorizationList');
3 | const authorize = require('../ocpp/authorize');
4 |
5 | describe('authorizatoin list', () => {
6 | let authList = [];
7 |
8 | before(() => {
9 | authList = authorizationList({ type: 'list' });
10 | const idTagInfo = {
11 | expiryDate: new Date(new Date().getTime() + 3*60*60*1000).toISOString(),
12 | parentIdTag: 5,
13 | status: 'Accepted'
14 | };
15 | const idTag = '920EB';
16 | authList.add(idTag, idTagInfo);
17 | });
18 |
19 | it('add new user to list', () => {
20 | const idTag = '920EB';
21 | const list = authList.get();
22 | const isInList = list.some(u => u.idTag === idTag);
23 |
24 | assert(isInList);
25 | });
26 |
27 | it('update user', () => {
28 | const idTagInfo = {
29 | expiryDate: new Date().toISOString(),
30 | parentIdTag: 5,
31 | status: 'Expired'
32 | };
33 | const idTag = '920EB';
34 | authList.update(idTag, idTagInfo);
35 | const user = authList.get().find(u => u.idTag === idTag);
36 |
37 | assert(user.idTagInfo.status === 'expired');
38 | });
39 |
40 | it('remove user', () => {
41 | const idTag = '920EB';
42 | authList.remove(idTag);
43 | const user = authList.get().find(u => u.idTag === idTag);
44 |
45 | assert(!user);
46 | });
47 |
48 | it('is expired', () => {
49 | const idTag = '920EB';
50 |
51 | assert(authList.isExpired(idTag) === true);
52 | });
53 |
54 | it('remove oldest entry if list/cache is full', () => {
55 | const authList = authorizationList({ type: 'list', MAX_LENGTH: 2 });
56 | const users = [
57 | {
58 | idTag: '920EB',
59 | idTagInfo: {
60 | expiryDate: new Date(new Date().getTime() + 3*60*60*1000).toISOString(),
61 | parentIdTag: 5,
62 | status: 'Accepted'
63 | }
64 | },
65 | {
66 | idTag: '63ela',
67 | idTagInfo: {
68 | expiryDate: new Date(new Date().getTime() + 2*60*60*1000).toISOString(),
69 | parentIdTag: 5,
70 | status: 'Accepted'
71 | }
72 | }
73 | ];
74 | users.forEach(u => authList.add(u.idTag, u.idTagInfo));
75 |
76 | const newUser = {
77 | idTag: 'JEAB9',
78 | idTagInfo: {
79 | expiryDate: new Date(new Date().getTime() + 1*60*60*1000).toISOString(),
80 | parentIdTag: 5,
81 | status: 'Accepted'
82 | }
83 | };
84 | authList.add(newUser.idTag, newUser.idTagInfo);
85 |
86 | const idTagsInList = authList.get().map(u => u.idTag);
87 |
88 | assert(idTagsInList.length === 2);
89 | assert(idTagsInList.includes(newUser.idTag));
90 | assert(idTagsInList.includes(users[0].idTag));
91 | });
92 |
93 | it('remove user with invalid status if list/cache is full', () => {
94 | const authList = authorizationList({ type: 'list', MAX_LENGTH: 2 });
95 | const users = [
96 | {
97 | idTag: '920EB',
98 | idTagInfo: {
99 | expiryDate: new Date(new Date().getTime() + 3*60*60*1000).toISOString(),
100 | parentIdTag: 5,
101 | status: 'Invalid'
102 | }
103 | },
104 | {
105 | idTag: '63ela',
106 | idTagInfo: {
107 | expiryDate: new Date(new Date().getTime() + 2*60*60*1000).toISOString(),
108 | parentIdTag: 5,
109 | status: 'Accepted'
110 | }
111 | }
112 | ];
113 | users.forEach(u => authList.add(u.idTag, u.idTagInfo));
114 |
115 | const newUser = {
116 | idTag: 'JEAB9',
117 | idTagInfo: {
118 | expiryDate: new Date(new Date().getTime() + 1*60*60*1000).toISOString(),
119 | parentIdTag: 5,
120 | status: 'Accepted'
121 | }
122 | };
123 | authList.add(newUser.idTag, newUser.idTagInfo);
124 |
125 | const idTagsInList = authList.get().map(u => u.idTag);
126 |
127 | assert(idTagsInList.length === 2);
128 | assert(idTagsInList.includes(newUser.idTag));
129 | assert(idTagsInList.includes(users[1].idTag));
130 | });
131 | });
132 |
133 | describe('authorize', () => {
134 |
135 | const authList = authorizationList({ type: 'list' });
136 | const idTagInfo = {
137 | expiryDate: new Date(new Date().getTime() + 3*60*60*1000).toISOString(),
138 | parentIdTag: 5,
139 | status: 'Accepted'
140 | };
141 | const idTag = '920EB';
142 | authList.add(idTag, idTagInfo);
143 |
144 | it('authorized by list', () => {
145 | const idTag = '920EB';
146 | const authCache = authorizationList({ type: 'cache' });
147 |
148 | const isAuthorized = authorize({ idTag, authList, authCache });
149 |
150 | assert(isAuthorized === true);
151 | });
152 |
153 | it('authorized by cache', () => {
154 | const idTag = 'OUE923';
155 | const authCache = authorizationList({ type: 'cache' });
156 | const idTagInfo = {
157 | expiryDate: new Date(new Date().getTime() + 3*60*60*1000).toISOString(),
158 | parentIdTag: 5,
159 | status: 'Accepted'
160 | };
161 | authCache.add(idTag, idTagInfo);
162 |
163 | const isAuthorized = authorize({ idTag, authList, authCache });
164 |
165 | assert(isAuthorized === true);
166 | });
167 |
168 | it('not authorized', () => {
169 | const idTag = 'OUE923';
170 | const authCache = authorizationList({ type: 'cache' });
171 | const idTagInfo = {
172 | expiryDate: new Date(new Date().getTime() + 3*60*60*1000).toISOString(),
173 | parentIdTag: 5,
174 | status: 'Accepted'
175 | };
176 | authCache.add(idTag, idTagInfo);
177 |
178 | const isAuthorized = authorize({ idTag: 'foo', authList, authCache });
179 |
180 | assert(isAuthorized === false);
181 | });
182 |
183 | it('list has higher priority', () => {
184 | const idTag = 'OUE923';
185 | const authCache = authorizationList({ type: 'cache' });
186 | const idTagInfo = {
187 | expiryDate: new Date(new Date().getTime() + 3*60*60*1000).toISOString(),
188 | parentIdTag: 5,
189 | status: 'Accepted'
190 | };
191 | authCache.add(idTag, idTagInfo);
192 |
193 | const authList = authorizationList({ type: 'list' });
194 | const idTagInfoList = {
195 | expiryDate: new Date(new Date().getTime() + 3*60*60*1000).toISOString(),
196 | parentIdTag: 5,
197 | status: 'Invalid'
198 | };
199 | authList.add(idTag, idTagInfoList);
200 |
201 | const isAuthorized = authorize({ idTag: 'foo', authList, authCache });
202 |
203 | assert(isAuthorized === false);
204 | })
205 | });
206 |
--------------------------------------------------------------------------------
/test/testComposite.js:
--------------------------------------------------------------------------------
1 | const assert = require('assert');
2 | const {
3 | stacking,
4 | mergeTx,
5 | combining,
6 | combineConnectorProfiles,
7 | compositeSchedule
8 | } = require('../ocpp/chargingProfiles');
9 |
10 | describe('Composite schedule', () => {
11 | it('stacking', () => {
12 | const defaultProfiles = [
13 | {
14 | "connectorId": 0,
15 | "csChargingProfiles": {
16 | "chargingProfileId": 1,
17 | "stackLevel": 1,
18 | "chargingProfilePurpose": "TxDefaultProfile",
19 | "chargingProfileKind": "Absolute",
20 | "recurrencyKind": "Daily",
21 | "validFrom": "2019-03-03T00:01:00Z",
22 | "validTo": "2019-04-16T15:01:00Z",
23 | "chargingSchedule": {
24 | "duration": 28800,
25 | "chargingRateUnit": "A",
26 | "minChargingRate": 4,
27 | "startSchedule": "2019-03-03T04:01:00Z",
28 | "chargingSchedulePeriod": [
29 | { "startPeriod": 3600, "numberPhases": 3, "limit": 16 }
30 | ]
31 | }
32 | }
33 | },
34 | {
35 | "connectorId": 0,
36 | "csChargingProfiles": {
37 | "chargingProfileId": 1,
38 | "stackLevel": 2,
39 | "chargingProfilePurpose": "TxDefaultProfile",
40 | "chargingProfileKind": "Absolute",
41 | "recurrencyKind": "Daily",
42 | "validFrom": "2019-03-03T00:01:00Z",
43 | "validTo": "2019-04-16T15:01:00Z",
44 | "chargingSchedule": {
45 | "duration": 12800,
46 | "chargingRateUnit": "A",
47 | "minChargingRate": 4,
48 | "startSchedule": "2019-03-05T04:01:00Z",
49 | "chargingSchedulePeriod": [
50 | { "startPeriod": 7200, "numberPhases": 3, "limit": 32 }
51 | ]
52 | }
53 | }
54 | },
55 | {
56 | "connectorId": 0,
57 | "csChargingProfiles": {
58 | "chargingProfileId": 1,
59 | "stackLevel": 3,
60 | "chargingProfilePurpose": "TxDefaultProfile",
61 | "chargingProfileKind": "Absolute",
62 | "recurrencyKind": "Daily",
63 | "validFrom": "2019-03-03T00:01:00Z",
64 | "validTo": "2019-04-16T15:01:00Z",
65 | "chargingSchedule": {
66 | "duration": 10000,
67 | "chargingRateUnit": "A",
68 | "minChargingRate": 4,
69 | "startSchedule": "2019-03-10T04:01:00Z",
70 | "chargingSchedulePeriod": [
71 | { "startPeriod": 0, "numberPhases": 3, "limit": 8 }
72 | ]
73 | }
74 | }
75 | }
76 | ];
77 |
78 | const txProfiles = [
79 | {
80 | "connectorId": 0,
81 | "csChargingProfiles": {
82 | "chargingProfileId": 1,
83 | "stackLevel": 1,
84 | "chargingProfilePurpose": "TxProfile",
85 | "chargingProfileKind": "Absolute",
86 | "recurrencyKind": "Daily",
87 | "validFrom": "2019-03-03T00:01:00Z",
88 | "validTo": "2019-04-16T15:01:00Z",
89 | "chargingSchedule": {
90 | "duration": 12300,
91 | "chargingRateUnit": "A",
92 | "minChargingRate": 4,
93 | "startSchedule": "2019-03-08T04:01:00Z",
94 | "chargingSchedulePeriod": [
95 | { "startPeriod": 7200, "numberPhases": 3, "limit": 4 }
96 | ]
97 | }
98 | }
99 | }
100 | ];
101 |
102 | const maxProfiles = [
103 | {
104 | "connectorId": 0,
105 | "csChargingProfiles": {
106 | "chargingProfileId": 1,
107 | "stackLevel": 1,
108 | "chargingProfilePurpose": "ChargePointMaxProfile",
109 | "chargingProfileKind": "Absolute",
110 | "recurrencyKind": "Daily",
111 | "validFrom": "2019-03-03T00:01:00Z",
112 | "validTo": "2019-04-16T15:01:00Z",
113 | "chargingSchedule": {
114 | "duration": 30000,
115 | "chargingRateUnit": "A",
116 | "minChargingRate": 4,
117 | "startSchedule": "2019-03-08T00:01:00Z",
118 | "chargingSchedulePeriod": [
119 | { "startPeriod": 0, "numberPhases": 3, "limit": 30 }
120 | ]
121 | }
122 | }
123 | }
124 | ];
125 |
126 | const stackedDefault = stacking(defaultProfiles);
127 | const stackedTx = stacking(txProfiles);
128 | const merged = mergeTx(stackedDefault, stackedTx);
129 | const stackedMax = stacking(maxProfiles);
130 | const combined = combining([...stackedMax, ...merged]);
131 |
132 |
133 | console.log('default', stackedDefault);
134 | console.log('tx', stackedTx);
135 | console.log('merged', merged);
136 | console.log('max', stackedMax);
137 | console.log('combined', combined);
138 | })
139 |
140 | it('Add connector profiles', () => {
141 | const defaultProfiles = [
142 | {
143 | "connectorId": 0,
144 | "csChargingProfiles": {
145 | "chargingProfileId": 1,
146 | "stackLevel": 1,
147 | "chargingProfilePurpose": "TxDefaultProfile",
148 | "chargingProfileKind": "Absolute",
149 | "recurrencyKind": "Daily",
150 | "validFrom": "2019-03-03T00:01:00Z",
151 | "validTo": "2019-04-16T15:01:00Z",
152 | "chargingSchedule": {
153 | "duration": 28800,
154 | "chargingRateUnit": "A",
155 | "minChargingRate": 4,
156 | "startSchedule": "2019-03-03T04:01:00Z",
157 | "chargingSchedulePeriod": [
158 | { "startPeriod": 3600, "numberPhases": 3, "limit": 16 }
159 | ]
160 | }
161 | }
162 | },
163 | {
164 | "connectorId": 1,
165 | "csChargingProfiles": {
166 | "chargingProfileId": 1,
167 | "stackLevel": 2,
168 | "chargingProfilePurpose": "TxDefaultProfile",
169 | "chargingProfileKind": "Absolute",
170 | "recurrencyKind": "Daily",
171 | "validFrom": "2019-03-03T00:01:00Z",
172 | "validTo": "2019-04-16T15:01:00Z",
173 | "chargingSchedule": {
174 | "duration": 12800,
175 | "chargingRateUnit": "A",
176 | "minChargingRate": 4,
177 | "startSchedule": "2019-03-05T04:01:00Z",
178 | "chargingSchedulePeriod": [
179 | { "startPeriod": 7200, "numberPhases": 3, "limit": 25 }
180 | ]
181 | }
182 | }
183 | },
184 | {
185 | "connectorId": 2,
186 | "csChargingProfiles": {
187 | "chargingProfileId": 1,
188 | "stackLevel": 3,
189 | "chargingProfilePurpose": "TxDefaultProfile",
190 | "chargingProfileKind": "Absolute",
191 | "recurrencyKind": "Daily",
192 | "validFrom": "2019-03-03T00:01:00Z",
193 | "validTo": "2019-04-16T15:01:00Z",
194 | "chargingSchedule": {
195 | "duration": 10000,
196 | "chargingRateUnit": "A",
197 | "minChargingRate": 4,
198 | "startSchedule": "2019-03-10T04:01:00Z",
199 | "chargingSchedulePeriod": [
200 | { "startPeriod": 0, "numberPhases": 3, "limit": 8 }
201 | ]
202 | }
203 | }
204 | }
205 | ];
206 |
207 | const txProfiles = [
208 | {
209 | "connectorId": 1,
210 | "csChargingProfiles": {
211 | "chargingProfileId": 1,
212 | "stackLevel": 1,
213 | "chargingProfilePurpose": "TxProfile",
214 | "chargingProfileKind": "Absolute",
215 | "recurrencyKind": "Daily",
216 | "validFrom": "2019-03-03T00:01:00Z",
217 | "validTo": "2019-04-16T15:01:00Z",
218 | "chargingSchedule": {
219 | "duration": 12300,
220 | "chargingRateUnit": "A",
221 | "minChargingRate": 4,
222 | "startSchedule": "2019-03-08T04:01:00Z",
223 | "chargingSchedulePeriod": [
224 | { "startPeriod": 7200, "numberPhases": 3, "limit": 4 }
225 | ]
226 | }
227 | }
228 | },
229 | {
230 | "connectorId": 2,
231 | "csChargingProfiles": {
232 | "chargingProfileId": 1,
233 | "stackLevel": 1,
234 | "chargingProfilePurpose": "TxProfile",
235 | "chargingProfileKind": "Absolute",
236 | "recurrencyKind": "Daily",
237 | "validFrom": "2019-03-03T00:01:00Z",
238 | "validTo": "2019-04-16T15:01:00Z",
239 | "chargingSchedule": {
240 | "duration": 23200,
241 | "chargingRateUnit": "A",
242 | "minChargingRate": 4,
243 | "startSchedule": "2019-03-08T06:01:00Z",
244 | "chargingSchedulePeriod": [
245 | { "startPeriod": 7200, "numberPhases": 3, "limit": 10 }
246 | ]
247 | }
248 | }
249 | }
250 | ];
251 |
252 | const maxProfiles = [
253 | {
254 | "connectorId": 0,
255 | "csChargingProfiles": {
256 | "chargingProfileId": 1,
257 | "stackLevel": 1,
258 | "chargingProfilePurpose": "ChargePointMaxProfile",
259 | "chargingProfileKind": "Absolute",
260 | "recurrencyKind": "Daily",
261 | "validFrom": "2019-03-03T00:01:00Z",
262 | "validTo": "2019-04-16T15:01:00Z",
263 | "chargingSchedule": {
264 | "duration": 30000,
265 | "chargingRateUnit": "A",
266 | "minChargingRate": 4,
267 | "startSchedule": "2019-03-08T00:01:00Z",
268 | "chargingSchedulePeriod": [
269 | { "startPeriod": 0, "numberPhases": 3, "limit": 30*2 }
270 | ]
271 | }
272 | }
273 | }
274 | ];
275 |
276 | // const added = combineConnectorProfiles([1,2], defaultProfiles, txProfiles);
277 | // console.log('added', added);
278 | const combined = compositeSchedule({
279 | connectorId: 0,
280 | chargingProfiles: {
281 | ChargePointMaxProfile: maxProfiles,
282 | TxDefaultProfile: defaultProfiles,
283 | TxProfile: txProfiles
284 | }
285 | });
286 |
287 | console.log('combined', combined);
288 | })
289 |
290 | it('default only', () => {
291 | const defaultProfile = [{
292 | "connectorId": 0,
293 | "csChargingProfiles": {
294 | "chargingProfileId": 1,
295 | "stackLevel": 3,
296 | "chargingProfilePurpose": "TxDefaultProfile",
297 | "chargingProfileKind": "Absolute",
298 | "recurrencyKind": "Daily",
299 | "validFrom": "2019-03-05T22:46:42Z",
300 | "validTo": "2019-04-06T22:46:42Z",
301 | "chargingSchedule": {
302 | "duration": 3600,
303 | "chargingRateUnit": "A",
304 | "minChargingRate": 4,
305 | "startSchedule": "2019-03-05T10:00:00Z",
306 | "chargingSchedulePeriod": [
307 | {
308 | "startPeriod": 0,
309 | "numberPhases": 3,
310 | "limit": 10
311 | }
312 | ]
313 | }
314 | }
315 | }];
316 |
317 | const composite = compositeSchedule({
318 | connectorId: 0,
319 | chargingProfiles: {
320 | TxDefaultProfile: defaultProfile,
321 | ChargePointMaxProfile: [],
322 | TxProfile: []
323 | },
324 | cpMaxAmp: 30
325 | });
326 | console.log(JSON.stringify(composite, null, 4));
327 | })
328 | })
--------------------------------------------------------------------------------
/server/sql/smart_charging.sql:
--------------------------------------------------------------------------------
1 | /*
2 | * Central Smart Charging
3 | * MySQL 5.7
4 | */
5 |
6 | DROP TABLE IF EXISTS centralSmartChargingGroup;
7 | CREATE TABLE centralSmartChargingGroup (
8 | groupId INT NOT NULL AUTO_INCREMENT,
9 | chargingProfileId INT NOT NULL, -- to be applied to each connector
10 | PRIMARY KEY (groupId)
11 |
12 | -- CONSTRAINT chargingProfile_id
13 | -- FOREIGN KEY (chargingProfileId)
14 | -- REFERENCES chargingProfile (chargingProfileId)
15 | -- ON UPDATE CASCADE
16 | -- ON DELETE CASCADE
17 | );
18 |
19 | DROP TABLE IF EXISTS chargepointGroup;
20 | CREATE TABLE chargepointGroup (
21 | chargepointId INT NOT NULL,
22 | connectorId INT NOT NULL,
23 | groupId INT NOT NULL,
24 | PRIMARY KEY (chargepointId, connectorId, groupId)
25 |
26 | -- CONSTRAINT chargepoint_id
27 | -- FOREIGN KEY (chargepointId)
28 | -- REFERENCES chargepoint (chargepointId)
29 | -- ON UPDATE CASCADE
30 | -- ON DELETE CASCADE,
31 |
32 | -- CONSTRAINT group_id
33 | -- FOREIGN KEY (groupId)
34 | -- REFERENCES centralSmartChargingGroup (groupId)
35 | -- ON UPDATE CASCADE
36 | -- ON DELETE CASCADE
37 | );
38 |
39 |
40 | DROP FUNCTION IF EXISTS `isInOutboundRequest`;
41 | DELIMITER $$
42 | CREATE FUNCTION `isInOutboundRequest` (
43 | chargepointId INT, connectorId INT, chargingProfileId INT
44 | ) RETURNS INT
45 | BEGIN
46 | DECLARE ret INT DEFAULT 0;
47 |
48 | IF EXISTS(
49 | SELECT * FROM outboundRequest r
50 | WHERE r.requestTypeId = requestTypeId('SetChargingProfile')
51 | AND r.chargepointId = chargepointId
52 | AND r.connectorId = connectorId
53 | AND r.chargingProfileId = chargingProfileId
54 | ) THEN
55 | SET ret = 1;
56 | END IF;
57 |
58 | return ret;
59 | END
60 | $$
61 | DELIMITER ;
62 |
63 | DROP FUNCTION IF EXISTS `isChargingProfileAssigned`;
64 | DELIMITER $$
65 | CREATE FUNCTION `isChargingProfileAssigned` (
66 | chargingProfileId INT, chargepointId INT, connectorId INT
67 | ) RETURNS INT
68 | BEGIN
69 | DECLARE ret INT DEFAULT 0;
70 |
71 | IF EXISTS(
72 | SELECT * FROM chargingProfileAssigned p
73 | WHERE p.chargepointId = chargepointId
74 | AND p.connectorId = connectorId
75 | AND p.chargingProfileId = chargingProfileId
76 | ) THEN
77 | SET ret = 1;
78 | END IF;
79 |
80 | return ret;
81 | END
82 | $$
83 | DELIMITER ;
84 |
85 | DROP FUNCTION IF EXISTS `getNumOfConnectorsInGroup`;
86 | DELIMITER $$
87 | CREATE FUNCTION `getNumOfConnectorsInGroup` (
88 | groupId INT
89 | ) RETURNS INT
90 | BEGIN
91 | DECLARE n INT DEFAULT 0;
92 |
93 | SELECT COUNT(*) INTO n
94 | FROM chargepointGroup cpg WHERE cpg.groupId = groupId;
95 |
96 | return n;
97 | END
98 | $$
99 | DELIMITER ;
100 |
101 | DROP FUNCTION IF EXISTS `getNumOfActiveTxInGroup`;
102 | DELIMITER $$
103 | CREATE FUNCTION `getNumOfActiveTxInGroup` (
104 | groupId INT
105 | ) RETURNS INT
106 | BEGIN
107 | DECLARE n INT DEFAULT 0;
108 |
109 | SELECT COUNT(DISTINCT chargepointId)
110 | INTO n
111 | FROM transactionLog
112 | WHERE terminateReasonId = 1
113 | AND chargepointId IN (
114 | SELECT g.chargepointId FROM chargepointGroup g
115 | WHERE g.groupId = groupId
116 | );
117 |
118 | return n;
119 | END
120 | $$
121 | DELIMITER ;
122 |
123 |
124 | /* Central smart charging */
125 | DROP PROCEDURE IF EXISTS CENTRAL_SMART_CHARGING;
126 | DELIMITER $$
127 | CREATE PROCEDURE CENTRAL_SMART_CHARGING (
128 | IN groupId INT
129 | )
130 | BEGIN
131 | DECLARE numOfConnectors INT DEFAULT 0;
132 | DECLARE numOfActiveTx INT DEFAULT 0;
133 | DECLARE r_cp VARCHAR(20);
134 | DECLARE r_chargingProfileId INT;
135 | DECLARE r_connectorId INT;
136 | DECLARE r_transactionId INT;
137 | DECLARE v_finished INT DEFAULT 0;
138 | DECLARE v_chargepointId INT;
139 | DECLARE v_chargingProfileId INT;
140 | DECLARE v_portId INT;
141 | DECLARE v_transactionLogId INT;
142 | DECLARE isInOutboundReq INT DEFAULT 1;
143 | DECLARE isAssigned INT DEFAULT 1;
144 | DECLARE s INT;
145 | DECLARE cpCursor CURSOR FOR (
146 | SELECT tl.chargepointId, tl.portId, tl.transactionLogId
147 | FROM transactionLog tl
148 | WHERE terminateReasonId = 1 -- Not Terminated
149 | AND tl.chargepointId IN (
150 | SELECT cpg.chargepointId FROM chargepointGroup cpg
151 | WHERE cpg.groupId = groupId
152 | )
153 | );
154 | DECLARE CONTINUE HANDLER
155 | FOR NOT FOUND SET v_finished = 1;
156 |
157 | -- number of connectors in the group
158 | SELECT COUNT(*) INTO numOfConnectors
159 | FROM chargepointGroup cpg WHERE cpg.groupId = groupId;
160 |
161 | -- number of active transactions related to the group
162 | SELECT COUNT(DISTINCT chargepointId) INTO numOfActiveTx
163 | FROM transactionLog
164 | WHERE terminateReasonId = 1 -- Not Terminated
165 | AND chargepointId IN (
166 | SELECT g.chargepointId FROM chargepointGroup g
167 | WHERE g.groupId = groupId
168 | );
169 |
170 | -- apply when all chargepoints in the group are in use
171 | IF numOfActiveTx >= numOfConnectors THEN
172 | OPEN cpCursor;
173 |
174 | -- set TxProfile for each connector (connectorId must be > 0)
175 | setChargingProfile: LOOP
176 |
177 | FETCH cpCursor INTO v_chargepointId, v_portId, v_transactionLogId;
178 |
179 | IF v_finished = 1 THEN
180 | LEAVE setChargingProfile;
181 | END IF;
182 |
183 | -- set cp
184 | SELECT HTTP_CP INTO r_cp FROM chargepoint
185 | WHERE chargepointId = v_chargepointId;
186 |
187 | -- set connector id (different from port id)
188 | SELECT p.connectorId INTO r_connectorId FROM `port` p
189 | WHERE p.portId = v_portId;
190 |
191 | -- set profile id
192 | SELECT g.chargingProfileId INTO r_chargingProfileId
193 | FROM centralSmartChargingGroup g
194 | WHERE g.groupId = groupId;
195 |
196 | -- set transaction id (same as transactionLogId)
197 | SET r_transactionId = v_transactionLogId;
198 |
199 | SET isAssigned = isChargingProfileAssigned(
200 | r_chargingProfileId, v_chargepointId, r_connectorId
201 | );
202 | SET isInOutboundReq = isInOutboundRequest(
203 | v_chargepointId, r_connectorId, r_chargingProfileId
204 | );
205 |
206 | -- add to charging profile assigned
207 | IF isAssigned = 0 THEN
208 | REPLACE INTO chargingProfileAssigned(chargepointId,connectorId,chargingProfileId)
209 | VALUES (v_chargepointId, r_connectorId, r_chargingProfileId);
210 | END IF;
211 |
212 | -- add to outbound request
213 | IF isInOutboundReq = 0 THEN
214 | /*
215 | * `SET_CHARGING_PROFILE` is a stored procedure from OpenOCPP v1.1.1
216 | * that adds the `setChargingProfile` request to `outboundRequest`
217 | */
218 | CALL SET_CHARGING_PROFILE(
219 | r_cp, r_connectorId, r_chargingProfileId, r_transactionId, s
220 | );
221 | END IF;
222 |
223 | END LOOP setChargingProfile;
224 | CLOSE cpCursor;
225 | END IF;
226 | END;
227 | $$
228 | DELIMITER ;
229 |
230 |
231 | DROP PROCEDURE IF EXISTS CENTRAL_SMART_CHARGING_ALL_GROUPS;
232 | DELIMITER $$
233 | CREATE PROCEDURE CENTRAL_SMART_CHARGING_ALL_GROUPS()
234 | BEGIN
235 | DECLARE v_finished INT DEFAULT 0;
236 | DECLARE v_groupId INT;
237 | DECLARE groupCursor CURSOR FOR (
238 | SELECT DISTINCT groupId FROM centralSmartChargingGroup
239 | );
240 | DECLARE CONTINUE HANDLER
241 | FOR NOT FOUND SET v_finished = 1;
242 |
243 | OPEN groupCursor;
244 | applySmartChargingToAllGroups: LOOP
245 |
246 | FETCH groupCursor INTO v_groupId;
247 |
248 | IF v_finished = 1 THEN
249 | LEAVE applySmartChargingToAllGroups;
250 | END IF;
251 |
252 | CALL CENTRAL_SMART_CHARGING(v_groupId);
253 |
254 | END LOOP applySmartChargingToAllGroups;
255 | CLOSE groupCursor;
256 | END
257 | $$
258 | DELIMITER ;
259 |
260 |
261 | DROP FUNCTION IF EXISTS `getSmartChargingGroupByTxId`;
262 | DELIMITER $$
263 | CREATE FUNCTION `getSmartChargingGroupByTxId` (
264 | txId INT
265 | ) RETURNS INT
266 | BEGIN
267 | DECLARE cpId INT DEFAULT 0;
268 | DECLARE groupId INT DEFAULT 0;
269 |
270 | SELECT chargepointId
271 | INTO cpId
272 | FROM transactionLog
273 | WHERE transactionLogId = txId;
274 |
275 | IF cpId > 0 THEN
276 | SELECT cpg.groupId
277 | INTO groupId
278 | FROM chargepointGroup cpg
279 | WHERE cpg.chargepointId = cpId;
280 | END IF;
281 |
282 | return groupId;
283 | END
284 | $$
285 | DELIMITER ;
286 |
287 | /*
288 | * Note this function should only be called in stop transaction req
289 | * and after the server updates `transactionLog`. This ensures the last
290 | * log item corresponds to the stop transaction req.
291 | */
292 | DROP FUNCTION IF EXISTS `getSmartChargingGroupFromLastTx`;
293 | DELIMITER $$
294 | CREATE FUNCTION `getSmartChargingGroupFromLastTx` ()
295 | RETURNS INT
296 | BEGIN
297 | DECLARE cpId INT DEFAULT 0;
298 | DECLARE groupId INT DEFAULT 0;
299 |
300 | SELECT chargepointId
301 | INTO cpId
302 | FROM transactionLog
303 | ORDER BY timestampStop DESC
304 | LIMIT 1;
305 |
306 | IF cpId > 0 THEN
307 | SELECT cpg.groupId
308 | INTO groupId
309 | FROM chargepointGroup cpg
310 | WHERE cpg.chargepointId = cpId;
311 | END IF;
312 |
313 | return groupId;
314 | END
315 | $$
316 | DELIMITER ;
317 |
318 |
319 | DROP PROCEDURE IF EXISTS `CENTRAL_SMART_CHARGING_CLEAR`;
320 | DELIMITER $$
321 | CREATE PROCEDURE `CENTRAL_SMART_CHARGING_CLEAR`(
322 | IN txId INT
323 | )
324 | BEGIN
325 | DECLARE transactionId INT;
326 | DECLARE chargepointId INT;
327 | DECLARE cp VARCHAR(20);
328 | DECLARE portId INT;
329 | DECLARE connectorId INT;
330 | DECLARE groupId INT;
331 | DECLARE chargingProfileId INT DEFAULT 0;
332 | DECLARE chargingProfilePurposeTypeId INT;
333 | DECLARE TxProfile INT;
334 | DECLARE numOfCpsInGroup INT DEFAULT 0;
335 | DECLARE numOfActiveTxInGroup INT DEFAULT 0;
336 | DECLARE s VARCHAR(20);
337 |
338 | SET TxProfile = 3; -- see chargingProfilePurposeType
339 |
340 | IF txId > 0 THEN
341 | -- if transaction id is provided by the server
342 | SET groupId = getSmartChargingGroupByTxId(txId);
343 |
344 | SELECT tl.transactionLogId, tl.chargepointId, tl.portId
345 | INTO transactionId, chargepointId, portId
346 | FROM transactionLog tl
347 | WHERE tl.transactionLogId = txId;
348 | ELSE
349 | SET groupId = getSmartChargingGroupFromLastTx();
350 |
351 | SELECT tl.transactionLogId, tl.chargepointId, tl.portId
352 | INTO transactionId, chargepointId, portId
353 | FROM transactionLog tl
354 | ORDER BY tl.timestampStop DESC LIMIT 1;
355 | END IF;
356 |
357 | SELECT p.connectorId INTO connectorId
358 | FROM port p
359 | WHERE p.portId = portId;
360 |
361 | SELECT cpa.chargingProfileId INTO chargingProfileId
362 | FROM chargingProfileAssigned cpa
363 | WHERE cpa.chargepointId = chargepointId
364 | AND cpa.connectorId = connectorId;
365 |
366 | SELECT cprofile.chargingProfilePurposeTypeId INTO chargingProfilePurposeTypeId
367 | FROM chargingProfile cprofile
368 | WHERE cprofile.chargingProfileId = chargingProfileId;
369 |
370 | -- TxProfile only
371 | IF chargingProfileId > 0 AND chargingProfilePurposeTypeId = TxProfile THEN
372 | SET numOfCpsInGroup = getNumOfConnectorsInGroup(groupId);
373 | SET numOfActiveTxInGroup = getNumOfActiveTxInGroup(groupId);
374 |
375 | SELECT c.HTTP_CP INTO cp
376 | FROM chargepoint c
377 | WHERE c.chargepointId = chargepointId;
378 |
379 | /*
380 | * Add a `claerChargingProfile` request to `outboundRequest` for
381 | * the cp requested stop transaction
382 | */
383 | CALL CLEAR_CHARGING_PROFILE(cp, connectorId, chargingProfileId, s);
384 |
385 | IF (numOfActiveTxInGroup + 1 <= numOfCpsInGroup) THEN
386 | /* Drop profiles on all other cps in the group */
387 | CALL CLEAR_OTHER_TXPROFILES_IN_GROUP(groupId);
388 | END IF;
389 | END IF;
390 |
391 | END
392 | $$
393 | DELIMITER ;
394 |
395 | DROP PROCEDURE IF EXISTS `CLEAR_OTHER_TXPROFILES_IN_GROUP`;
396 | DELIMITER $$
397 | CREATE PROCEDURE `CLEAR_OTHER_TXPROFILES_IN_GROUP`(
398 | IN groupId INT
399 | )
400 | BEGIN
401 | DECLARE cp VARCHAR(20);
402 | DECLARE connectorId INT;
403 | DECLARE chargingProfileId INT DEFAULT 0;
404 | DECLARE chargingProfilePurposeTypeId INT;
405 | DECLARE v_chargepointId INT;
406 | DECLARE v_portId INT;
407 | DECLARE v_finished INT DEFAULT 0;
408 | DECLARE s VARCHAR(20);
409 | DECLARE cpCursor CURSOR FOR (
410 | SELECT tl.chargepointId, tl.portId
411 | FROM transactionLog tl
412 | WHERE terminateReasonId = 1 -- Not Terminated
413 | AND tl.chargepointId IN (
414 | SELECT cpg.chargepointId FROM chargepointGroup cpg
415 | WHERE cpg.groupId = groupId
416 | )
417 | );
418 | DECLARE CONTINUE HANDLER
419 | FOR NOT FOUND SET v_finished = 1;
420 |
421 | OPEN cpCursor;
422 | clearOtherTxProfiles: LOOP
423 |
424 | FETCH cpCursor INTO v_chargepointId, v_portId;
425 |
426 | IF v_finished = 1 THEN
427 | LEAVE clearOtherTxProfiles;
428 | END IF;
429 |
430 | SELECT c.HTTP_CP INTO cp
431 | FROM chargepoint c
432 | WHERE c.chargepointId = v_chargepointId;
433 |
434 | SELECT p.connectorId INTO connectorId
435 | FROM port p
436 | WHERE p.portId = v_portId;
437 |
438 | SELECT cpa.chargingProfileId INTO chargingProfileId
439 | FROM chargingProfileAssigned cpa
440 | WHERE cpa.chargepointId = v_chargepointId
441 | AND cpa.connectorId = connectorId;
442 |
443 | SELECT cprofile.chargingProfilePurposeTypeId INTO chargingProfilePurposeTypeId
444 | FROM chargingProfile cprofile
445 | WHERE cprofile.chargingProfileId = chargingProfileId;
446 |
447 | CALL CLEAR_CHARGING_PROFILE(cp, connectorId, chargingProfileId, s);
448 |
449 | END LOOP clearOtherTxProfiles;
450 | CLOSE cpCursor;
451 | END
452 | $$
453 | DELIMITER ;
454 |
455 |
456 | DROP PROCEDURE IF EXISTS `CENTRAL_SMART_CHARGING_DROP_ASSIGNED_TXPROFILE`;
457 | DELIMITER $$
458 | CREATE PROCEDURE `CENTRAL_SMART_CHARGING_DROP_ASSIGNED_TXPROFILE`(
459 | IN CP VARCHAR(40), IN connectorId INT, IN chargingProfileId INT
460 | )
461 | BEGIN
462 | DECLARE chargingProfilePurposeTypeId INT;
463 | DECLARE TxProfile INT;
464 | SET TxProfile = 3;
465 |
466 | SELECT cprofile.chargingProfilePurposeTypeId INTO chargingProfilePurposeTypeId
467 | FROM chargingProfile cprofile
468 | WHERE cprofile.chargingProfileId = chargingProfileId;
469 |
470 | -- TxProfile only
471 | IF chargingProfilePurposeTypeId = TxProfile THEN
472 | DELETE cpa FROM chargingProfileAssigned AS cpa
473 | WHERE cpa.chargepointId = chargepointId(CP)
474 | AND cpa.connectorId = connectorId
475 | AND cpa.chargingProfileId = chargingProfileId;
476 | END IF;
477 | END
478 | $$
479 | DELIMITER ;
480 |
--------------------------------------------------------------------------------
/src/responseHandler.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Handler for all incoming messages from OCPP server
3 | */
4 |
5 | const util = require('util');
6 | const requestHandler = require('./requestHandler');
7 | const CP = require('../data/chargepoints');
8 | const sendLocalList = require('../ocpp/sendLocalList');
9 | const triggerMessage = require('../ocpp/triggerMessage');
10 | const {
11 | addProfile,
12 | removeProfile,
13 | compositeSchedule,
14 | getLimitNow,
15 | removeTxProfile
16 | } = require('../ocpp/chargingProfiles');
17 |
18 | const setTimeoutPromise = util.promisify(setTimeout);
19 |
20 | function responseHandler(
21 | stationId,
22 | wsBrowser,
23 | {
24 | ws: wsOcppClient,
25 | getMsgId,
26 | getQueue,
27 | addToQueue,
28 | getActiveTransaction,
29 | addLog,
30 | getLogs,
31 | authList,
32 | authCache,
33 | getChargingProfiles,
34 | setChargingProfiles,
35 | setLimit,
36 | getLimit,
37 | meter,
38 | getRatings,
39 | scheduler
40 | },
41 | response
42 | ) {
43 | const {
44 | MAX_AMP: DEFAULT_AMP,
45 | VOLTAGE
46 | } = getRatings();
47 |
48 | /**
49 | * Handle server request
50 | */
51 | function handleCall() {
52 | console.log('Handling server call');
53 |
54 | const [_, messageId, action, payload] = response;
55 |
56 | let res;
57 |
58 | switch (action) {
59 | case 'ClearChargingProfile': {
60 | res = composeResponse(messageId, { status: 'Accepted' });
61 | wsOcppClient.send(JSON.stringify(res), () => {
62 | addLog('REQ', res);
63 | });
64 |
65 | removeProfile({ response: payload, getChargingProfiles, setChargingProfiles });
66 |
67 | const { connectorId } = payload;
68 | let amp = DEFAULT_AMP;
69 | let composite = null;
70 | try {
71 | // recalculate the limit after profile removal
72 | amp = getLimitNow({
73 | connectorId,
74 | chargingProfiles: getChargingProfiles(),
75 | cpMaxAmp: DEFAULT_AMP
76 | }) || DEFAULT_AMP;
77 |
78 | composite = compositeSchedule({
79 | connectorId,
80 | chargingProfiles: getChargingProfiles(),
81 | cpMaxAmp: DEFAULT_AMP
82 | });
83 | } catch(e) {
84 | console.log('Error in getting limit', e);
85 | }
86 |
87 | // update amp limit and notify UI
88 | updateLimit(amp);
89 |
90 | // cancel the scheduler corresponding to the removed profile
91 | scheduler.removeSchedules(composite);
92 | }
93 | break;
94 | case 'GetCompositeSchedule': {
95 | console.log('GetCompositeSchedule req', JSON.stringify(response, null, 4));
96 | const { connectorId = 0 } = payload;
97 | const composite = compositeSchedule({
98 | connectorId,
99 | chargingProfiles: getChargingProfiles(),
100 | cpMaxAmp: DEFAULT_AMP
101 | });
102 | const startOfDay = new Date();
103 | startOfDay.setHours(0, 0, 0, 0);
104 | const endOfDay = 86400;
105 | const periodsStartTime = composite[0].ts;
106 | const periodsEndTime = Math.min(endOfDay, composite[composite.length - 1].ts);
107 | const retPayload = {
108 | status: 'Accepted',
109 | connectorId,
110 | scheduleStart: startOfDay.toISOString(),
111 | chargingSchedule: {
112 | duration: periodsEndTime - periodsStartTime,
113 | chargingRateUnit: 'A',
114 | minChargingRate: 4,
115 | startSchedule: startOfDay.toISOString(),
116 | chargingSchedulePeriod: composite
117 | // remove the last item which clears the limit
118 | .filter((p, idx) => idx < composite.length - 1)
119 | .map(p => ({
120 | startPeriod: p.ts,
121 | numberPhases: p.numberPhases,
122 | limit: p.limit
123 | }))
124 | }
125 | };
126 | res = composeResponse(messageId, retPayload);
127 | wsOcppClient.send(JSON.stringify(res), () => {
128 | addLog('REQ', res);
129 | });
130 | }
131 | break;
132 | case 'GetConfiguration':
133 | let configurationKey = CP[stationId].configurationKey;
134 | res = composeResponse(messageId, { configurationKey });
135 | wsOcppClient.send(JSON.stringify(res), () => {
136 | addLog('REQ', res);
137 | });
138 | break;
139 | case 'GetLocalListVersion':
140 | res = composeResponse(messageId, { listVersion: auth.getVersion() });
141 | wsOcppClient.send(JSON.stringify(res), () => {
142 | addLog('REQ', res);
143 | });
144 | break;
145 | case 'RemoteStopTransaction':
146 | res = composeResponse(messageId, { status: 'Accepted' });
147 | wsOcppClient.send(JSON.stringify(res), () => {
148 | addLog('REQ', res);
149 | });
150 | break;
151 | case 'SendLocalList':
152 | let payloadConf = sendLocalList.conf(authList, payload);
153 | res = composeResponse(messageId, payloadConf)
154 | wsOcppClient.send(JSON.stringify(res), () => {
155 | addLog('REQ', res);
156 | });
157 | break;
158 | case 'SetChargingProfile': {
159 | let {
160 | connectorId,
161 | csChargingProfiles: {
162 | chargingProfileId,
163 | transactionId,
164 | stackLevel,
165 | chargingProfilePurpose,
166 | chargingProfileKind,
167 | recurrencyKind,
168 | validFrom,
169 | validTo,
170 | chargingSchedule: {
171 | duration,
172 | startSchedule,
173 | chargingRateUnit,
174 | chargingSchedulePeriod,
175 | minChargingRate
176 | }
177 | }
178 | } = payload;
179 |
180 | let status = 'Accepted';
181 | if (chargingProfilePurpose === 'TxProfile') {
182 | // per page 20 under TxProfile in the specs
183 | let activeTx = getActiveTransaction();
184 | status = (activeTx) ? 'Accepted' : 'Rejected';
185 | }
186 | res = composeResponse(messageId, { status });
187 |
188 | addProfile({
189 | newProfile: payload,
190 | getChargingProfiles,
191 | setChargingProfiles
192 | });
193 |
194 | wsOcppClient.send(JSON.stringify(res), () => {
195 | addLog('REQ', res);
196 |
197 | let amp = DEFAULT_AMP;
198 | let composite = [];
199 | try {
200 | amp = getLimitNow({
201 | connectorId,
202 | chargingProfiles: getChargingProfiles(),
203 | cpMaxAmp: DEFAULT_AMP
204 | }) || DEFAULT_AMP;
205 | console.log('got amp limit', amp);
206 | composite = compositeSchedule({
207 | connectorId,
208 | chargingProfiles: getChargingProfiles(),
209 | cpMaxAmp: DEFAULT_AMP
210 | });
211 | console.log('composite schedule', JSON.stringify(composite, null, 4));
212 | } catch(e) {
213 | console.log('Error in getting limit', e);
214 | }
215 |
216 | // update amp limit and notify UI
217 | updateLimit(amp);
218 |
219 | // setup scheduler to notify UI when charging profile is done
220 | scheduler.updateSchedules(composite, updateLimit);
221 | });
222 | }
223 | break;
224 | case 'TriggerMessage':
225 | let implemented = triggerMessage.conf(payload);
226 | if (!implemented) {
227 | res = composeResponse(messageId, { status: 'NotImplemented' });
228 | wsOcppClient.send(JSON.stringify(res));
229 | } else {
230 | res = composeResponse(messageId, { status: 'Accepted' });
231 | wsOcppClient.send(JSON.stringify(res), () => {
232 | addLog('REQ', res);
233 | });
234 |
235 | setTimeoutPromise(5000).then(function respondToTrigger() {
236 | let action = [payload.requestedMessage];
237 | requestHandler(
238 | stationId,
239 | action,
240 | {
241 | ws: wsOcppClient,
242 | getMsgId,
243 | getQueue,
244 | addToQueue,
245 | getActiveTransaction,
246 | addLog,
247 | getLogs,
248 | authList,
249 | authCache
250 | },
251 | wsBrowser
252 | );
253 | });
254 | }
255 | break;
256 | default:
257 | console.log(`${action} not supported`);
258 | }
259 |
260 | // add some delay for the logs to be updated
261 | setTimeoutPromise(200).then(() => {
262 | wsBrowser.send(JSON.stringify(['OCPP', getLogs()]));
263 | })
264 | }
265 |
266 | /**
267 | * Send new current limit to the UI
268 | * @param {number} lim amp limit
269 | */
270 | function updateLimit(lim) {
271 | const limitNow = getLimit();
272 |
273 | if (limitNow === lim) {
274 | console.log('Limit not changed. No op.');
275 | return;
276 | }
277 |
278 | setLimit(lim); // update current limit
279 |
280 | // update meter
281 | if (getActiveTransaction()) {
282 | meter.finishLastMeterSession();
283 | meter.initNewMeterSession();
284 | }
285 |
286 | let powerLimit = parseFloat(Number(lim) * VOLTAGE / 1000).toFixed(3);
287 | wsBrowser.send(JSON.stringify(['SetChargingProfileConf', powerLimit]));
288 | }
289 |
290 | /**
291 | * Handle response from server to client request
292 | * @param {object} states
293 | * @param {object} setStates
294 | */
295 | function handleCallResult(states, setStates) {
296 | console.log('Handling call result');
297 |
298 | wsBrowser.send(JSON.stringify(['OCPP', getLogs()]));
299 |
300 | const [_, messageId, payload] = response;
301 | // req action waiting for conf
302 | const pending = states.queue.find(q => q.messageId === messageId);
303 |
304 | const handlerFns = callResulthandler(
305 | wsBrowser,
306 | pending,
307 | setStates,
308 | authCache,
309 | meter,
310 | { DEFAULT_AMP, getChargingProfiles, setChargingProfiles, setLimit }
311 | );
312 |
313 | handlerFns[pending.action](payload);
314 |
315 | setStates.popQueue(messageId.toString());
316 | }
317 |
318 | function handleCallError() {
319 | console.log('Handling call error');
320 |
321 | wsBrowser.send(JSON.stringify(['OCPP', getLogs()]));
322 | }
323 |
324 | return { handleCall, handleCallResult, handleCallError };
325 | }
326 |
327 | const callResulthandler = (
328 | wsBrowser,
329 | pending,
330 | setStates,
331 | authCache,
332 | meter,
333 | { DEFAULT_AMP, getChargingProfiles, setChargingProfiles, setLimit }
334 | ) => {
335 | const { action } = pending;
336 |
337 | return {
338 | 'Authorize': ({ idTagInfo }) => {
339 | const isAuthorized = idTagInfo.status === 'Accepted';
340 | if (isAuthorized) {
341 | // notify the UI
342 | wsBrowser.send(JSON.stringify([`${action}Conf`, isAuthorized]));
343 | }
344 |
345 | updateAuthorizationCache(authCache, pending.idTag, idTagInfo);
346 | },
347 | 'BootNotification': ({ currentTime, interval, status }) => {
348 | console.log('Received BootNotification conf', JSON.stringify({ currentTime, interval, status }));
349 | },
350 | 'DataTransfer': ({ status, data }) => {
351 | console.log('Received DataTransfer conf', JSON.stringify({ status, data }));
352 | },
353 | 'DiagnosticsStatusNotification': (conf) => {
354 | console.log('Received DiagnosticsStatusNotification conf', JSON.stringify(conf));
355 | },
356 | 'FirmwareStatusNotification': (conf) => {
357 | console.log('Received FirmwareStatusNotification conf', JSON.stringify(conf));
358 | },
359 | 'Heartbeat': ({ currentTime }) => {
360 | console.log('Received Heartbeat conf', JSON.stringify({ currentTime }));
361 | },
362 | 'MeterValues': (conf) => {
363 | console.log('Received MeterValues conf', JSON.stringify(conf));
364 | },
365 | 'StartTransaction': ({ idTagInfo, transactionId }) => {
366 | const isAccepted = idTagInfo.status === 'Accepted';
367 | if (isAccepted) {
368 | setStates.setActiveTransaction({ ...pending, transactionId });
369 |
370 | // start meter after conf
371 | meter.initNewMeterSession();
372 | }
373 | // notify the UI
374 | wsBrowser.send(JSON.stringify([`${action}Conf`, isAccepted]));
375 |
376 | updateAuthorizationCache(authCache, pending.idTag, idTagInfo);
377 | },
378 | 'StatusNotification': (conf) => {
379 | console.log('Received StatusNotification conf', JSON.stringify(conf));
380 | },
381 | 'StopTransaction': ({ idTagInfo }) => {
382 | const isAccepted = idTagInfo.status === 'Accepted';
383 | if (isAccepted) {
384 | setStates.setActiveTransaction(undefined);
385 |
386 | // clear TxProfiles after transaction
387 | removeTxProfile(setChargingProfiles);
388 | let amp = DEFAULT_AMP;
389 | try {
390 | // recalculate the limit after profile removal
391 | amp = getLimitNow({
392 | connectorId: 0,
393 | chargingProfiles: getChargingProfiles(),
394 | cpMaxAmp: DEFAULT_AMP
395 | }) || DEFAULT_AMP;
396 | } catch(e) {
397 | console.log('Error in getting limit', e);
398 | }
399 |
400 | setLimit(amp);
401 |
402 | console.log('Amp after stop tx', amp);
403 | }
404 | // notify the UI
405 | wsBrowser.send(JSON.stringify([`${action}Conf`, isAccepted]));
406 |
407 | updateAuthorizationCache(authCache, pending.idTag, idTagInfo);
408 | }
409 | };
410 | };
411 |
412 | /**
413 | * Update the authorization cache in AuthorizeConf, StartTransactionConf
414 | * and StopTransactionConf, per page 13 in the OCPP 1.6 spec.
415 | * @param {object} cache authorization cache
416 | * @param {string} idTag id tag
417 | * @param {object} idTagInfo given by the server
418 | */
419 | function updateAuthorizationCache(cache, idTag, idTagInfo) {
420 | cache.update(idTag, idTagInfo);
421 | console.log('Updated auth cache');
422 | console.log('Auth cache', JSON.stringify(cache.get()));
423 | }
424 |
425 | function composeResponse(messageId, payload) {
426 | const messageType = 3;
427 | const res = [messageType, messageId, payload];
428 |
429 | return res;
430 | }
431 |
432 | module.exports = responseHandler;
433 |
--------------------------------------------------------------------------------
/ocpp/chargingProfiles.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Handle charging profile composition in smart charging
3 | */
4 |
5 | const _ = require('lodash');
6 |
7 | const MAX_AMP = 30;
8 |
9 | /**
10 | * Add a new profile from server's `SetChargingProfile` request
11 | * @param {object} param0
12 | */
13 | function addProfile({
14 | newProfile,
15 | getChargingProfiles,
16 | setChargingProfiles
17 | }) {
18 | const {
19 | connectorId,
20 | csChargingProfiles: {
21 | stackLevel,
22 | chargingProfilePurpose
23 | }
24 | } = newProfile;
25 |
26 | // connectorId, stackLevel, chargingProfilePurpose
27 | const { [chargingProfilePurpose]: currentProfiles } = getChargingProfiles();
28 |
29 | const isNewStackLevel = currentProfiles.some(p =>
30 | p.connectorId === connectorId &&
31 | p.csChargingProfiles.stackLevel === stackLevel
32 | );
33 | const isNewPurpose = currentProfiles.some(p =>
34 | p.connectorId === connectorId &&
35 | p.csChargingProfiles.chargingProfilePurpose === chargingProfilePurpose
36 | );
37 |
38 | if (currentProfiles.length < 1 || (!isNewStackLevel && !isNewPurpose)) {
39 | setChargingProfiles(
40 | chargingProfilePurpose,
41 | [...currentProfiles, newProfile]
42 | );
43 | console.log('added new profile');
44 | console.log('profiles', JSON.stringify(getChargingProfiles(), null, 4));
45 | } else {
46 | let idx = currentProfiles.findIndex(p => {
47 | return (
48 | p.connectorId === connectorId &&
49 | p.csChargingProfiles.stackLevel === stackLevel &&
50 | p.csChargingProfiles.chargingProfilePurpose === chargingProfilePurpose
51 | );
52 | });
53 |
54 | if (idx > -1) {
55 | let profilesUpdated = [...currentProfiles];
56 | profilesUpdated[idx] = newProfile;
57 |
58 | setChargingProfiles(chargingProfilePurpose, profilesUpdated);
59 | console.log('second if');
60 | console.log('updated profile');
61 | console.log('profiles', JSON.stringify(getChargingProfiles(), null, 4));
62 | }
63 | }
64 | }
65 |
66 | /**
67 | * Remove a profile on `ClearChargingProfile` request from server
68 | *
69 | * @param {object} param0
70 | */
71 | function removeProfile({ response, getChargingProfiles, setChargingProfiles }) {
72 | const {
73 | connectorId,
74 | id: chargingProfileId
75 | } = response;
76 | const allProfiles = getChargingProfiles();
77 | let fromPurpose;
78 | Object.entries(allProfiles).forEach(([purpose, profiles]) => {
79 | let found = profiles.some(p =>
80 | p.connectorId === connectorId &&
81 | p.csChargingProfiles.chargingProfileId === chargingProfileId
82 | );
83 | if (found) {
84 | fromPurpose = purpose;
85 | }
86 | })
87 |
88 | if (fromPurpose) {
89 | let updated = allProfiles[fromPurpose].filter(p =>
90 | p.connectorId !== connectorId &&
91 | p.csChargingProfiles.chargingProfileId === chargingProfileId
92 | );
93 | setChargingProfiles(fromPurpose, updated);
94 |
95 | console.log('removed profile');
96 | console.log('profiles', JSON.stringify(getChargingProfiles(), null, 4));
97 | }
98 | }
99 |
100 | function removeTxProfile(setChargingProfiles) {
101 | setChargingProfiles('TxProfile', []);
102 | }
103 |
104 | /**
105 | * Calculate the limit at the moment
106 | *
107 | * @param {object} param0 connectorId and all charging profiles
108 | */
109 | function getLimitNow({ connectorId, chargingProfiles, cpMaxAmp }) {
110 | const composite = compositeSchedule({ connectorId, chargingProfiles, cpMaxAmp });
111 |
112 | const secondsFromStartOfDay = getSecondsFromStartOfDay();
113 | console.log(secondsFromStartOfDay);
114 | const idx = composite.findIndex(p => secondsFromStartOfDay <= p.ts);
115 | console.log(idx);
116 | let limit;
117 | const hasPrevIdx = idx >= 0;// - 1 >= 0;
118 | if (idx > -1 && hasPrevIdx) {
119 | // schedule is in effect now
120 | console.log(composite);
121 | limit = composite[idx].limit; // - 1].limit;
122 | } else if (idx === 0) {
123 | // schedule not started yet
124 | limit = undefined;
125 | } else if (composite.length > 0) {
126 | // schedule has finished
127 | limit = cpMaxAmp;
128 | }
129 |
130 | return limit;
131 | }
132 |
133 | /**
134 | * Create composite charging schedule from all valid charging profiles.
135 | *
136 | * @param {object} param0 connectorId, charging profiles and max amp
137 | */
138 | function compositeSchedule({ connectorId, chargingProfiles, cpMaxAmp }) {
139 |
140 | const {
141 | ChargePointMaxProfile,
142 | TxDefaultProfile,
143 | TxProfile
144 | } = chargingProfiles;
145 |
146 | // filter out expired profiles
147 | let chargePointMaxProfile = ChargePointMaxProfile.filter(p => {
148 | let validFrom = new Date(p.csChargingProfiles.validFrom).getTime();
149 | let validTo = new Date(p.csChargingProfiles.validTo).getTime();
150 | let now = Date.now();
151 |
152 | return now >= validFrom && now <= validTo;
153 | });
154 | let txDefaultProfile = TxDefaultProfile.filter(p => {
155 | let validFrom = new Date(p.csChargingProfiles.validFrom).getTime();
156 | let validTo = new Date(p.csChargingProfiles.validTo).getTime();
157 | let now = Date.now();
158 |
159 | return now >= validFrom && now <= validTo;
160 | });
161 | let txProfile = TxProfile.filter(p => {
162 | console.log(p.csChargingProfiles);
163 | let validFrom = new Date(p.csChargingProfiles.validFrom).getTime();
164 | let validTo = new Date(p.csChargingProfiles.validTo).getTime();
165 | let now = Date.now();
166 |
167 | return true; // now >= validFrom && now <= validTo; //assume it's always true for now
168 | });
169 |
170 | let merged;
171 |
172 | // get non-zero connector ids
173 | let connectorIds = [...new Set([
174 | ...txDefaultProfile.map(p => p.connectorId),
175 | ...txProfile.map(p => p.connectorId)
176 | ])].filter(id => id > 0);
177 |
178 | if (connectorId === 0) {
179 | // combine profile on each connector
180 | merged = combineConnectorProfiles({ connectorIds, txDefaultProfile, txProfile, cpMaxAmp });
181 | console.log('added', merged);
182 | } else {
183 | // get profiles for specific connector
184 | let defaultProfiles = txDefaultProfile.filter(p =>
185 | p.connectorId === connectorId ||
186 | p.connectorId === 0
187 | );
188 | let txProfiles = txProfile.filter(p => p.connectorId === connectorId);
189 |
190 | // stack profiles of the same purpose
191 | let stackedDefault = stacking(defaultProfiles);
192 | let stackedTx = stacking(txProfiles);
193 | // then combine TxProfile and TxDefaultProfile where TxProfile
194 | // overrules if they overlap
195 | merged = mergeTx(stackedDefault, stackedTx);
196 | }
197 |
198 | const stackedMax = stacking(chargePointMaxProfile);
199 |
200 | // combine max and tx profiles
201 | const composite = combining([...stackedMax, ...merged], cpMaxAmp);
202 |
203 | return composite;
204 | }
205 |
206 | function combineConnectorProfiles({ connectorIds, txDefaultProfile, txProfile, cpMaxAmp }) {
207 | let ids = [...connectorIds];
208 | const numOfConnectors = connectorIds.length;
209 | if (numOfConnectors === 0) {
210 | ids = [0];
211 | }
212 |
213 | let profiles = ids.map(function mergeTxForConnector(connectorId) {
214 | let defaultProfiles = txDefaultProfile.filter(p =>
215 | p.connectorId === connectorId ||
216 | p.connectorId === 0 // connectorId=0 applies to all connectors
217 | );
218 | let txProfiles = txProfile.filter(p => p.connectorId === connectorId);
219 | let stackedDefault = stacking(defaultProfiles);
220 | let stackedTx = stacking(txProfiles);
221 | let merged = mergeTx(stackedDefault, stackedTx);
222 |
223 | console.log(`merged connector ${connectorId}`, merged);
224 |
225 | return merged;
226 | });
227 |
228 | // convert profiles from absolute limits to differences relative to MAX_AMP.
229 | // +ve means limit relaxed, -ve means limit increased
230 | profiles = profiles.map(function profileDiff(profile) {
231 | let limit = cpMaxAmp; // for one connector
232 | let pDiff = profile.map(p => {
233 | let diff = {
234 | ...p,
235 | limit: p.limit === -1 ? cpMaxAmp - limit : p.limit - limit
236 | };
237 | limit = p.limit === -1 ? cpMaxAmp : p.limit; // update
238 | return diff;
239 | });
240 | return pDiff;
241 | });
242 |
243 | console.log('diffs', profiles)
244 |
245 | // collapse profiles into one array
246 | profiles = profiles.reduce((res, item) => {
247 | return [...res, ...item];
248 | }, []);
249 |
250 | profiles = _.sortBy(profiles, 'ts');
251 |
252 | // group by timestamp and sum by limit
253 | profiles = _(profiles)
254 | .groupBy('ts')
255 | .map((objs, key) => ({
256 | ts: Number(key),
257 | chargingProfilePurpose: objs[0].chargingProfilePurpose,
258 | limit: _.sumBy(objs, 'limit') // limits are additive
259 | }))
260 | .value();
261 |
262 | console.log('grouped', profiles)
263 |
264 | // convert differential limits back to absolute values
265 | let limit = cpMaxAmp; // on cp level
266 | profiles = profiles.map(p => {
267 | limit = (p.limit + limit > 0) ? (p.limit + limit) : 0;
268 | let abs = {
269 | ...p,
270 | limit: Math.min(limit, cpMaxAmp)
271 | };
272 | limit = Math.min(limit, cpMaxAmp); // update
273 | return abs;
274 | });
275 |
276 | profiles.forEach(p => {
277 | if (p.limit >= cpMaxAmp) {
278 | p.limit = -1; // -1 indicates unlimited
279 | }
280 | })
281 |
282 | return profiles;
283 | }
284 |
285 | /**
286 | * Stack charging profiles of the same purpose.
287 | * `StackLevel` determines the precedence (see section 3.13.2
288 | * Stacking charging profiles on pg 21)
289 | *
290 | * @param {array} profiles Charging profiles of the same purpose
291 | */
292 | function stacking(profiles=[]) {
293 | let stacked = [];
294 |
295 | const periods = extractPeriods(profiles);
296 |
297 | // determine precedence based on `stackLevel`
298 | let currentStackLevel = undefined;
299 | periods.forEach(p => {
300 | if (currentStackLevel === undefined) {
301 | currentStackLevel = p.stackLevel;
302 | stacked.push(p);
303 | } else {
304 | if (p.stackLevel < currentStackLevel) {
305 | currentStackLevel = p.stackLevel;
306 | stacked.push(p);
307 | } else if (p.stackLevel === currentStackLevel && p.limit === -1) {
308 | // here indicates the preceding profile is done
309 | currentStackLevel = undefined;
310 | stacked.push(p);
311 | }
312 | }
313 | })
314 |
315 | return stacked;
316 | }
317 |
318 | /**
319 | * Extract periods where `limit` is defined
320 | *
321 | * @param {array} profiles
322 | */
323 | function extractPeriods(profiles=[]) {
324 | let periods = [];
325 |
326 | profiles.forEach(p => {
327 | let {
328 | csChargingProfiles: {
329 | stackLevel,
330 | chargingProfilePurpose,
331 | chargingProfileKind,
332 | validTo,
333 | chargingSchedule: {
334 | duration,
335 | startSchedule,
336 | chargingSchedulePeriod
337 | }
338 | }
339 | } = p;
340 |
341 | chargingSchedulePeriod = _.sortBy(chargingSchedulePeriod, 'startPeriod');
342 |
343 | // handle scenario when multiple periods start at the same time
344 | chargingSchedulePeriod = aggregateByMin(chargingSchedulePeriod, 'ts', 'limit');
345 |
346 | let startHours = new Date(startSchedule).getHours();
347 | let startMinutes = new Date(startSchedule).getMinutes();
348 |
349 | chargingSchedulePeriod.forEach(csp => {
350 | let {
351 | startPeriod,
352 | numberPhases,
353 | limit
354 | } = csp;
355 |
356 | let ts;
357 | if (chargingProfileKind === 'Relative') {
358 | let secFromStartOfDay = getSecondsFromStartOfDay();
359 | ts = secFromStartOfDay + startPeriod;
360 | } else {
361 | // Absolute, Recurring
362 | ts = startHours*3600 + startMinutes*60 + startPeriod;
363 | }
364 |
365 | periods.push({
366 | // ts relative to the start of the day, in seconds
367 | ts,
368 | stackLevel,
369 | chargingProfilePurpose,
370 | numberPhases,
371 | limit
372 | });
373 | })
374 |
375 | // add one item for the end of all periods
376 | let ts;
377 | if (duration === 0) {
378 | // get end time from `validTo` if duration not provided
379 | let validToTs = new Date(validTo).getTime();
380 | let endOfDay = new Date();
381 | endOfDay.setHours(23, 59, 59, 999);
382 | if (validToTs >= endOfDay.getTime()) {
383 | ts = 24*3600 - 1; // end of day
384 | } else {
385 | let hrs = new Date(validTo).getHours();
386 | let mins = new Date(validTo).getMinutes();
387 | ts = hrs*3600 + mins*60;
388 | }
389 | } else {
390 | if (chargingProfileKind === 'Relative') {
391 | let secFromStartOfDay = getSecondsFromStartOfDay();
392 | ts = secFromStartOfDay + duration;
393 | } else {
394 | ts = startHours*3600 + startMinutes*60 + duration;
395 | }
396 | }
397 | periods.push({
398 | ts,
399 | stackLevel,
400 | chargingProfilePurpose,
401 | numberPhases: chargingSchedulePeriod[0].numberPhases,
402 | limit: -1 // unlimited
403 | });
404 | })
405 |
406 | periods = _.sortBy(periods, 'ts');
407 |
408 | return periods;
409 | }
410 |
411 | function getSecondsFromStartOfDay() {
412 | let now = new Date();
413 | let hours = now.getHours();
414 | let minutes = now.getMinutes();
415 | let seconds = now.getSeconds();
416 | let res = hours*3600 + minutes*60 + seconds;
417 | return res;
418 | }
419 |
420 | /**
421 | * Combine TxDefaultProfiles and TxProfiles. Per specs, TxProfile precededs
422 | * TxDefaultProfile if they occur at the same time.
423 | * @param {array} TxDefaultProfiles
424 | * @param {array} TxProfiles
425 | */
426 | function mergeTx(TxDefaultProfiles=[], TxProfiles=[]) {
427 | // collapse into one array
428 | const profiles = _.sortBy([...TxDefaultProfiles, ...TxProfiles], 'ts');
429 | let txOverruled = []; // result
430 | let limit, limitDefault = -1, limitTx = -1;
431 |
432 | profiles.forEach(p => {
433 | if (p.chargingProfilePurpose === 'TxDefaultProfile') {
434 | limitDefault = p.limit;
435 | } else if (p.chargingProfilePurpose === 'TxProfile') {
436 | limitTx = p.limit;
437 | }
438 |
439 | // use TxDefaultProfile if no TxProfile, otherwise always TxProfile
440 | limit = limitTx === -1 ? limitDefault : limitTx;
441 |
442 | txOverruled.push({
443 | ...p,
444 | limit,
445 | chargingProfilePurpose: 'Tx' // use one name for TxProfile and TxDefaultProfile
446 | })
447 | })
448 |
449 | return txOverruled;
450 | }
451 |
452 | /**
453 | * Combine max profile with tx profile (after combining TxProfile and
454 | * TxDefaultProfile). At each instance, the profile with the lowest amp/kw
455 | * precedes.
456 | *
457 | * @param {array} stackedProfiles stacked max profile and tx profile
458 | * @returns limits in absolute values
459 | */
460 | function combining(stackedProfiles=[], maxAmp=MAX_AMP) {
461 | let sorted = _.sortBy(stackedProfiles, 'ts');
462 | let combined = [];
463 | let limit;
464 | let limitMax = maxAmp, limitTx = maxAmp;
465 |
466 | sorted.forEach(p => {
467 | if (p.chargingProfilePurpose === 'ChargePointMaxProfile') {
468 | limitMax = p.limit === -1 ? maxAmp : p.limit;
469 | } else if (p.chargingProfilePurpose === 'Tx') {
470 | limitTx = p.limit === -1 ? maxAmp : p.limit;;
471 | }
472 |
473 | limit = limit
474 | ? Math.min(limitMax, limitTx)
475 | : p.limit;
476 |
477 | combined.push({
478 | ...p,
479 | limit,
480 | limitPrev: p.limit
481 | });
482 | })
483 |
484 | // clean up
485 | let filtered = [];
486 | combined.forEach(function removeDuplicatedLimit(p, idx) {
487 | if (idx === 0) {
488 | filtered.push(p);
489 | } else if (p.limit !== filtered[filtered.length - 1].limit) {
490 | filtered.push(p);
491 | }
492 | })
493 |
494 | // in case two profiles occur at the same time, choose the one
495 | // with lower limit
496 | filtered = aggregateByMin(filtered, 'ts', 'limit');
497 |
498 | return filtered;
499 | }
500 |
501 | function aggregateByMin(data=[], group='ts', min='limit') {
502 | const res = _(data)
503 | .groupBy(group)
504 | .map(objs => _.minBy(objs, min))
505 | .value();
506 |
507 | return res;
508 | }
509 |
510 | module.exports.stacking = stacking;
511 | module.exports.mergeTx = mergeTx;
512 | module.exports.combining = combining;
513 | module.exports.compositeSchedule = compositeSchedule;
514 | module.exports.combineConnectorProfiles = combineConnectorProfiles;
515 | module.exports.addProfile = addProfile;
516 | module.exports.getLimitNow = getLimitNow;
517 | module.exports.removeProfile = removeProfile;
518 | module.exports.removeTxProfile = removeTxProfile;
519 |
--------------------------------------------------------------------------------