├── server ├── sql │ ├── create_db.sql │ ├── grant_access.sql │ ├── add_group.sql │ ├── run-sql.sh │ └── smart_charging.sql ├── run-ocpp.sh ├── addons │ ├── smart-charging.h │ ├── smart-charging.c │ ├── mockup.sql │ └── README.md ├── docker-compose.yml ├── Dockerfile └── README.md ├── .gitignore ├── doc ├── app.png └── howitworks.png ├── config.js ├── data ├── users.js └── chargepoints.js ├── app ├── js │ ├── Button.js │ ├── App.js │ ├── Logs.js │ ├── Card.js │ └── Station.js └── index.html ├── ocpp ├── triggerMessage.js ├── sendLocalList.js ├── authorize.js └── chargingProfiles.js ├── package.json ├── LICENSE.md ├── src ├── ocpp.js ├── scheduler.js ├── authorizationList.js ├── ocppClient.js ├── requestHandler.js └── responseHandler.js ├── README.md ├── test ├── testSendLocalList.js ├── testAuthorize.js └── testComposite.js ├── client.js └── index.js /server/sql/create_db.sql: -------------------------------------------------------------------------------- 1 | create database ocpp; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | .vscode 4 | .DS_Store -------------------------------------------------------------------------------- /doc/app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZeMorfe/nodejs_ocpp16_client/HEAD/doc/app.png -------------------------------------------------------------------------------- /server/sql/grant_access.sql: -------------------------------------------------------------------------------- 1 | grant all on *.* to 'ocpp'@'172.18.0.3' identified by 'ocpp'; -------------------------------------------------------------------------------- /doc/howitworks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZeMorfe/nodejs_ocpp16_client/HEAD/doc/howitworks.png -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | OCPPServer: 'ws://localhost:8080/ocpp/1.6J' 3 | }; 4 | 5 | module.exports = config; 6 | -------------------------------------------------------------------------------- /data/users.js: -------------------------------------------------------------------------------- 1 | const USERS = [ 2 | { 3 | "idTag": "23F532C35" 4 | }, 5 | { 6 | "idTag": "829FEAB" 7 | } 8 | ]; 9 | -------------------------------------------------------------------------------- /server/run-ocpp.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # start Apache 4 | /etc/init.d/apache2 restart 5 | 6 | # run OCPP server 7 | cd /opt/ocpp/ 8 | ./ocpp_server -------------------------------------------------------------------------------- /server/sql/add_group.sql: -------------------------------------------------------------------------------- 1 | /* Create smart charging group */ 2 | insert into centralSmartChargingGroup(chargingProfileId) values (1); 3 | 4 | /* Add charge points to smart charging group */ 5 | insert into chargepointGroup(chargepointId, connectorId, groupId) values (1,1,1),(2,1,1); -------------------------------------------------------------------------------- /app/js/Button.js: -------------------------------------------------------------------------------- 1 | 2 | window.Button = ({ onClick, label, disabled = false }) => { 3 | return ( 4 | 12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /server/addons/smart-charging.h: -------------------------------------------------------------------------------- 1 | /* 2 | * Smart charging addons to OpenOCPP 3 | */ 4 | 5 | #include 6 | #include 7 | #include 8 | #include "mysql.h" 9 | 10 | #include "../utils/db.h" 11 | #include "../utils/ocpp-util.h" 12 | #include "../server/ocpp-server.h" 13 | 14 | extern MYSQL conn; 15 | 16 | int central_smart_charging(char *cmd, struct per_session_data__ocpp *pss); 17 | int drop_assigned_txprofile(struct per_session_data__ocpp *pss); 18 | -------------------------------------------------------------------------------- /ocpp/triggerMessage.js: -------------------------------------------------------------------------------- 1 | 2 | function conf({ connectorId, requestedMessage }) { 3 | let implemented = false; 4 | 5 | switch (requestedMessage) { 6 | case 'BootNotification': 7 | implemented = true; 8 | break; 9 | case 'DiagnosticsStatusNotification': 10 | case 'FirmwareStatusNotification': 11 | case 'Heartbeat': 12 | case 'MeterValues': 13 | case 'StatusNotification': 14 | default: 15 | console.log('Not implemented'); 16 | } 17 | 18 | return implemented; 19 | } 20 | 21 | module.exports.conf = conf; 22 | -------------------------------------------------------------------------------- /app/js/App.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const e = React.createElement; 4 | 5 | const layout = { 6 | display: 'grid', 7 | gridTemplateColumns: '50% 50%', 8 | gridColumnGap: '24px', 9 | width: '100%', 10 | }; 11 | 12 | const App = () => { 13 | return ( 14 |
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(, domContainer); 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "occp", 3 | "version": "1.0.0", 4 | "description": "test client ocpp", 5 | "main": "client.js", 6 | "dependencies": { 7 | "express": "^4.16.4", 8 | "loadash": "^1.0.0", 9 | "lodash": "^4.17.11", 10 | "node-schedule": "^1.3.2", 11 | "nodejs": "^0.0.0", 12 | "websocket": "^1.0.28", 13 | "ws": "^6.1.2" 14 | }, 15 | "devDependencies": { 16 | "chai": "^4.2.0", 17 | "mocha": "^6.0.2", 18 | "nodemon": "^1.18.10" 19 | }, 20 | "scripts": { 21 | "start": "nodemon index.js", 22 | "test": "mocha" 23 | }, 24 | "author": "Matth", 25 | "license": "ISC" 26 | } 27 | -------------------------------------------------------------------------------- /server/sql/run-sql.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Adapted from BCIT's OpenOCPP v1.1.1 4 | 5 | # timezone 6 | mysql_tzinfo_to_sql /usr/share/zoneinfo | mysql -uroot -proot mysql 7 | 8 | # populate tables and data 9 | echo create database ocpp | mysql -uroot -proot 10 | cat /sql/create.sql | mysql -uroot -proot ocpp 11 | cat /sql/alter_from_1.1.0.sql | mysql -uroot -proot ocpp 12 | cat /sql/enumerated.sql | mysql -uroot -proot ocpp 13 | cat /sql/setup_CPO.sql | mysql -uroot -proot ocpp 14 | cat /sql/smart_charging.sql | mysql -uroot -proot ocpp 15 | 16 | # grant access to ocpp-server 17 | # the IP address is the static address defined in docker-compose.yml 18 | mysql --user="root" --password="root" --database="ocpp" \ 19 | --execute="grant all on *.* to 'ocpp'@'172.18.0.3' identified by 'ocpp';" 20 | -------------------------------------------------------------------------------- /app/js/Logs.js: -------------------------------------------------------------------------------- 1 | 2 | window.Logs = ({ logs }) => { 3 | return ( 4 |
13 |
14 | OCPP logs 15 |
16 | { 17 | logs.map((log, idx) => 18 |
19 | {log} 20 |
21 | ) 22 | } 23 |
24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /ocpp/sendLocalList.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Update local authorization list 3 | * @param {object} authList list version and users on local list 4 | * @param {object} param1 payload from server's `SendLocalList.req` 5 | */ 6 | function conf(authList, { listVersion, localAuthorizationList, updateType }) { 7 | let updateStatus; 8 | 9 | switch (updateType) { 10 | case 'Full': 11 | updateStatus = authList.fullUpdate(listVersion, localAuthorizationList); 12 | break; 13 | case 'Differential': 14 | updateStatus = authList.differentialUpdate(listVersion, localAuthorizationList); 15 | break; 16 | default: 17 | console.log('Unknown update type. No op'); 18 | updateStatus = 'Failed'; 19 | } 20 | 21 | const payloadConf = { status: updateStatus }; 22 | 23 | return payloadConf; 24 | } 25 | 26 | module.exports.conf = conf; 27 | -------------------------------------------------------------------------------- /server/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | ocpp-server-image: 5 | build: . 6 | image: ocpp-server-image 7 | container_name: ocpp-server-image 8 | ocpp-mysql: 9 | container_name: ocpp-mysql 10 | image: mysql:5.7 11 | environment: 12 | - MYSQL_ROOT_PASSWORD=root 13 | networks: 14 | ocpp-network: 15 | ipv4_address: 172.18.0.2 16 | ports: 17 | - "3306:3306" 18 | volumes: 19 | - ./sql:/sql 20 | - ./sql/run-sql.sh:/docker-entrypoint-initdb.d/1-run-sql.sh 21 | - ./db:/var/lib/mysql 22 | ocpp-server: 23 | container_name: ocpp-server 24 | image: ocpp-server-image 25 | depends_on: 26 | - ocpp-server-image 27 | networks: 28 | ocpp-network: 29 | ipv4_address: 172.18.0.3 30 | ports: 31 | - "8080:8080" 32 | - "8000:8000" 33 | - "3000:3000" 34 | - "80:80" 35 | - "443:443" 36 | volumes: 37 | - ./ocpp:/ocpp 38 | command: bash -c "chmod +x /ocpp/run-ocpp.sh && /ocpp/run-ocpp.sh" 39 | 40 | networks: 41 | ocpp-network: 42 | ipam: 43 | driver: default 44 | config: 45 | - subnet: 172.18.0.0/16 -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2019 ZeMorfe, guanchenz 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 4 | 5 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 6 | 7 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 | 9 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 10 | 11 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /src/ocpp.js: -------------------------------------------------------------------------------- 1 | const MESSAGE_TYPE = { 2 | '2': 'CALL', 3 | '3': 'CALLRESULT', 4 | '4': 'CALLERROR' 5 | }; 6 | 7 | const ACTIONS_CORE = [ 8 | "Authorize", "BootNotification", "ChangeAvailability", 9 | "ChangeConfiguration", "ClearCache", "DataTransfer", 10 | "GetConfiguration", "Heartbeat", "MeterValues", 11 | "RemoteStartTransaction", "RemoteStopTransaction", 12 | "Reset", "StartTransaction", "StatusNotification", 13 | "StopTransaction", "UnlockConnector" 14 | ]; 15 | 16 | const ACTIONS_FIRMWARE_MANAGEMENT = [ 17 | "GetDiagnostics", "DiagnosticsStatusNotification", 18 | "FirmwareStatusNotification", "UpdateFirmware" 19 | ]; 20 | 21 | const ACTIONS_LOCAL_AUTH_LIST_MANAGEMENT = [ 22 | "GetLocalListVersion", "SendLocalList" 23 | ]; 24 | 25 | const ACTIONS_SMART_CHARGING = [ 26 | "TriggerMessage" 27 | ]; 28 | 29 | const VALID_ACTIONS = [ 30 | ...ACTIONS_CORE, 31 | ...ACTIONS_FIRMWARE_MANAGEMENT, 32 | ...ACTIONS_LOCAL_AUTH_LIST_MANAGEMENT, 33 | ...ACTIONS_SMART_CHARGING 34 | ]; 35 | 36 | const STOP_REASONS = [ 37 | 'DeAuthorized', 'EmergencyStop', 'EVDisconnected', 38 | 'HardReset', 'Local', 'Other', 'PowerLoss', 39 | 'Reboot', 'Remote', 'SoftReset', 'UnlockCommand' 40 | ]; 41 | 42 | const DIAGNOSTICS_STATUS = [ 43 | 'Idle', 'Uploaded', 'UploadFailed', 'Uploading' 44 | ]; 45 | 46 | const FIRMWARE_STATUS = [ 47 | 'Downloaded', 'DownloadFailed', 'Downloading', 48 | 'Idle', 49 | 'InstallationFailed', 'Installing', 'Installed' 50 | ]; 51 | 52 | module.exports.MESSAGE_TYPE = MESSAGE_TYPE; 53 | module.exports.VALID_ACTIONS = VALID_ACTIONS; 54 | module.exports.STOP_REASONS = STOP_REASONS; 55 | module.exports.DIAGNOSTICS_STATUS = DIAGNOSTICS_STATUS; 56 | module.exports.FIRMWARE_STATUS = FIRMWARE_STATUS; 57 | -------------------------------------------------------------------------------- /data/chargepoints.js: -------------------------------------------------------------------------------- 1 | const CP = [ 2 | { 3 | name: 'TESTCP1', 4 | user: 'TESTCP1', 5 | pass: 'TESTCP1', 6 | props: { 7 | chargePointSerialNumber: 'CP1', 8 | chargePointVendor: 'FutureCP', 9 | chargePointModel: 'm1', 10 | chargeBoxSerialNumber: 'CP1BOX1', 11 | firmwareVersion: '1.0.0' 12 | }, 13 | configurationKey: [ 14 | { 15 | key: 'ChargeProfileMaxStackLevel', 16 | readonly: true, 17 | value: 5 18 | }, 19 | { 20 | key: 'ChargingScheduleAllowedChargingRateUnit', 21 | readonly: true, 22 | value: ['Current', 'Power'] 23 | } 24 | ], 25 | ratings: { 26 | amp: 30, 27 | voltage: 208 28 | } 29 | }, 30 | { 31 | name: 'TESTCP2', 32 | user: 'TESTCP2', 33 | pass: 'TESTCP2', 34 | props: { 35 | chargePointSerialNumber: 'CP2', 36 | chargePointVendor: 'FutureCP', 37 | chargePointModel: 'm1', 38 | chargeBoxSerialNumber: 'CP2BOX1', 39 | firmwareVersion: '1.0.0' 40 | }, 41 | configurationKey: [ 42 | { 43 | key: 'ChargeProfileMaxStackLevel', 44 | readonly: true, 45 | value: 5 46 | }, 47 | { 48 | key: 'ChargingScheduleAllowedChargingRateUnit', 49 | readonly: true, 50 | value: ['Current', 'Power'] 51 | } 52 | ], 53 | ratings: { 54 | amp: 30, 55 | voltage: 208 56 | } 57 | } 58 | ]; 59 | 60 | module.exports = CP; 61 | -------------------------------------------------------------------------------- /app/js/Card.js: -------------------------------------------------------------------------------- 1 | 2 | window.Card = (props) => { 3 | const iconStyle = { 4 | color: props.charging ? '#4CAF50' : 'grey', 5 | marginLeft: '16px', 6 | fontSize: 36 7 | }; 8 | const headerStyle = { 9 | display: 'flex', 10 | flexDirection: 'row', 11 | alignItems: 'center' 12 | }; 13 | return ( 14 |
15 |
16 | {/*
*/} 17 |
18 |

19 | {props.header} ev_station 20 |   21 |

{props.power || 0} kW

22 |

23 |

24 | Powertech Test Site 25 |

26 |
27 |
28 | {props.status}, Charging: {props.charging ? 'Yes' : 'No'} 29 |
30 |
31 |
32 |
33 | {props.children} 34 |
35 |
36 |
37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /ocpp/authorize.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Authorize user based on local authorization cache 3 | * and list. See page 13 in the 1.6 spec for more details. 4 | * The list has higher priority than the cache. 5 | * 6 | * @param {object} param0 7 | */ 8 | function authorize({ idTag, authList, authCache }) { 9 | if (!idTag) { 10 | return false; 11 | } 12 | 13 | const isInList = authList.isOnLocal(idTag); 14 | const isExpiredInList = authList.isExpired(idTag); 15 | let isAuthorized = false; 16 | const isNewUser = !isInList; 17 | 18 | if (isNewUser) { 19 | isAuthorized = isValidInCache(idTag); 20 | if (!isAuthorized) { 21 | updateStatus(idTag, authCache); 22 | } 23 | } else { 24 | isAuthorized = !isExpiredInList; 25 | } 26 | 27 | function isValidInCache(idTag) { 28 | return isValidLocal(idTag, authCache); 29 | } 30 | 31 | function isValidLocal(idTag, auth) { 32 | const isIncluded = auth.isOnLocal(idTag); 33 | const isStatusValid = auth.isValid(idTag); 34 | const isExpired = auth.isExpired(idTag); 35 | 36 | if (isIncluded && isStatusValid && !isExpired) { 37 | return true; 38 | } else { 39 | return false; 40 | } 41 | } 42 | 43 | function updateStatus(idTag, auth) { 44 | const isIncluded = auth.isOnLocal(idTag); 45 | const isStatusValid = auth.isValid(idTag); 46 | const isExpired = auth.isExpired(idTag); 47 | 48 | if (isIncluded && isStatusValid && isExpired) { 49 | // update `status` in `idTagInfo` 50 | let user = auth.get().find(u => u.idTag === idTag) || {}; 51 | let { idTagInfo } = user; 52 | auth.update(idTag, { ...idTagInfo, status: 'expired' }); 53 | } 54 | } 55 | 56 | return isAuthorized; 57 | } 58 | 59 | module.exports = authorize; 60 | -------------------------------------------------------------------------------- /server/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:16.04 2 | 3 | EXPOSE 8000 4 | EXPOSE 8080 5 | EXPOSE 80 6 | EXPOSE 443 7 | 8 | RUN apt-get update && \ 9 | apt-get -y upgrade 10 | RUN DEBIAN_FRONTEND=noninteractive apt-get -y install apache2 wget \ 11 | make mercurial gcc libmodbus-dev libjson-c-dev libhpdf-dev \ 12 | libmysqlclient-dev openssl libssl-dev cmake unzip php-db \ 13 | php-pear g++ flex bison \ 14 | git 15 | RUN pear install DB && \ 16 | pear install mail 17 | 18 | RUN sed -i 's|#LogLevel info ssl:warn|\n\tOptions +Indexes +FollowSymLinks +MultiViews +ExecCGI\n\tAllowOverride All\n\tOrder allow,deny\n\tallow from all\n\nRedirectMatch 404 .db|' /etc/apache2/sites-available/000-default.conf && \ 19 | sed -i 's|#AddHandler cgi-script .cgi|AddHandler cgi-script .cgi|' /etc/apache2/mods-enabled/mime.conf 20 | 21 | RUN a2enmod cgi && /etc/init.d/apache2 restart 22 | 23 | 24 | # LIBWEBSOCKETS 2.4 25 | 26 | RUN apt-get -y remove libwebsockets-dev 27 | RUN git clone https://github.com/warmcat/libwebsockets 28 | WORKDIR /libwebsockets 29 | RUN git reset --hard v3.0.0 30 | RUN mkdir build 31 | WORKDIR /libwebsockets/build 32 | RUN cmake .. && make && make install && ldconfig 33 | 34 | # SOAP, if needed 35 | # WORKDIR / 36 | # RUN wget https://sourceforge.net/projects/gsoap2/files/latest/download && \ 37 | # unzip download 38 | # WORKDIR /gsoap-2.8 39 | # RUN ./configure && make && make install 40 | 41 | WORKDIR / 42 | 43 | COPY ./ocpp /ocpp 44 | WORKDIR /ocpp 45 | 46 | RUN sed -i "s|// Directories.|define ('WEB_SITE', 'http://localhost');|" evportal/php/incl.php 47 | 48 | RUN mkdir evportal/html && \ 49 | mkdir evportal/html/_cgi && \ 50 | mkdir evportal/html/_cgi/plugshare 51 | 52 | RUN make && make install 53 | 54 | RUN cp -r opt/ocpp /opt/ocpp && \ 55 | rm /var/www/html/index.html && \ 56 | cp -r evportal/php /var/www/php && \ 57 | cp -r evportal/html/* /var/www/html/ && \ 58 | cp -r evportal/www/* /var/www/html/ 59 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OCPP 1.6 client 2 | 3 | An OCPP client implementaiton in node.js for testing purposes. 4 | 5 | ## OCPP profiles 6 | 7 | - Core 8 | - Smart Charging 9 | 10 | ## Operations 11 | 12 | - all operations initiated by charge point 13 | - operations initiated by central system: 14 | - `ClearChargingProfile` 15 | - `GetCompositeSchedule` 16 | - `GetConfiguration` 17 | - `GetLocalListVersion` 18 | - `RemoteStopTransaction` 19 | - `SendLocalList` 20 | - `SetChargingProfile` 21 | - `TriggerMessage` for `BootNotification` 22 | 23 | ## Run 24 | 25 | 1. `npm install` 26 | 1. modify the following config and data files 27 | 28 | - `config.js` 29 | - `data/users.js` 30 | - `data/chargepoints.js` 31 | 32 | 3. `npm start` 33 | 4. open `localhost:5000` on your browser 34 | 35 | ![app](doc/app.png) 36 | 37 | ## How it works 38 | 39 | The OCPP client (charge point) is a websocket client to the OCPP server (central system). The OCPP client also hosts a websocket server for the web interface. 40 | 41 | Requests are initiated from the interface, sent to the OCPP client, and relayed to the OCPP server. Responses from the server are sent down to the client first and the client notifies the interface for UI updates. 42 | 43 | ![howitworks](doc/howitworks.png) 44 | 45 | Requests from client to server are handled in `src/requestHandler.js`. Responses from server to client are handled in `src/responseHandler.js`. New operation and modification can be added to these two files as these handlers are separated from the websocket client. 46 | 47 | ## Smart Charging 48 | 49 | Client-side support for smart charging is implemented in `ocpp/chargeingProfiles`. `ChargePointMaxProfile` and `TxDefaultProfile`/`TxProfile` are combined to derive the composite profile. The instantaneous limit is calculated from the composite profile. 50 | 51 | Server-side smart charging can be found [here](server/addons/README.md). The implementation is based on BCIT's OpenOCPP v1.1.1, and it requires modification on its source code. -------------------------------------------------------------------------------- /app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | OCPP Client 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 |
17 |
18 |
19 | menu 20 | OCPP client simulator 21 |
22 |
23 |
24 |
25 |
26 | 27 |
28 | 29 |
30 |
31 |
32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /test/testSendLocalList.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const authorizationList = require('../src/authorizationList'); 3 | const sendLocalList = require('../ocpp/sendLocalList'); 4 | 5 | 6 | describe('SendLocalList Conf', () => { 7 | let authList; 8 | 9 | beforeEach(() => { 10 | authList = authorizationList({ type: 'list', version: 1 }); 11 | const users = [ 12 | { 13 | idTag: '920EB', 14 | idTagInfo: { 15 | expiryDate: new Date(new Date().getTime() + 3*60*60*1000).toISOString(), 16 | parentIdTag: 5, 17 | status: 'Accepted' 18 | } 19 | }, 20 | { 21 | idTag: '63ela', 22 | idTagInfo: { 23 | expiryDate: new Date(new Date().getTime() + 2*60*60*1000).toISOString(), 24 | parentIdTag: 5, 25 | status: 'Accepted' 26 | } 27 | } 28 | ]; 29 | users.forEach(u => authList.add(u.idTag, u.idTagInfo)); 30 | }); 31 | 32 | it('full update', () => { 33 | let user = { 34 | idTag: '8EBAFJ', 35 | idTagInfo: { 36 | expiryDate: new Date(new Date().getTime() + 3*60*60*1000).toISOString(), 37 | parentIdTag: 5, 38 | status: 'Accepted' 39 | } 40 | }; 41 | let localAuthorizationList = [user]; 42 | 43 | let payload = { 44 | listVersion: 1, 45 | localAuthorizationList, 46 | updateType: 'Full' 47 | }; 48 | 49 | sendLocalList.conf(authList, payload); 50 | 51 | assert(authList.get()[0].idTag === user.idTag); 52 | }); 53 | 54 | it('Differential update', () => { 55 | let user = { 56 | idTag: '63ela', 57 | idTagInfo: { 58 | expiryDate: new Date(new Date().getTime() + 3*60*60*1000).toISOString(), 59 | parentIdTag: 5, 60 | status: 'Blocked' 61 | } 62 | }; 63 | let localAuthorizationList = [user]; 64 | 65 | let payload = { 66 | listVersion: 1, 67 | localAuthorizationList, 68 | updateType: 'Differential' 69 | }; 70 | 71 | sendLocalList.conf(authList, payload); 72 | 73 | assert(authList.get()[1].idTagInfo.status === 'blocked'); 74 | }); 75 | }) -------------------------------------------------------------------------------- /server/README.md: -------------------------------------------------------------------------------- 1 | # OCPP Server 2 | 3 | This is a modification of BCIT's [OpenOCPP](http://files.mgrid-bcit.ca/download/evid.php) v1.1.1. 4 | 5 | The modification runs the MySQL DB and OCPP server separately on two docker containers. The modification aims to provide easier installation and deployment of OpenOCPP and to add smart charging functionality. See [here](./addons/README.md) for more information about the smart charging implementation. 6 | 7 | To install: 8 | 9 | 1. Download the source code (v1.1.1) from BCIT: http://files.mgrid-bcit.ca/download/evid.php and put the `ocpp` folder here. 10 | 1. Move the `addons` folder to `ocpp/source`. 11 | 1. Add `addons/smart-charging.o` to the end of line 17 in `ocpp/source/makefile` to include smart charging in the `ocpp_server` binary 12 | 1. Add smart charging function in `ocpp/source/server/ocpp-server-ocpp.c`: 13 | 14 | ```c 15 | #include "../addons/smart-charging.h" 16 | 17 | ... 18 | 19 | // add this in the `LWS_CALLBACK_SERVER_WRITEABLE` callback 20 | // after the first `lws_write` 21 | central_smart_charging(cmds[i].cmd, pss); 22 | 23 | ... 24 | 25 | // add this in the `LWS_CALLBACK_RECEIVE` callback 26 | // in the `MessageCallResult` case after the `callresults` function 27 | if (!strcasecmp(callresults[i].cmd, OCPP_CLEARCHARGINGPROFILE)) { 28 | drop_assigned_txprofile(pss); 29 | } 30 | ``` 31 | 5. Copy `run-ocpp.sh` to `ocpp/`. 32 | 1. Update the db host name in `ocpp/evportal/php/incl.php` and `ocpp/config_files/evportal.db` from `localhost` to `ocpp-mysql` 33 | 1. Copy `create.sql`, `alter_from_1.1.0.sql`, `enumerated.sql`, `setup_CPO.sql` from `ocpp/sql` to the `sql` folder on root. Note you may need to modify the `create.sql` script to avoid some timestamp error when creating tables. 34 | 1. Create a folder named `db` on root for data persistance. 35 | 1. Install Docker and Docker Compose 36 | 1. Run `docker-compose up` to start the MySQL db and OCPP server 37 | 1. Go to `localhost/admin.cgi` on your browser for the admin portal. Log in to create charge points, users and charging profiles. 38 | 1. Add a smart charging group using e.g. MySQL workbench (the MySQL container exposes port 3306. See `sql/add_group.sql` for how to add new groups). You may need to look up `chargepointId`, `connectorId` and `chargingProfileId` from the tables `chargepoint` and `chargingProfile`. 39 | 40 | The server exposes port 8080 for websocket connections. 41 | 42 | Notes: 43 | 44 | - If you see error related to SOAP or client during docker build and if you don't need SOAP, remomve all the commands related to SOAP and client in the makefiles 45 | - If the server you setup does not respond to the message from client, try install _libwebsockets_ from git 46 | -------------------------------------------------------------------------------- /src/scheduler.js: -------------------------------------------------------------------------------- 1 | const schedule = require('node-schedule'); 2 | 3 | /** 4 | * Scheduler for updating charging limit based on the composite 5 | * charging profile. 6 | */ 7 | function scheduler() { 8 | /** 9 | * sample composite schedule [ 10 | { 11 | "ts": 7200, 12 | "chargingProfilePurpose": "Tx", 13 | "limit": 10, 14 | "limitPrev": 10 15 | }, 16 | { 17 | "ts": 10800, 18 | "chargingProfilePurpose": "Tx", 19 | "limit": 30, 20 | "limitPrev": -1 21 | } 22 | ] 23 | */ 24 | 25 | let schedules = []; 26 | 27 | function getSchedules() { 28 | return schedules; 29 | } 30 | 31 | function updateSchedules(compositeProfile=[], cb=()=>{}) { 32 | let existingKeys = schedules.map(s => s.key); 33 | let newProfiles = compositeProfile.filter(p => { 34 | let key = `${p.ts}-${p.limit}`; 35 | return !existingKeys.includes(key); 36 | }); 37 | 38 | newProfiles.forEach(p => { 39 | let hours = Math.floor(p.ts / 3600); 40 | let minutes = Math.floor(p.ts % 3600 / 60); 41 | if (hours > 23) { 42 | hours = 23; 43 | minutes = 59; 44 | } 45 | // scheduler on each day at hours:minutes 46 | let sch = schedule.scheduleJob(`${minutes} ${hours} * * *`, function() { 47 | console.log('Schedule', p.ts, p.limit); 48 | cb(p.limit); // update limit and notify UI 49 | }); 50 | if (sch) { 51 | schedules.push({ 52 | key: `${p.ts}-${p.limit}`, 53 | schedule: sch 54 | }); 55 | } 56 | }) 57 | 58 | console.log('Updated schedules', JSON.stringify(schedules, null, 4)); 59 | } 60 | 61 | function removeSchedules(compositeProfile) { 62 | if (!compositeProfile) { 63 | // if composite profile is null or undefined, 64 | // assume it's an error and do not cancel schedule 65 | return; 66 | } 67 | 68 | let existingKeys = compositeProfile.map(p => `${p.ts}-${p.limit}`); 69 | let removedSchedule = schedules.filter(s => !existingKeys.includes(s.key)); 70 | 71 | removedSchedule.forEach(s => { 72 | s.schedule.cancel(); 73 | console.log('Cancelled schedule', s.key); 74 | }); 75 | 76 | // update schedule list 77 | schedules = schedules.filter(s => existingKeys.includes(s.key)); 78 | 79 | console.log('schedules', JSON.stringify(schedules, null, 4)); 80 | } 81 | 82 | return { updateSchedules, getSchedules, removeSchedules }; 83 | } 84 | 85 | module.exports = scheduler; 86 | -------------------------------------------------------------------------------- /server/addons/smart-charging.c: -------------------------------------------------------------------------------- 1 | /* 2 | * Smart charging per section 3.13 in the OCPP 1.6 specs. 3 | * 4 | * The implementation only covers the Central Smart Charging use case 5 | * (3.13.4 Central Smart Charging) where the central system assigns charging profile 6 | * on the chargepoint level. 7 | * 8 | * The implementation assumes chargepoints share the same circuit are stored in 9 | * the same `smart charging group` on the central system. 10 | * 11 | * Central Smart Charging starts when all the chargepoints in a smart charging group 12 | * are in use. Central Smart Charging stops when at least one chargepoint stops the 13 | * transaction. 14 | * 15 | * The charging profiles are `TxProfile` typed, meaning they are deleted, per the 16 | * OCPP specs, by `setChargingProfile` requests from the server, from each 17 | * chargepoint after the transaction is done. 18 | * 19 | * The implementation is built on top of BCIT's OpenOCPP v1.1.1. Changes may be 20 | * required for a different version of OpenOCPP. 21 | */ 22 | 23 | #include "smart-charging.h" 24 | 25 | 26 | int central_smart_charging(char *cmd, struct per_session_data__ocpp *pss) { 27 | int transactionId; 28 | char sql_buffer[128]; 29 | 30 | json_object *jobj= json_tokener_parse(pss->Q[pss->Q_a].payload); 31 | get_int_from_json(jobj, OCPP_TRANSACTIONID, &transactionId); 32 | 33 | printf("Central Smart Charging input\ncmd %s, txId: %d\n", cmd, transactionId); 34 | 35 | if (!strcasecmp(cmd, OCPP_STARTTRANSACTION)) { 36 | printf("Starting smart charging\n"); 37 | strncpy(sql_buffer, "call CENTRAL_SMART_CHARGING_ALL_GROUPS()", sizeof(sql_buffer)); 38 | sql_execute(sql_buffer); 39 | } else if (!strcasecmp(cmd, OCPP_STOPTRANSACTION)) { 40 | printf("Clearing charging profile\n"); 41 | sprintf(sql_buffer, "call CENTRAL_SMART_CHARGING_CLEAR(%d)", transactionId); 42 | sql_execute(sql_buffer); 43 | } 44 | 45 | memset(sql_buffer, 0, sizeof(sql_buffer)); 46 | 47 | return(0); 48 | } 49 | 50 | /* 51 | * Delete assigned profile (`TxProfile` typed) in `chargingProfileAssigned` 52 | * when a transaction finishes. 53 | */ 54 | int drop_assigned_txprofile(struct per_session_data__ocpp *pss) { 55 | char cp[40]; // HTTP_CP 56 | int connectorId; 57 | int chargingProfileId; 58 | json_object *payload; 59 | char sql_buffer[128]; 60 | 61 | printf("Drop assigned profile\n"); 62 | 63 | strncpy(cp, pss->CP, sizeof(cp)); 64 | payload = json_tokener_parse(pss->Q[pss->Q_a].payload); 65 | get_int_from_json(payload, OCPP_CONNECTORID, &connectorId); 66 | get_int_from_json(payload, "id", &chargingProfileId); 67 | 68 | printf("cp %s\n", cp); 69 | printf("payload %s\n", pss->Q[pss->Q_a].payload); 70 | printf("connectorId %d, chargingProfileId %d\n", connectorId, chargingProfileId); 71 | 72 | sprintf( 73 | sql_buffer, 74 | "call CENTRAL_SMART_CHARGING_DROP_ASSIGNED_TXPROFILE(\"%s\", %d, %d)", 75 | cp, connectorId, chargingProfileId 76 | ); 77 | sql_execute(sql_buffer); 78 | 79 | memset(sql_buffer, 0, sizeof(sql_buffer)); 80 | memset(cp, 0, sizeof(cp)); 81 | 82 | return(0); 83 | } 84 | -------------------------------------------------------------------------------- /server/addons/mockup.sql: -------------------------------------------------------------------------------- 1 | /* dummy values */ 2 | insert into centralSmartChargingGroup(chargingProfileId) values (2); 3 | insert into chargepointGroup(chargepointId, groupId) values (1,1),(3,1); 4 | 5 | /* from OpenOCPP 1.1.1 */ 6 | DROP TABLE IF EXISTS dummyOutboundRequest; 7 | create table dummyOutboundRequest ( 8 | `outboundRequestId` INTEGER NOT NULL AUTO_INCREMENT, 9 | `requestTypeId` INTEGER NOT NULL, 10 | `chargepointId` INTEGER NOT NULL, 11 | `connectorId` INTEGER(3) UNSIGNED DEFAULT 0, 12 | `chargingProfileId` INTEGER, 13 | `transactionId` INTEGER, 14 | `added` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 15 | `modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, 16 | PRIMARY KEY (`outboundRequestId`) 17 | ); 18 | 19 | /* from OpenOCPP 1.1.1 */ 20 | drop table if exists dummyTransactionLog; 21 | create table dummyTransactionLog ( 22 | `transactionLogId` INTEGER NOT NULL AUTO_INCREMENT, 23 | `customerId` INTEGER DEFAULT 0, 24 | `chargepointId` INTEGER NOT NULL DEFAULT 0, 25 | `portId` INTEGER NOT NULL, 26 | `vehicleId` INTEGER, 27 | `idtag` VARCHAR(20) NOT NULL, 28 | `reservationId` INTEGER, 29 | `meterStart` VARCHAR(40), 30 | `timestampStart` TIMESTAMP DEFAULT CURRENT_TIMESTAMP NULL, 31 | `timestampFinish` TIMESTAMP DEFAULT '1970-01-01 00:00:00', 32 | `meterStop` VARCHAR(40), 33 | `timestampStop` TIMESTAMP DEFAULT '1970-01-01 00:00:00' NULL, 34 | `terminateReasonId` INTEGER NOT NULL DEFAULT 1, 35 | `notified` INTEGER DEFAULT NULL, 36 | `notifiedBilling` INTEGER DEFAULT NULL, 37 | `price` FLOAT(8,2), 38 | `tax1` FLOAT(8,2) NOT NULL DEFAULT 0.00, 39 | `tax2` FLOAT(8,2) NOT NULL DEFAULT 0.00, 40 | `tax3` FLOAT(8,2) NOT NULL DEFAULT 0.00, 41 | `statementId` INTEGER, 42 | PRIMARY KEY (`transactionLogId`) 43 | ); 44 | insert into 45 | dummyTransactionLog(chargepointId,portId,vehicleId,idtag) 46 | values 47 | (1,1,1,'dummyIdTag'), 48 | (3,2,1,'dummyIdTag'); 49 | 50 | drop table if exists dummyChargingProfileAssigned; 51 | CREATE TABLE `dummyChargingProfileAssigned` ( 52 | `chargingProfileAssignedId` INTEGER NOT NULL AUTO_INCREMENT, 53 | `chargingProfileId` INTEGER NOT NULL, 54 | `chargepointId` INTEGER NOT NULL DEFAULT 0, 55 | `connectorId` INTEGER(3) UNSIGNED NOT NULL DEFAULT 0, 56 | `added` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 57 | `modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, 58 | PRIMARY KEY (`chargingProfileAssignedId`), 59 | CONSTRAINT `dummy_constraint` UNIQUE (`chargepointId`, `connectorId`, `chargingProfileAssignedId`) 60 | ); 61 | 62 | /* from OpenOCPP 1.1.1 */ 63 | DROP PROCEDURE IF EXISTS `DUMMY_SET_CHARGING_PROFILE`; 64 | DELIMITER $$ 65 | CREATE PROCEDURE `DUMMY_SET_CHARGING_PROFILE` ( 66 | IN cp VARCHAR(20),IN connectorId INT,IN profileId INT, IN transactionId INT, 67 | OUT status VARCHAR(20)) 68 | 69 | BEGIN 70 | SET status=''; 71 | INSERT INTO 72 | dummyOutboundRequest (requestTypeId,chargepointId,connectorId,chargingProfileId,transactionId) 73 | values (requestTypeId('SetChargingProfile'),chargepointId(cp),connectorId,profileId,transactionId); 74 | SELECT LAST_INSERT_ID() into status; 75 | END; 76 | $$ 77 | DELIMITER ; -------------------------------------------------------------------------------- /server/addons/README.md: -------------------------------------------------------------------------------- 1 | # Smart charging 2 | 3 | Smart charging implementation on BCIT's OpenOCPP v1.1.1. Download OpenOCPP from [here](http://files.mgrid-bcit.ca/download/evid.php). 4 | 5 | The implementation only covers the _Central Smart Charging_ scenario (see OCPP 1.6 specs for more details) when the central system balances the load between Level-2 charge points using `TxProfile` typed charging profiles. 6 | 7 | Smart charging is implemented in SQL procedures and the C code only invokes the procedures. This allows live update of the procedures without recompiling the OpenOCPP server. 8 | 9 | ## How it works 10 | 11 | Logics are implemented in `sql/smart_charging.sql` as sql procedures. `ocpp-server-ocpp.c` invokes the procedures in LWS writeable and receive callbacks. 12 | 13 | The number of active transactions and number of connectors in a smart charging group determine when to start smart charging. You need to create a smart charging group with a `TxProfile` to be applied to each connector in the group. E.g. if you have two single-port charge points in the group, the limit of the profile should be half of the total limit. 14 | 15 | The `TxProfile` should have a relative start time (i.e. starts when a transaction starts). The duration can be 0 since the profile will be deleted after the transaction. You need to define a charging period for the limit. The period can have a delayed start time but it should be 0 if you want start the limit right after the transaction starts. 16 | 17 | In `StartTransaction` event, the server checks if all the connectors in the smart charging group are in use. If so, it adds two `SetChargingProfile` requests to the `outboundRequest` table (one request for each charge point). On the next LWS writeable event, it sends the `SetChargingProfile` requests to the OCPP client. 18 | 19 | In `StopTransaction` event, if the connectors have ongoing TxProfile, the server adds two `ClearChargingProfile` requests to `outboundRequest` which are sent to the OCPP client on the next LWS writeable callback. 20 | 21 | Once the client acknowledges the `ClearChargingProfile` request and responds with `ClearChargingProfile` confirmation, the server removes the assigned TxProfile from the table `chargingProfileAssigned`. Charging profiles, e.g. max or default profiles, in `chargingProfileAssigned` will be sent to the OCPP client when the client reconnects to the server. 22 | 23 | The OCPP client stores all the assigned charging profiles and computes the composite profile that combines `ChargePointMaxProfile`, `TxDefaultProfile` and `TxProfile`. Real-time charging limit, e.g. amperage, is the limit in the composite profile at the current time. The client sets up schedulers for all the limit changes in the composite profile. E.g. if you setup a default profile with aboslute start time at 9am, a scheduler will update the limit at 9am. 24 | 25 | ## Add smart charging group 26 | 27 | 1. Create two charge points and users and a `TxProfile` from the admin portal `localhost/admin.cgi`. The `TxProfile` should have a relative start time so it's applied right after a transaction starts. The duration can be 0 since the profile will be cleared after the transaction. You also need to create a charging period with a current limit (amperage). The start time of the period should be 0 if you want the limit to apply at the start of the transaction. 28 | 1. Look up `chargepointId`, `connectorId` and `chargingProfileId` from the tables `chargepoint` and `chargingProfile`. 29 | 1. Create a new smart charging group following the example in `sql/add_group.sql`. The charge point connectors in the same group are assumed to, virtually, share the same circuit. Smart charging is only triggered when all the charge point connectors in a group are in use. 30 | -------------------------------------------------------------------------------- /client.js: -------------------------------------------------------------------------------- 1 | var WebSocket = require('ws'); 2 | const url = 'ws://192.234.196.158:8080/ocpp/1.6J/AAE00488 ';//CPG'; 3 | 4 | 5 | 6 | var auth = "Basic "+ Buffer.from("AAE00488:TempPassword").toString('base64');//("cpg:powertech").toString('base64'); 7 | 8 | console.log(auth); 9 | var header = { "Authorization":auth }; 10 | 11 | var msgId = 1; 12 | var idTag = "App-apVkYs5n4/meo";//"00001234"; // 13 | 14 | ws = new WebSocket(url, "ocpp1.6", {headers : { Authorization: auth } }); 15 | 16 | ws.on('open', function open() { 17 | msgId = msgId + 1; 18 | var bootNot = [2,msgId.toString(),"BootNotification",{"chargePointSerialNumber":"CPG","chargePointVendor":"Matth","chargePointModel":"Ghost 1","chargeBoxSerialNumber":"CPG01","firmwareVersion":"1.0.0"}]; 19 | ws.send(JSON.stringify(bootNot), function ack(error) { 20 | console.log(error); 21 | /* 22 | */ 23 | //"StartTransaction", {"connectorId": "1", "idTag": 24 | }); 25 | }); 26 | 27 | ws.on("message", function incoming(data) { 28 | console.log(JSON.parse(data)); 29 | if (JSON.parse(data)[2].status == "Accepted") { 30 | msgId = msgId + 1; 31 | var currentDate = new Date(); 32 | var stpTra = [2, msgId.toString(), "StopTransaction", {"connectorId":"1", "meterStop":1200, "idTag":idTag, "timestamp":currentDate.toISOString(), "transactionId": 44}]; 33 | ws.send(JSON.stringify(stpTra), function ack(error) { 34 | var snVal = [2, msgId.toString(), "StatusNotification", {"connectorId":"1", "errorCode":"NoError", "status":"Finishing"}]; 35 | ws.send(JSON.stringify(snVal), function ack(error) { 36 | var snVal = [2, msgId.toString(), "StatusNotification", {"connectorId":"1", "errorCode":"NoError", "status":"Available"}]; 37 | ws.send(JSON.stringify(snVal), function ack(error) { 38 | 39 | }); 40 | }); 41 | });/* 42 | var ocppAuth = [2, msgId.toString(), "Authorize", {"idTag":"00001234"}];//"App-apVkYs5n4/meo"}]; 43 | ws.send(JSON.stringify(ocppAuth), function ack(error) { 44 | msgId = msgId + 1; 45 | var currentDate = new Date(); 46 | var startTra = [2, msgId.toString(), "StartTransaction", {"connectorId":"1", "meterStart":"0", "idTag":idTag, "reservationId":0, "timestamp":currentDate.toISOString()}]; 47 | ws.send(JSON.stringify(startTra), function ack(error) { 48 | msgId = msgId + 1; 49 | var snVal = [2, msgId.toString(), "StatusNotification", {"connectorId":"1", "errorCode":"NoError", "status":"Charging"}]; 50 | 51 | ws.send(JSON.stringify(snVal), function ack(error) { 52 | var meterValue = 0.0; 53 | var metValue = setInterval(function met() { 54 | msgId = msgId + 1; 55 | meterValue = meterValue + 200; 56 | console.log(meterValue); 57 | if (meterValue < 100) { 58 | var metVal = [2, msgId.toString(), "MeterValues", {"connectorId":"1", "transactionId": 20, "meterValue":meterValue}]; 59 | ws.send(JSON.stringify(metVal), function ack(error) { }); 60 | } 61 | else { 62 | var currentDate = new Date(); 63 | var stpTra = [2, msgId.toString(), "StopTransaction", {"connectorId":"1", "meterStop":meterValue, "idTag":idTag, "timestamp":currentDate.toISOString(), "transactionId": 1}]; 64 | ws.send(JSON.stringify(stpTra), function ack(error) { 65 | var snVal = [2, msgId.toString(), "StatusNotification", {"connectorId":"1", "errorCode":"NoError", "status":"Finishing"}]; 66 | ws.send(JSON.stringify(snVal), function ack(error) { 67 | var snVal = [2, msgId.toString(), "StatusNotification", {"connectorId":"1", "errorCode":"NoError", "status":"Available"}]; 68 | ws.send(JSON.stringify(snVal), function ack(error) { 69 | 70 | }); 71 | }); 72 | }); 73 | } 74 | }, 60000); 75 | }); 76 | }); 77 | });*/ 78 | } 79 | }); 80 | 81 | const interval = setInterval(function ping() { 82 | msgId = msgId + 1; 83 | var ocppHB = [2, msgId.toString(), "Heartbeat", {}]; 84 | ws.send(JSON.stringify(ocppHB), function ack(error) { 85 | console.log(error); 86 | }); 87 | }, 300000); 88 | -------------------------------------------------------------------------------- /src/authorizationList.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Mock-up of authorization list/cache. 3 | * See page 13 of the 1.6 spec for more details. 4 | */ 5 | 6 | const statusMap = { 7 | /** 8 | * Definitions per page 13 of the OCPP 1.6 spec. 9 | * Use the same definitions for both list and cache. 10 | */ 11 | 'Accepted': 'valid', 12 | 'ConcurrentTx': 'valid', 13 | 'Expired': 'expired', 14 | 'Blocked': 'blocked', 15 | 'Invalid': 'blacklisted' 16 | }; 17 | 18 | function authorizationList({ type = 'list', version = 1, MAX_LENGTH = 10 }) { 19 | let list = { version, users: [] }; 20 | 21 | function getVersion() { 22 | // see section 5.10 on page 49 in the spec 23 | return (list.users.length > 0) ? list.version : 0; 24 | } 25 | 26 | function setVersion(v) { 27 | list.version = v; 28 | } 29 | 30 | function get() { 31 | return [...list.users]; 32 | } 33 | 34 | function add(idTag, { expiryDate, parentIdTag, status }) { 35 | let newUser = { 36 | idTag, 37 | idTagInfo: { 38 | expiryDate, 39 | parentIdTag, 40 | status: statusMap[status] 41 | } 42 | }; 43 | 44 | if (list.users.length >= MAX_LENGTH) { 45 | handleListOverflow(); 46 | } 47 | 48 | list.users.push(newUser); 49 | console.log(`Added user to authorization ${type}`); 50 | } 51 | 52 | function update(idTag, idTagInfo) { 53 | let user = list.users.find(u => u.idTag === idTag); 54 | if (user) { 55 | user.idTagInfo = { 56 | ...idTagInfo, 57 | status: statusMap[idTagInfo.status] || idTagInfo.status 58 | }; 59 | console.log(`Updated user in authorization ${type}`); 60 | } else { 61 | add(idTag, idTagInfo); 62 | } 63 | } 64 | 65 | function fullUpdate(newVersion, newList) { 66 | // per page 94-95 in the OCPP 1.6 spec 67 | if (newVersion !== list.version) { 68 | return 'VersionMismatch'; 69 | } 70 | if (list.version === -1) { 71 | return 'NotSupported'; 72 | } 73 | 74 | try { 75 | list.version = newVersion; 76 | list.users = [...newList]; 77 | console.log('Local authorization list updated'); 78 | return 'Accepted'; 79 | } catch (e) { 80 | console.log(e); 81 | return 'Failed'; 82 | } 83 | } 84 | 85 | function differentialUpdate(newVersion, differentialList) { 86 | // per page 94-95 in the OCPP 1.6 spec 87 | if (newVersion !== list.version) { 88 | return 'VersionMismatch'; 89 | } 90 | if (list.version === -1) { 91 | return 'NotSupported'; 92 | } 93 | 94 | try { 95 | list.version = newVersion; 96 | differentialList.forEach(userNext => { 97 | update(userNext.idTag, userNext.idTagInfo); 98 | }) 99 | console.log('Local authorization list updated'); 100 | return 'Accepted'; 101 | } catch (e) { 102 | console.log(e); 103 | return 'Failed'; 104 | } 105 | } 106 | 107 | function handleListOverflow() { 108 | let invalidUserIdx = list.users.findIndex(u => u.idTagInfo.status !== 'valid'); 109 | 110 | if (invalidUserIdx > -1) { 111 | list.users.splice(invalidUserIdx, 1); 112 | } else { 113 | let inactiveUserIdx = list.users.reduce((res, user, idx, arr) => { 114 | let earliestExpiryDate = new Date(arr[res].idTagInfo.expiryDate); 115 | if (new Date(user.idTagInfo.expiryDate) < earliestExpiryDate) { 116 | return idx; 117 | } else { 118 | return res; 119 | } 120 | }, 0); 121 | list.users.splice(inactiveUserIdx, 1); 122 | } 123 | } 124 | 125 | function remove(idTag) { 126 | let userIdx = list.users.findIndex(u => u.idTag === idTag); 127 | if (userIdx > -1) { 128 | list.users.splice(userIdx, 1); 129 | console.log(`Removed user from authorization ${type}`); 130 | } 131 | } 132 | 133 | function isExpired(idTag) { 134 | const now = new Date(); 135 | const user = list.users.find(u => u.idTag === idTag); 136 | if (!user) { 137 | return true; 138 | } 139 | 140 | return new Date(user.idTagInfo.expiryDate) < now; 141 | } 142 | 143 | function isValid(idTag) { 144 | const user = list.users.find(u => u.idTag === idTag); 145 | 146 | return user && user.idTagInfo.status === 'valid'; 147 | } 148 | 149 | function isOnLocal(idTag) { 150 | return list.users.some(u => u.idTag === idTag); 151 | } 152 | 153 | return { 154 | getVersion, 155 | get, 156 | add, 157 | update, 158 | fullUpdate, 159 | differentialUpdate, 160 | remove, 161 | isExpired, 162 | isOnLocal, 163 | isValid 164 | }; 165 | }; 166 | 167 | module.exports = authorizationList; 168 | -------------------------------------------------------------------------------- /app/js/Station.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const e = React.createElement; 4 | 5 | const actionMap = { 6 | 'authorize': 'Authorize', 7 | 'boot': 'BootNotification', 8 | 'start': 'StartTransaction', 9 | 'stop': 'StopTransaction', 10 | 'unlock': 'UnlockConnector', 11 | 'data transfer': 'DataTransfer', 12 | 'diagnostics': 'DiagnosticsStatusNotification', 13 | 'firmware': 'FirmwareStatusNotification', 14 | 'heartbeat': 'Heartbeat', 15 | 'meter': 'MeterValues', 16 | 'status': 'StatusNotification', 17 | }; 18 | 19 | function composeMessage(action, stationId=0) { 20 | const idTag = USERS[stationId].idTag; // global var 21 | let message; 22 | switch (action) { 23 | case 'Authorize': 24 | message = [action, { idTag }]; 25 | break; 26 | case 'StartTransaction': 27 | message = [action, { connectorId: 1, idTag }]; 28 | break; 29 | case 'StopTransaction': 30 | // need client to handle transactionId 31 | message = [action, { connectorId: 1, idTag, reason: 'Local' }]; 32 | break; 33 | case 'DataTransfer': 34 | case 'DiagnosticsStatusNotification': 35 | case 'FirmwareStatusNotification': 36 | case 'Heartbeat': 37 | case 'MeterValues': 38 | case 'StatusNotification': 39 | default: 40 | message = [action]; 41 | } 42 | 43 | return message; 44 | } 45 | 46 | window.Station = ({ stationProps, stationId }) => { 47 | const [socket, setSocket] = React.useState(); 48 | const [online, setOnline] = React.useState(); 49 | const [authorized, setAuthorized] = React.useState(false); 50 | const [message, setMessage] = React.useState(); 51 | const [logs, setLogs] = React.useState([]); 52 | const [charging, setCharging] = React.useState(false); 53 | const [limit, setLimit] = React.useState(undefined); 54 | 55 | const handleClick = (event) => { 56 | console.log(event.target.value); 57 | const value = event.target.value.toLowerCase(); 58 | const action = actionMap[value]; 59 | const message = composeMessage(action, stationId); 60 | 61 | send(message); 62 | } 63 | 64 | const send = (message) => { 65 | console.log('sending message', message); 66 | socket.send(JSON.stringify(message)); 67 | }; 68 | 69 | React.useEffect(() => { 70 | const ws = new WebSocket('ws://localhost:5050/simulator' + stationId); 71 | setSocket(ws); 72 | 73 | ws.onopen = () => { 74 | console.log('Connection open'); 75 | setOnline(true); 76 | }; 77 | ws.onmessage = (message) => { 78 | let data = JSON.parse(message.data) 79 | console.log('From client server', data); 80 | setMessage(data); 81 | 82 | let [messageType, payload] = data; 83 | 84 | switch (messageType) { 85 | case 'OCPP': 86 | setLogs(payload); 87 | break; 88 | case 'AuthorizeConf': 89 | setAuthorized(payload); 90 | break; 91 | case 'StartTransactionConf': 92 | setCharging(payload); 93 | break; 94 | case 'StopTransactionConf': 95 | setCharging(!payload); 96 | if (payload) { 97 | setAuthorized(false); 98 | } 99 | break; 100 | case 'SetChargingProfileConf': 101 | setLimit(payload); 102 | break; 103 | } 104 | }; 105 | ws.onclose = () => { 106 | console.log('Connection closed'); 107 | setOnline(false); 108 | }; 109 | 110 | return () => { ws.close() }; 111 | }, []); 112 | 113 | const maxPower = stationProps.ratings.amp * stationProps.ratings.voltage / 1000; 114 | 115 | const status = { 116 | header: stationProps.name, 117 | status: `Status: ${online ? 'online' : 'offline'}`, 118 | charging, 119 | power: (((limit === undefined || limit === null) ? maxPower : Number(limit))) * Number(charging) 120 | }; 121 | 122 | return ( 123 |
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 | --------------------------------------------------------------------------------