├── .env.example ├── .gitattributes ├── .gitignore ├── .gitmodules ├── Car.js ├── LICENSE ├── README.md ├── config-wizard.sh ├── config └── config.example.json ├── db.sql ├── db_mysql.sql ├── docker ├── .env.example ├── Dockerfile-app ├── Dockerfile-web ├── docker-compose.yml └── docker-start.sh ├── docs ├── beginnerguide.md ├── certbot.sh ├── config.md ├── docker.md ├── img │ ├── idView.png │ ├── widget.png │ ├── widget_and_chargingOverview.png │ ├── widgetcharging.png │ └── widgets.png ├── install.sh ├── ioswidget.md └── update.sh ├── public ├── DatabaseConnection.php ├── carPicture.php ├── carStatus.php ├── chargingSessionDataProvider.php ├── chargingSessions.php ├── env.php ├── idView │ ├── carGraphData.php │ ├── carGraphDataProvider.php │ ├── chargingOverview.html │ ├── css │ │ ├── chargingOverview.css │ │ ├── datepicker.css │ │ ├── idView.css │ │ ├── pageNavigation.css │ │ └── theme.css │ ├── idView.php │ └── js │ │ ├── AnimatedValue.js │ │ ├── DoughnutValue.js │ │ ├── SelectableList.js │ │ ├── chargingOverview.js │ │ ├── idView.js │ │ └── pageNavigation.js ├── index.php └── login │ ├── login.php │ ├── loginCheck.php │ └── logon.php ├── src ├── Autoloader.php ├── utils │ ├── ErrorUtils.php │ ├── Logger.php │ └── QueryCreationHelper.php ├── vwid │ ├── CarPictureHandler.php │ ├── CarStatusFetcher.php │ ├── CarStatusUpdateReceiver.php │ ├── CarStatusWriter.php │ ├── CarStatusWrittenUpdateReceiver.php │ ├── Main.php │ ├── Server.php │ ├── api │ │ ├── API.php │ │ ├── LoginInformation.php │ │ ├── MobileAppAPI.php │ │ ├── WebsiteAPI.php │ │ └── exception │ │ │ ├── IDAPIException.php │ │ │ ├── IDAuthorizationException.php │ │ │ └── IDLoginException.php │ ├── chargesession │ │ ├── ChargeSession.php │ │ └── ChargeSessionHandler.php │ ├── db │ │ ├── DBmigrator.php │ │ └── DatabaseConnection.php │ ├── integrations │ │ └── ABRP.php │ └── wizard │ │ ├── ConfigWizard.php │ │ ├── InteractiveWizard.php │ │ └── SetupWizard.php └── webutils │ ├── CurlError.php │ ├── CurlWrapper.php │ ├── Form.php │ └── HTTPUtils.php └── start.sh /.env.example: -------------------------------------------------------------------------------- 1 | DB_HOST="" 2 | DB_NAME="" 3 | DB_USER="" 4 | DB_PASSWORD=null 5 | DB_DRIVER="pgsql" -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.sh text eol=lf -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | vendor/ 3 | data/ 4 | config.json 5 | .env 6 | log/ -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "public/idView/ChartJS"] 2 | path = public/idView/ChartJS 3 | url = https://github.com/robske110/php-chartjs.git 4 | -------------------------------------------------------------------------------- /Car.js: -------------------------------------------------------------------------------- 1 | // Variables used by Scriptable. 2 | // These must be at the very top of the file. Do not edit. 3 | // icon-color: deep-green; icon-glyph: car; 4 | // CONFIGURATION 5 | 6 | const baseURL = "" 7 | const apiKey = "" 8 | 9 | const rangeInMiles = false //set to true to show range in miles 10 | const showFinishTime = true //set to false to hide charge finish time 11 | const forceImageRefresh = false //set to true to refresh the image 12 | 13 | const exampleData = false 14 | 15 | const timetravel = null; //set to a unix timestamp to emulate the script being run at that time (seconds!) 16 | 17 | const socThreshold = 95 //not implemented 18 | 19 | // WIDGET VERSION: v0.0.7-InDev 20 | 21 | // Created by robske_110 24.01.2020 22 | // This script is originally inspired from https://gist.github.com/mountbatt/772e4512089802a2aa2622058dd1ded7 23 | 24 | let scriptRun = new Date() 25 | if(timetravel !== null){ 26 | scriptRun = new Date(timetravel*1000); 27 | } 28 | 29 | 30 | // Translations 31 | const translations = { 32 | en: { 33 | chargeStatus: { 34 | disconnected: "Disconnected", 35 | holdingCharge: "holding charge", 36 | connected: "connected", 37 | charging: "charging…" 38 | }, 39 | soc: "SOC", 40 | range: "Range", 41 | targetSOC: "Target SOC", 42 | hvac: "HVAC", 43 | hvacStatus: { 44 | heating: "heating", 45 | cooling: "cooling", 46 | ventilation: "ventilating", 47 | off: "off" 48 | } 49 | }, 50 | de: { 51 | chargeStatus: { 52 | disconnected: "Entkoppelt", 53 | holdingCharge: "Ladezustand halten", 54 | connected: "Verbunden", 55 | charging: "Lädt…" 56 | }, 57 | soc: "Ladezustand", 58 | range: "Reichweite", 59 | targetSOC: "Zielladung", 60 | hvac: "Klimaanlage", 61 | hvacStatus: { 62 | heating: "Heizen", 63 | cooling: "Kühlen", 64 | ventilation: "Lüften", 65 | off: "Aus" 66 | } 67 | } 68 | } 69 | 70 | function getTranslatedText(key){ 71 | let lang = Device.language(); 72 | let translation = translations[lang]; 73 | if(translation == undefined){ 74 | translation = translations.en; 75 | } 76 | let nested = key.split("."); 77 | key.split(".").forEach(function(element){ 78 | translation = translation[element]; 79 | }); 80 | return translation; 81 | } 82 | 83 | let widget = await createWidget() 84 | 85 | // present the widget in app 86 | if (!config.runsInWidget) { 87 | await widget.presentMedium() 88 | } 89 | Script.setWidget(widget) 90 | Script.complete() 91 | 92 | // adds a vertical stack to widgetStack 93 | function verticalStack(widgetStack){ 94 | let stack = widgetStack.addStack() 95 | stack.layoutVertically() 96 | return stack 97 | } 98 | 99 | // adds a value - title pair 100 | function addFormattedData(widgetStack, dataTitle, dataValue){ 101 | let stack = widgetStack.addStack() 102 | stack.layoutVertically() 103 | const label = stack.addText(dataTitle) 104 | label.font = Font.mediumSystemFont(12) 105 | const value = stack.addText(dataValue) 106 | value.font = Font.boldSystemFont(16) 107 | } 108 | 109 | // build the widget 110 | async function createWidget() { 111 | let widget = new ListWidget() 112 | const data = await getData() 113 | 114 | widget.setPadding(20, 15, 20, 15) //top, leading, bottom, trailing 115 | widget.backgroundColor = Color.dynamic(new Color("eee"), new Color("111")) 116 | 117 | const wrap = widget.addStack() 118 | //wrap.centerAlignContent() 119 | wrap.spacing = 15 120 | 121 | const carColumn = verticalStack(wrap) 122 | 123 | carColumn.addSpacer(5) 124 | 125 | const carImage = await getImage( 126 | baseURL.substr(baseURL.indexOf("://")+3).replaceAll("/", "_")+"-car.png", 127 | baseURL+"/carPicture.php?key="+apiKey) 128 | carColumn.addImage(carImage) 129 | 130 | //carColumn.addSpacer(5) 131 | 132 | let chargeStatus 133 | 134 | switch (data.plugConnectionState){ 135 | case "disconnected": 136 | chargeStatus = "⚫ "+getTranslatedText("chargeStatus.disconnected") 137 | break; 138 | case "connected": 139 | //widget.refreshAfterDate = new Date(Date.now() + 300) //increase refresh rate? 140 | switch (data.chargeState){ 141 | case "chargePurposeReachedAndNotConservationCharging": 142 | case "notReadyForCharging": 143 | case "readyForCharging": 144 | chargeStatus = "🟠 "+getTranslatedText("chargeStatus.connected") 145 | break; 146 | case "chargePurposeReachedAndConservation": 147 | chargeStatus = "🟢 "+getTranslatedText("chargeStatus.holdingCharge") 148 | break; 149 | case "charging": 150 | chargeStatus = "⚡ "+getTranslatedText("chargeStatus.charging") 151 | break; 152 | default: 153 | chargeStatus = "unknown cS: "+data.chargeState 154 | } 155 | let plugLockStatus; 156 | switch (data.plugLockState){ 157 | case "locked": 158 | plugLockStatus = " (🔒)" 159 | break; 160 | case "unlocked": 161 | plugLockStatus = " (🔓)" 162 | break; 163 | case "invalid": 164 | plugLockStatus = " (❌)" 165 | break; 166 | default: 167 | plugLockStatus = "unknown pLS: "+data.plugConnectionState 168 | break; 169 | } 170 | chargeStatus = chargeStatus + plugLockStatus; 171 | break; 172 | default: 173 | chargeStatus = "unknown pCS: "+data.plugConnectionState+" cS: "+data.chargeState 174 | } 175 | 176 | //const chargeInfo = verticalStack(carColumn) 177 | //chargeInfo.setPadding(0,10,0,10) 178 | const chargeInfo = carColumn 179 | 180 | let dF = new DateFormatter() 181 | dF.useNoDateStyle() 182 | dF.useShortTimeStyle() 183 | 184 | chargeStatus = chargeInfo.addText(chargeStatus) 185 | chargeStatus.font = Font.regularSystemFont(10) 186 | chargeInfo.addSpacer(5) 187 | let dataTimestamp = null; 188 | if(!Number.isNaN(Date.parse(data.time))){ 189 | dataTimestamp = new Date(Date.parse(data.time)); 190 | } 191 | if(data.chargeState === "charging" || data.chargeState === "chargePurposeReachedAndConservation"){ 192 | let realRemainChgTime = data.remainingChargingTime; 193 | let finishTime = "" 194 | if(dataTimestamp != null){ 195 | realRemainChgTime -= (scriptRun.getTime() - dataTimestamp.getTime()) / 60000; 196 | realRemainChgTime = Math.max(0, realRemainChgTime); 197 | finishTime = " ("+dF.string(new Date(dataTimestamp.getTime() + realRemainChgTime * 60000))+")"; 198 | } 199 | let timeStr = Math.floor(realRemainChgTime / 60) + ":" + String(Math.round(realRemainChgTime % 60)).padStart(2, '0') + "h" 200 | let chargeStateLabel = chargeInfo.addText(data.chargePower + " kW | " + timeStr + (showFinishTime ? finishTime : "")) 201 | chargeStateLabel.font = Font.regularSystemFont(10) 202 | }else{ 203 | chargeInfo.addSpacer(10) 204 | } 205 | 206 | const dataCol1 = verticalStack(wrap) 207 | 208 | addFormattedData(dataCol1, getTranslatedText("soc"), data.batterySOC.toString()+"%") 209 | dataCol1.addSpacer(10) 210 | let range 211 | if(!rangeInMiles){ 212 | range = data.remainingRange+"km"; 213 | }else{ 214 | range = Math.round(data.remainingRange/1.609344)+"mi"; 215 | } 216 | addFormattedData(dataCol1, getTranslatedText("range"), range) 217 | 218 | const dataCol2 = verticalStack(wrap) 219 | 220 | addFormattedData(dataCol2, getTranslatedText("targetSOC"), data.targetSOC+"%") 221 | dataCol2.addSpacer(10) 222 | let hvacStatus; 223 | switch (data.hvacState){ 224 | case "heating": 225 | hvacStatus = getTranslatedText("hvacStatus.heating"); 226 | break; 227 | case "cooling:": 228 | hvacStatus = getTranslatedText("hvacStatus.cooling"); 229 | break; 230 | case "ventilation": 231 | hvacStatus = getTranslatedText("hvacStatus.ventilation"); 232 | break; 233 | case "off": 234 | hvacStatus = getTranslatedText("hvacStatus.off"); 235 | break; 236 | default: 237 | hvacStatus = "unknown hS: "+date.hvacState; 238 | } 239 | addFormattedData(dataCol2, getTranslatedText("hvac"), hvacStatus+" ("+data.hvacTargetTemp+"°C)") 240 | 241 | timedebug = widget.addText("carUpdate "+(dataTimestamp == null ? data.time : dF.string(dataTimestamp))+" (widget "+dF.string(scriptRun)+")") 242 | timedebug.font = Font.lightSystemFont(8) 243 | timedebug.textColor = Color.dynamic(Color.lightGray(), Color.darkGray()) 244 | timedebug.rightAlignText() 245 | return widget; 246 | } 247 | 248 | 249 | // fetch data 250 | async function getData() { 251 | let state 252 | if(exampleData || baseURL == ""){ 253 | state = {}; 254 | state["batterySOC"] = "40" 255 | state["remainingRange"] = "150" 256 | state["remainingChargingTime"] = "61" 257 | state["chargeState"] = "charging" 258 | state["chargePower"] = "100" 259 | state["targetSOC"] = "100" 260 | state["plugConnectionState"] = "connected" 261 | state["plugLockState"] = "locked" 262 | state["hvacState"] = "heating" 263 | state["hvacTargetTemp"] = "21.5" 264 | 265 | state["time"] = "simulated" 266 | }else{ 267 | state = getJSON() 268 | } 269 | 270 | /*let currentDate = ; 271 | let newDate = new Date((new Date).getTime()+1000); 272 | chargeReached = new Notification() 273 | chargeReached.identifier = "SoCReached" 274 | chargeReached.title = "ID.3 🔋 Geladen" 275 | chargeReached.body = "Die Batterie ist zu " + socThreshold + "% geladen!" 276 | chargeReached.sound = "complete" 277 | chargeReached.setTriggerDate(newDate) 278 | chargeReached.schedule()*/ 279 | 280 | return state 281 | } 282 | 283 | async function getJSON(){ 284 | url = baseURL+"/carStatus.php?key="+apiKey 285 | if(timetravel !== null){ 286 | url += "&at=@"+timetravel 287 | } 288 | req = new Request(url) 289 | req.method = "GET" 290 | apiResult = await req.loadString() 291 | console.log(apiResult) 292 | return JSON.parse(apiResult) 293 | } 294 | 295 | 296 | // get images from local filestore or download them once 297 | // credits: https://gist.github.com/marco79cgn (for example https://gist.github.com/marco79cgn/c3410c8ecc8cb0e9f87409cee7b87338#file-ffp2-masks-availability-js-L234) 298 | async function getImage(imageName, imgUrl){ 299 | let fm = FileManager.local() 300 | let dir = fm.documentsDirectory() 301 | let path = fm.joinPath(dir, imageName) 302 | if(fm.fileExists(path) && !forceImageRefresh){ 303 | return fm.readImage(path) 304 | }else{ 305 | // download once 306 | let iconImage = await loadImage(imgUrl) 307 | fm.writeImage(path, iconImage) 308 | return iconImage 309 | } 310 | } 311 | 312 | async function loadImage(imgUrl){ 313 | console.log("fetching_pic"); 314 | const req = new Request(imgUrl) 315 | return await req.loadImage() 316 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # IDDataLogger 2 | 3 | Welcome to IDDataLogger, a data logger for Volkswagen ID vehicles. 4 | Features include: 5 | - A website displaying current status, history graphs and previous charging sessions. 6 | - An [iOS widget](https://github.com/robske110/IDDataLogger/blob/master/docs/ioswidget.md) (using Scriptable) 7 | - [A Better Route Planner live data integration](https://github.com/robske110/IDDataLogger/wiki/ABRP-integration) 8 | - An easy-to-use API for integration with other systems. If you are interested see [here](https://github.com/robske110/IDDataLogger/wiki/API-reference). 9 | 10 |

11 | 12 | 13 |

14 | 15 | ## Setup 16 | 17 | ### Setup for beginners 18 | 19 | You have never set up something similar? Don't worry, the [beginners guide](docs/beginnerguide.md) guides you through every step you need to take. 20 | Common problems and answers to frequently asked questions can be found [here](https://github.com/robske110/IDDataLogger/wiki/FAQ-and-frequent-problems). 21 | Should you have any remaining questions or issues please see [getting help](https://github.com/robske110/IDDataLogger/wiki/Getting-help). 22 | 23 | ### Setup for advanced users 24 | 25 | You can install this software using docker with the instructions [here](docs/docker.md). 26 | Alternatively continue with the instructions below for installing on a system directly: 27 | 28 | #### Prerequisites 29 | 30 | - PHP 8 cli with pdo-pgsql (or pdo-mysql), curl, gd, pcntl and dom 31 | - A webserver serving .php files (PHP 8 with pdo-pgsql (or pdo-mysql)) 32 | - (strongly recommended) HTTPS enabled server with certificate 33 | - A PostgreSQL server (Any version from 9 and up should work, although testing has only been done on 11 and up) 34 | - alternatively MySQL / MariaDB is supported, but PostgreSQL is recommended. 35 | 36 | #### Overview of the setup process 37 | 38 | Looking at the automated install script for debian [install.sh](docs/install.sh) alongside the following instructions 39 | might be helpful. (Instructions for using it can be found [here](docs/beginnerguide.md#installing-using-the-install-script)) 40 | 41 | Clone this repository. 42 | 43 | `git clone https://github.com/robske110/IDDataLogger.git --recursive` 44 | 45 | Create a database (and a user) in your PostgreSQL (or other) server for this project and fill in the details into 46 | `config/config.example.json` and `.env.example.` We'll need these files later. 47 | You can do this using the config setup wizard by running the `config-wizard.sh` script, or manually. 48 | Note: for a detailed description of the possible config values visit [config.md](docs/config.md). 49 | 50 | After creating the config.json from config.example.json run `./start.sh`. 51 | The necessary tables in the database will be automatically created. 52 | After a successful connection to the db, the setup wizard will help you create an API key for the widget and a user for 53 | the website. You can create additional API keys or add additional users at any time using `./start.sh --wizard`. 54 | 55 | All files in the `public` directory of this repository must now be placed somewhere in the webroot. 56 | It is recommended to place them in the second level (not directly in webroot). 57 | 58 | Then copy the `.env` file (created from `.env.example`) outside the webroot with the db credentials set in it. 59 | 60 | Note: 61 | `env.php` looks for a `.env` file two folders up from its location. 62 | (If you put the contents of the public folder in `/path/to/webroot/vwid/` it will look in `/path/to/.env`) 63 | If you place the files deeper inside the webroot, please consider editing env.php and configuring the correct path in 64 | the first line. It is strongly recommended keeping the .env file out of the webroot. 65 | 66 | You can alternatively set the environment variables through your webserver. (Or anything else that populates php's `$_ENV`) 67 | 68 | You now need to set up your system to automatically start `start.sh` on system start. Using systemd is recommended. 69 | 70 | You can now visit idView.php or use the iOS widget after [setting it up](docs/ioswidget.md)! 71 | 72 | #### Updating 73 | 74 | To update the software at a later data execute `git pull && git submodule update` in the repository directory and 75 | replace the files in the webroot with the new contents of the `public` folder. Make sure to restart the php process. 76 | (The one started by `start.sh`) 77 | 78 | ## Contributing 79 | 80 | Contributions are always welcome! You can help to improve the documentation, fix bugs in the code or add new features. 81 | 82 | Improving the beginners guide and documentation are currently something I would love to have help with. 83 | Feel free to open a PR! 84 | 85 | ### A big 'Thank you!' to the following contributors 86 | 87 | - @drego83 - Invaluable help with general testing and MySQL support 88 | 89 | ## Disclaimer 90 | 91 | This project is not endorsed by Volkswagen in any way, shape or form. This project is to be used entirely at your own risk. 92 | All brands and trademarks belong to their respective owners. 93 | 94 | Copyright (C) 2021 robske_110 95 | 96 | This program is free software: you can redistribute it and/or modify 97 | it under the terms of the GNU General Public License as published by 98 | the Free Software Foundation, either version 3 of the License, or 99 | (at your option) any later version. 100 | 101 | This program is distributed in the hope that it will be useful, 102 | but WITHOUT ANY WARRANTY; without even the implied warranty of 103 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 104 | GNU General Public License for more details. -------------------------------------------------------------------------------- /config-wizard.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | DIR="$(cd -P "$( dirname "${BASH_SOURCE[0]}" )" && pwd)" 4 | cd "$DIR" || exit 1 5 | 6 | exec "php" "./src/vwid/Server.php" "--configwizard" "$@" -------------------------------------------------------------------------------- /config/config.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "username": "", 3 | "password": "", 4 | "vin": null, 5 | "base-updaterate": 600, 6 | "increased-updaterate": 60, 7 | "db": { 8 | "host": "", 9 | "dbname": "", 10 | "user": "", 11 | "password": null, 12 | "driver": "pgsql" 13 | }, 14 | "carpic": { 15 | "viewDirection": "front", 16 | "angle": "right", 17 | "flip": true 18 | }, 19 | "integrations": { 20 | "abrp": { 21 | "user-token": "", 22 | "api-key": null 23 | } 24 | }, 25 | "timezone": null, 26 | "logging": { 27 | "debug-enable": false, 28 | "curl-verbose": false, 29 | "file-enable": false, 30 | "log-dir": null 31 | } 32 | } -------------------------------------------------------------------------------- /db.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS carStatus ( 2 | time TIMESTAMP NOT NULL PRIMARY KEY, 3 | batterySOC decimal(3, 0), 4 | remainingRange smallint, 5 | remainingChargingTime smallint, 6 | chargeState text, 7 | chargePower decimal(4, 1), 8 | chargeRateKMPH smallint, 9 | maxChargeCurrentAC text, 10 | autoUnlockPlugWhenCharged text, 11 | targetSOC decimal(3, 0), 12 | plugConnectionState text, 13 | plugLockState text, 14 | remainClimatisationTime smallint, 15 | hvacState text, 16 | hvacTargetTemp decimal(3, 1), 17 | hvacWithoutExternalPower boolean, 18 | hvacAtUnlock boolean, 19 | windowHeatingEnabled boolean, 20 | zoneFrontLeftEnabled boolean, 21 | zoneFrontRightEnabled boolean, 22 | zoneRearLeftEnabled boolean, 23 | zoneRearRightEnabled boolean, 24 | frontWindowHeatingState text, 25 | rearWindowHeatingState text, 26 | odometer integer 27 | ); 28 | 29 | CREATE TABLE IF NOT EXISTS users ( 30 | userid serial NOT NULL PRIMARY KEY, 31 | username text UNIQUE NOT NULL, 32 | hash text NOT NULL 33 | ); 34 | 35 | CREATE TABLE IF NOT EXISTS authKeys ( 36 | authKey text UNIQUE NOT NULL 37 | ); 38 | 39 | CREATE TABLE IF NOT EXISTS carPictures ( 40 | pictureID varchar(128) NOT NULL PRIMARY KEY, 41 | carPicture text NOT NULL 42 | ); 43 | 44 | CREATE TABLE IF NOT EXISTS chargingSessions ( 45 | sessionid serial UNIQUE NOT NULL, 46 | startTime TIMESTAMP NOT NULL PRIMARY KEY, 47 | endTime TIMESTAMP, 48 | chargeStartTime TIMESTAMP, 49 | chargeEndTime TIMESTAMP, 50 | duration integer, 51 | avgChargePower decimal(4, 1), 52 | maxChargePower decimal(4, 1), 53 | minChargePower decimal(4, 1), 54 | chargeEnergy float4, 55 | rangeStart smallint, 56 | rangeEnd smallint, 57 | targetSOC smallint, 58 | socStart decimal(3, 0), 59 | socEnd decimal(3, 0) 60 | ); 61 | 62 | CREATE TABLE IF NOT EXISTS settings ( 63 | settingKey varchar(128) NOT NULL PRIMARY KEY, 64 | settingValue text 65 | ) -------------------------------------------------------------------------------- /db_mysql.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS carStatus ( 2 | time TIMESTAMP NOT NULL PRIMARY KEY, 3 | batterySOC decimal(3, 0), 4 | remainingRange smallint, 5 | remainingChargingTime smallint, 6 | chargeState text, 7 | chargePower decimal(4, 1), 8 | chargeRateKMPH smallint, 9 | maxChargeCurrentAC text, 10 | autoUnlockPlugWhenCharged text, 11 | targetSOC decimal(3, 0), 12 | plugConnectionState text, 13 | plugLockState text, 14 | remainClimatisationTime smallint, 15 | hvacState text, 16 | hvacTargetTemp decimal(3, 1), 17 | hvacWithoutExternalPower boolean, 18 | hvacAtUnlock boolean, 19 | windowHeatingEnabled boolean, 20 | zoneFrontLeftEnabled boolean, 21 | zoneFrontRightEnabled boolean, 22 | zoneRearLeftEnabled boolean, 23 | zoneRearRightEnabled boolean, 24 | frontWindowHeatingState text, 25 | rearWindowHeatingState text, 26 | odometer integer 27 | ); 28 | 29 | CREATE TABLE IF NOT EXISTS users ( 30 | userid serial NOT NULL PRIMARY KEY, 31 | username varchar(64) UNIQUE NOT NULL, 32 | hash text NOT NULL 33 | ); 34 | 35 | CREATE TABLE IF NOT EXISTS authKeys ( 36 | authKey text UNIQUE NOT NULL 37 | ); 38 | 39 | CREATE TABLE IF NOT EXISTS carPictures ( 40 | pictureID varchar(128) NOT NULL PRIMARY KEY, 41 | carPicture mediumtext NOT NULL 42 | ); 43 | 44 | CREATE TABLE IF NOT EXISTS chargingSessions ( 45 | sessionid serial UNIQUE NOT NULL, 46 | startTime TIMESTAMP NOT NULL PRIMARY KEY, 47 | endTime TIMESTAMP NULL, 48 | chargeStartTime TIMESTAMP NULL, 49 | chargeEndTime TIMESTAMP NULL, 50 | duration integer, 51 | avgChargePower decimal(4, 1), 52 | maxChargePower decimal(4, 1), 53 | minChargePower decimal(4, 1), 54 | chargeEnergy float4, 55 | rangeStart smallint, 56 | rangeEnd smallint, 57 | targetSOC smallint, 58 | socStart decimal(3, 0), 59 | socEnd decimal(3, 0) 60 | ); 61 | 62 | CREATE TABLE IF NOT EXISTS settings ( 63 | settingKey varchar(128) NOT NULL PRIMARY KEY, 64 | settingValue mediumtext 65 | ) -------------------------------------------------------------------------------- /docker/.env.example: -------------------------------------------------------------------------------- 1 | IDDATALOGGER_USERNAME= 2 | IDDATALOGGER_PASSWORD= 3 | IDDATALOGGER_IDVIEW_USERNAME= 4 | IDDATALOGGER_IDVIEW_PASSWORD= 5 | IDDATALOGGER_WEB_PORT=80 -------------------------------------------------------------------------------- /docker/Dockerfile-app: -------------------------------------------------------------------------------- 1 | FROM php:8.0-cli AS base 2 | 3 | # install needed php extensions 4 | RUN apt-get update && apt-get install -y \ 5 | libpng-dev \ 6 | zlib1g-dev \ 7 | libpq-dev \ 8 | && docker-php-ext-install gd \ 9 | && docker-php-ext-install pdo_pgsql \ 10 | && docker-php-ext-install pcntl 11 | 12 | # copy all source files and scripts 13 | COPY . /usr/src/IDDataLogger 14 | 15 | # setup entrypoint with automatic config generation from environment variables 16 | ENTRYPOINT /usr/src/IDDataLogger/docker/docker-start.sh -------------------------------------------------------------------------------- /docker/Dockerfile-web: -------------------------------------------------------------------------------- 1 | FROM php:8.0-apache AS base 2 | 3 | # install needed php extensions 4 | RUN apt-get update && apt-get install -y \ 5 | libpq-dev \ 6 | && docker-php-ext-install pdo_pgsql 7 | 8 | # copy puplic http content 9 | COPY ./public/. /var/www/html/ 10 | 11 | # use production php config (no warnings) 12 | RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | services: 3 | db: 4 | image: postgres:latest 5 | container_name: iddatalogger_db 6 | restart: always 7 | environment: 8 | POSTGRES_USER: iddatalogger 9 | POSTGRES_DB: vwid 10 | POSTGRES_HOST_AUTH_METHOD: trust 11 | PGDATA: /var/lib/postgresql/data/pgdata 12 | volumes: 13 | - persistent_data_db:/var/lib/postgresql/data:rw 14 | 15 | app: 16 | build: 17 | context: ../ 18 | dockerfile: ./docker/Dockerfile-app 19 | container_name: iddatalogger_app 20 | environment: 21 | IDDATALOGGER_USERNAME: ${IDDATALOGGER_USERNAME} 22 | IDDATALOGGER_PASSWORD: ${IDDATALOGGER_PASSWORD} 23 | IDDATALOGGER_VIN: ${IDDATALOGGER_VIN} 24 | IDDATALOGGER_BASE_UPDATERATE: ${IDDATALOGGER_BASE_UPDATERATE} 25 | IDDATALOGGER_INCREASED_UPDATERATE: ${IDDATALOGGER_INCREASED_UPDATERATE} 26 | IDDATALOGGER_CARPIC_VIEWDIRECTION: ${IDDATALOGGER_CARPIC_VIEWDIRECTION} 27 | IDDATALOGGER_CARPIC_ANGLE: ${IDDATALOGGER_CARPIC_ANGLE} 28 | IDDATALOGGER_CARPIC_FLIP: ${IDDATALOGGER_CARPIC_FLIP} 29 | IDDATALOGGER_TIMEZONE: ${IDDATALOGGER_TIMEZONE} 30 | IDDATALOGGER_LOGGING_CURL_VERBOSE: ${IDDATALOGGER_LOGGING_CURL_VERBOSE} 31 | IDDATALOGGER_LOGGING_DEBUG_ENABLE: ${IDDATALOGGER_LOGGING_DEBUG_ENABLE} 32 | IDDATALOGGER_LOGGING_FILE_ENABLE: ${IDDATALOGGER_LOGGING_FILE_ENABLE} 33 | IDDATALOGGER_INTEGRATIONS_ABRP_USER_TOKEN: ${IDDATALOGGER_INTEGRATIONS_ABRP_USER_TOKEN} 34 | IDDATALOGGER_INTEGRATIONS_ABRP_API_KEY: ${IDDATALOGGER_INTEGRATIONS_ABRP_API_KEY} 35 | IDDATALOGGER_DB_HOST: iddatalogger_db 36 | IDDATALOGGER_DB_DBNAME: vwid 37 | IDDATALOGGER_DB_USER: iddatalogger 38 | IDDATALOGGER_IDVIEW_USERNAME: ${IDDATALOGGER_IDVIEW_USERNAME} 39 | IDDATALOGGER_IDVIEW_PASSWORD: ${IDDATALOGGER_IDVIEW_PASSWORD} 40 | IDDATALOGGER_API_KEY: ${IDDATALOGGER_API_KEY} 41 | depends_on: 42 | - db 43 | restart: always 44 | volumes: 45 | - persistent_data_app:/usr/src/IDDataLogger/data:rw 46 | 47 | web: 48 | build: 49 | context: ../ 50 | dockerfile: ./docker/Dockerfile-web 51 | container_name: iddatalogger_web 52 | environment: 53 | DB_HOST: iddatalogger_db 54 | DB_NAME: vwid 55 | DB_USER: iddatalogger 56 | FORCE_ALLOW_HTTP: "true" 57 | restart: always 58 | ports: 59 | - "${IDDATALOGGER_WEB_PORT}:80" 60 | 61 | volumes: 62 | persistent_data_db: 63 | persistent_data_app: -------------------------------------------------------------------------------- /docker/docker-start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | /usr/src/IDDataLogger/config-wizard.sh --use-env --fill-defaults --quiet 4 | /usr/src/IDDataLogger/start.sh --wizard --frontend-username ${IDDATALOGGER_IDVIEW_USERNAME} --frontend-password ${IDDATALOGGER_IDVIEW_PASSWORD} --frontend-apikey ${IDDATALOGGER_API_KEY} 5 | -------------------------------------------------------------------------------- /docs/beginnerguide.md: -------------------------------------------------------------------------------- 1 | # Setup Guide for beginners 2 | Hi! You want to use this project, but have little to no experience setting up servers? This guide is for you! 3 | Please free up about an hour for setting this project up. 4 | 5 | Setting this project up on a Raspberry Pi is recommended for beginners. This guide assumes you are running raspbian. 6 | If you do not have a Raspberry Pi handy, any Debian installation will also work with the easy install scripts and this guide. 7 | 8 | Before we begin, let's go through what this project needs and does from a technical overview standpoint: 9 | 10 | This project contains a long-running program which will fetch data about your car from VW APIs and store it in a database. 11 | It also provides a website where you can view this data. Furthermore, it provides an API itself, providing the data in machine-readable format. 12 | This API is for example used for the iOS widget. 13 | 14 | To summarize we have three components: The long-running programm, the database and a webserver to serve you the statistics website or the data for the iOS widget. 15 | All of this can run on a Raspberry Pi. 16 | 17 | Let's get to work then. 18 | 19 | ## Prerequisites 20 | You'll need 21 | - a Raspberry Pi (see [supported models](https://github.com/robske110/IDDataLogger/wiki/Supported-Raspberry-PI-models)) with an internet connection and raspbian installed (alternatively any machine with a debian installation works) 22 | - a publicly routable IPv4 address if you want to use the widget and website from outside your home network (Some fibre plans for example do not include this) 23 | 24 | We assume you have your Raspberry Pi freshly setup and have the command prompt in front of you. 25 | There are plenty of guides on the internet on how to archive this. 26 | 27 | You should see the following line: `pi@Raspberry Pi:~ $` 28 | 29 | We strongly recommend changing your password on the Raspberry Pi to a strong one using the command `passwd`. 30 | 31 | If any problems or questions pop up during set up: 32 | 1. See the list of common problems and FAQs [here](https://github.com/robske110/IDDataLogger/wiki/FAQ-and-frequent-problems). 33 | 2. If there are remaining problems or questions see [getting help](https://github.com/robske110/IDDataLogger/wiki/Getting-help). 34 | 35 | Now you'll need to decide how you want to set this project up. 36 | There is a one-line command which installs this project automagically, but if you prefer to do some things manually and 37 | learn some things in the process jump to this [section](#installing-manually). 38 | 39 | ## Installing using the install script 40 | 41 | The install script works and is tested on raspbian and debian. 42 | It assumes you have a fresh OS, especially without any existing PostgreSQL or webserver installations. 43 | 44 | *Note for debian:* Run the install script as a normal user, **do not run it under root**! Make sure to have sudo installed and be in sudoers! 45 | 46 | Enter (or copy) the following command to download and run the install script: 47 | 48 | `wget https://raw.githubusercontent.com/robske110/IDDataLogger/master/docs/install.sh; bash install.sh; rm install.sh` 49 | 50 | The install script will produce a lot of output. 51 | After a few minutes you will be prompted for your VW account login information. 52 | It will ask for the username and password of your account. Note that the username is the E-Mail you use to log in. 53 | After you entered the information you should see `Installation complete, ...`. 54 | 55 | You'll now need to jump to [finishing setting up](#finishing-set-up). 56 | 57 | ## Installing manually 58 | 59 | We are going to execute a series of commands to set up this project on the pi. 60 | It is recommended to install this application in the home directory, although it is possible to use a different location. 61 | To get to the home directory execute `cd ~` 62 | 63 | #### 1. Installing software dependencies 64 | 65 | We need to install some software on the raspberry needed for this project. 66 | 67 | We need to install php, the language in which this project is written. 68 | 69 | ``` 70 | wget -q -O - https://packages.sury.org/php/README.txt | bash -s - 71 | sudo apt install php8.0 php8.0-pgsql php8.0-curl php8.0-gd php8.0-dom 72 | ``` 73 | The first command will install a repository which contains the latest version of PHP. 74 | The second command will install PHP 8, along with certain extensions we need. 75 | The same command will also install and set up the webserver apache2. 76 | The last command we'll need to execute is 77 | 78 | `sudo apt install postgresql` 79 | 80 | This will install the PostgreSQL database. It will store our information about the car. 81 | 82 | #### 2. Setting up the database 83 | 84 | We need to create a user and database in PostgreSQL for the ID DataLogger to be able to write and read from it. 85 | 86 | The user will have a username and password. Since this password will never have to be entered by you, we can generate a 87 | secure one automatically using the following command 88 | 89 | To be able to create new users and databases in PostgreSQL we will log in as the user that can administer the PostgreSQL 90 | database, which is called `postgres`: 91 | 92 | `sudo su postgres` 93 | 94 | We will first create a user in the database with the following two commands: 95 | ``` 96 | pg_pw=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1) 97 | psql -c "CREATE USER iddatalogger WITH PASSWORD '$pg_pw';" 98 | ``` 99 | The first cpmmand will create a secure password for our database. The second one will connect to the database and use 100 | the generated password to create the user iddatalogger. We must now echo this password using `echo "$pg_pw"`. 101 | You'll need the password in the one of the next steps, please copy it to a temporary location. 102 | Before we leave the postgres user we must create a database for the ID DataLogger: `createdb vwid`. 103 | We are now finished with setting up the PostgreSQL database and can leave the postgres user using `exit`. 104 | 105 | #### 3. Downloading and configuring the ID DataLogger 106 | 107 | We can now install git and clone this repository. This will download the ID DataLogger software. 108 | ``` 109 | sudo apt install git 110 | git clone https://github.com/robske110/IDDataLogger.git --recursive 111 | ``` 112 | Once we have done this we need to configure the ID DataLogger. We'll need to tell it the database details and our VW 113 | account login information. We'll change into the directory of the program by executing `cd IDDataLogger` and then run 114 | the config wizard with `./config-wizard.sh --allow-insecure-http`. The `allow-insecure-http` option allows us to test 115 | and run the application in our home network. 116 | 117 | The wizard will first ask for the username of the VW ID account. This is the E-Mail address you used to register at VW. 118 | After that you'll need to enter the password for your VW account. 119 | 120 | Now we need to configure the database parameters. It will ask us for the hostname of the database server. Since we run 121 | the database on the same machine the default value of `localhost` is correct and we can just press enter. Now it will 122 | ask us for the name of the database. We used createdb vwid earlier, so we'll need to enter `vwid` here. The username of 123 | the user we created earlier was `iddatalogger` so enter that for the next question. Now it will ask us for the password 124 | of the database. Here we'll need to enter the password that was generated earlier. It will now ask us which database 125 | driver it should use. The default of `pgsql` is fine, since it is an abbreviation for PostgreSQL. 126 | 127 | For additional configuration options after completing the config wizard see [config.md](config.md) 128 | 129 | #### 4. Copying files to the webserver 130 | 131 | We successfully configured the application. Now we will have to do what the config wizard told us: Copy the contents of 132 | the public folder to our webroot. This will copy the parts of the program that will be accessed from the internet (or 133 | home network) to the webserver, which needs to "serve" them. We can do that using the following commands: 134 | ``` 135 | sudo rm /var/www/html/index.html # removes the default "It works!" page, this is optional! 136 | sudo mkdir /var/www/html/vwid/ # creates a new directory for the ID DataLogger 137 | sudo cp -r ./public/. /var/www/html/vwid # copies the files from the public folder 138 | sudo ln -s "$PWD/.env" /var/www/ # links the .env file (created by the Config Wizard) to the appropiate location 139 | ``` 140 | 141 | #### 5. Creating a service for ID DataLogger 142 | 143 | After these command we need to create a service for the ID DataLogger fetch program. This will ensure that it will be 144 | started on every boot of the Raspberry PI and be able to fetch data continuously. 145 | 146 | ``` 147 | echo "[Unit] 148 | Description=ID DataLogger php backend 149 | After=network.target 150 | Requires=postgresql.service 151 | 152 | [Service] 153 | ExecStart=/home/$(whoami)/IDDataLogger/start.sh 154 | WorkingDirectory=/home/$(whoami)/IDDataLogger 155 | StandardOutput=syslog 156 | StandardError=syslog 157 | SyslogIdentifier=iddatalogger 158 | User=$(whoami) 159 | RestartSec=5 160 | Restart=always 161 | 162 | [Install] 163 | WantedBy=multi-user.target" | sudo tee /etc/systemd/system/iddatalogger.service > /dev/null 164 | ``` 165 | We need to enable the service using the command `sudo systemctl enable iddatalogger.service`. 166 | 167 | You can now continue in the next section. 168 | 169 | ## Finishing set up 170 | 171 | Make sure to be in the IDDataLogger directory. If you installed using the install script you will need to enter 172 | `cd ./IDDataLogger`. If you come from the manual installation you should already be at the correct location. 173 | 174 | You can now enter `./start.sh`, which will start the ID DataLogger. 175 | It will now ask you if you want to generate an API key. If you want to use the iOS widget you will need to answer with `Y`. 176 | For more information on setting up the iOS widget using the API key see [Setting up the iOS Widget](ioswidget.md). 177 | After that it will ask you if you want to create an user. This user is used to log into the website. Make sure to choose 178 | a strong and long password. It is recommended to store this in a password manager. 179 | Note that you can create additional API keys or add additional users at any time using `./start.sh --wizard`. 180 | 181 | After creating the API key and the user you should see `Done. Ready! Fetching car status...` and `Writing new data for timestamp`. 182 | This means you have successfully set up the ID DataLogger! 183 | Please shutdown the ID DataLogger using the key combination CTRL+C and reboot the Raspberry Pi using `systemctl reboot`. 184 | 185 | You can now access the ID DataLogger using the iOS widget or by entering `http://IP/vwid` into your browser where `IP` is the ip address or hostname of your raspberry. 186 | 187 | To find the ip address of your Raspberry Pi simply enter the command `hostname -I`. 188 | 189 | A command you might find useful from time to time is `sudo journalctl -u iddatalogger`. This will show the output of the fetch 190 | program and can help you debug issues if you ever have any trouble. 191 | 192 | If you want to view the website and have the iOS widget update outside your home network, 193 | please refer to [making the ID DataLogger available from the internet](https://github.com/robske110/IDDataLogger/wiki/Making-the-ID-DataLogger-available-from-the-internet). 194 | 195 | ## Updating the installation in the future 196 | 197 | Make sure to periodically keep your installation up-to-date to receive security and bug fixes along with new features. 198 | Execute the following commands to ensure raspbian and other software is secure and up-to-date: 199 | ``` 200 | sudo apt update 201 | sudo apt -y upgrade 202 | sudo apt -y dist-upgrade 203 | systemctl reboot 204 | ``` 205 | To update the ID DataLogger you can either execute the following command to do this automatically: 206 | 207 | `cd ~/IDDataLogger && ./docs/update.sh` 208 | 209 | Or if you want to do it manually go into the directory of the ID DataLogger installation with `cd ~/IDDataLogger`, update 210 | the ID DataLogger software files using `git pull && git submodule update`, remove the old files in the webserver directory 211 | using `sudo rm -r /var/www/html/vwid`, copy the new files into the directory using `sudo cp -r ./public/. /var/www/html/vwid` 212 | and finally restart the ID DataLogger fetch program by running `sudo systemctl restart iddatalogger.service`. -------------------------------------------------------------------------------- /docs/certbot.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | DIR="$(cd -P "$( dirname "${BASH_SOURCE[0]}" )" && pwd)" 4 | cd "$DIR" || exit 1 5 | 6 | echo "What's your domain name?" 7 | read -r domain 8 | echo "Enter an E-Mail where you want to receive important certificate alerts (this will not be often)" 9 | read -r email 10 | sudo apt -y install certbot python3-certbot-apache 11 | sudo certbot --apache --agree-tos -n -m $email -d $domain 12 | 13 | cd .. 14 | 15 | sudo rm /var/www/.env 16 | ./config-wizard.sh --secure --quiet 17 | sudo cp ./.env /var/www/ -------------------------------------------------------------------------------- /docs/config.md: -------------------------------------------------------------------------------- 1 | # Config 2 | 3 | ## config.json 4 | 5 | `username` The E-Mail Address used for your VW ID account 6 | 7 | `password` The password used for your VW ID account 8 | 9 | `vin` The vin of the vehicle for which data should be logged. 10 | Can be `null`, which causes the first vehicle to be used. (Recommended when you only have one vehicle in your account) 11 | 12 | `base-updaterate` Updaterate for the car values. (Note: Data will only be written to db when data changed.) 13 | 14 | `increased-updaterate` This updaterate will be used while the car is charging or hvac is active, or if the last 15 | update was during the last 6 minutes (which will mainly be while driving). 16 | 17 | `db.host` The host of the postgres db server 18 | 19 | `db.dbname` The name of the database that this application can use 20 | 21 | `db.user` The username for the database 22 | 23 | `db.password` The password for the database 24 | 25 | `db.driver` The driver for the database. See https://www.php.net/manual/en/pdo.drivers.php for possible values. 26 | 27 | `carpic.flip` Whether to flip the carpic (default: true) 28 | 29 | The keys 30 | `carpic.viewDirection` and 31 | `carpic.angle` 32 | can have the following values: 33 | 34 | | viewDirection | angle | 35 | | ----- | ------ | 36 | | side | right | 37 | | side | left | 38 | | back | left | 39 | | back | right | 40 | | front | center | 41 | | front | left | 42 | | front | right | 43 | 44 | Note: For changes to the carpic settings to apply, delete data/carPic.png 45 | 46 | `integrations.abrp.user-token` The user token to use for the [ABRP integration](https://github.com/robske110/IDDataLogger/wiki/ABRP-integration), which can be generated in the ABRP app. 47 | 48 | `integrations.abrp.api-key` Strictly optional. Overrides the default (hardcoded) api token for the ABRP live data API. 49 | 50 | `timezone` Server timezone. This is used for correct timestamps in logs. (Overrides settings in php.ini) 51 | 52 | `logging.debug-enable` Enables debug output 53 | 54 | `logging.curl-verbose` Enables verbose curl output 55 | (Highly detailed, produces a lot of output and used for debugging login / api issues) 56 | 57 | `logging.file-enable` Enables debug output 58 | 59 | `logging.log-dir` The directory in which to store log files. Can be `null` for default directory (`program_directory/log`). 60 | 61 | Note: If you run config-wizard.sh with the `--use-env` option (default in docker) the ENV variable names will be the 62 | config names in uppercase with dots and hyphens are replaced by underscores and the prefix IDDATALOGGER. 63 | (For example `logging.log-dir` becomes `IDDATALOGGER_LOGGING_LOG_DIR`.) 64 | 65 | ## .env file 66 | 67 | `DB_HOST` The host of the postgres db server 68 | 69 | `DB_NAME` The name of the database that this application can use 70 | 71 | `DB_USER` The username for the database 72 | 73 | `DB_PASSWORD` The password for the database 74 | 75 | `DB_DRIVER` The driver for the database. See https://www.php.net/manual/en/pdo.drivers.php for possible values. 76 | 77 | `FORCE_ALLOW_HTTP` Set this option to true to force the login system to allow http access. 78 | It is strongly recommended to omit this option whenever possible, especially on installations accessible over the internet. -------------------------------------------------------------------------------- /docs/docker.md: -------------------------------------------------------------------------------- 1 | # Setup with docker 2 | 3 | A docker-compose setup with associated Dockerfiles is provided for running this application using docker. 4 | 5 | There are three docker containers: 6 | - db - based on postgres, hosts the database and persists its data 7 | - web - based on php-apache, hosts the frontend and persists nothing 8 | - app - based on php, hosts the background fetcher and persists the data directory (currently used for caching carPic) 9 | 10 | All configuration is done using environment variables. 11 | 12 | ### Installing 13 | 14 | Clone this repository and change into the docker subdirectory. 15 | 16 | `git clone https://github.com/robske110/IDDataLogger.git --recursive && cd ./IDDataLogger/docker` 17 | 18 | Create a .env file in the docker directory (using .env.example of the docker directory as a basis). 19 | Make sure to be in the docker subdirectory! The .env file in the root directory IS NOT USED in the docker installation! 20 | 21 | You need to set at least 22 | `IDDATALOGGER_USERNAME`, 23 | `IDDATALOGGER_PASSWORD`, 24 | `IDDATALOGGER_IDVIEW_USERNAME` and 25 | `IDDATALOGGER_IDVIEW_PASSWORD` 26 | for the application to work. 27 | `IDDATALOGGER_USERNAME` and `IDDATALOGGER_PASSWORD` have to be set to the credentials of your 28 | VW account. The IDVIEW variants are for the account created for the web frontend of this application. 29 | 30 | Note: If you plan to use the iOS widget and want to create a custom authentication key for it, set `IDDATALOGGER_API_KEY` 31 | to your desired key. Otherwise, an API key will be automatically generated, and you'll need to copy it from the log 32 | output on first startup. 33 | ``` 34 | iddatalogger_app | [Y]: Successfully generated the API key ad65c068e1a7cf6bee6f65a6f04157545ba22d870a0a1fe019b20989e26c6749 35 | iddatalogger_app | Please enter this API key in the apiKey setting at the top of the iOS widget! 36 | ``` 37 | 38 | Further environment variables available are the same as defined in [config.md](docs/config.md). The environment variable 39 | names for the configuration options will be all UPPERCASE with hyphens and dots replaced by underscores and prefixed 40 | with `IDDATALOGGER`. 41 | (For example `logging.debug-enable` becomes `IDDATALOGGER_LOGGING_DEBUG_ENABLE`) 42 | 43 | After creating the .env file the last command to execute is `docker compose up`. 44 | 45 | You can now visit `localhost:IDDATALOGGER_WEB_PORT` or [set up](docs/ioswidget.md) the iOS widget using the API key 46 | you copied from the first startup log or specified using `IDDATALOGGER_API_KEY`! 47 | 48 | If you want to access the ID DataLogger from the internet, please place it behind a reverse-proxy providing SSL 49 | certificates and HTTPS support. 50 | 51 | ### Updating 52 | 53 | To update the software at a later data execute `git pull && git submodule update` in the repository directory and 54 | rebuild the docker containers web and app. (`docker-compose build web && docker-compose build app`) -------------------------------------------------------------------------------- /docs/img/idView.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robske110/IDDataLogger/3c6cd0d92e40ec9defd06dc7efc0056a26c1e576/docs/img/idView.png -------------------------------------------------------------------------------- /docs/img/widget.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robske110/IDDataLogger/3c6cd0d92e40ec9defd06dc7efc0056a26c1e576/docs/img/widget.png -------------------------------------------------------------------------------- /docs/img/widget_and_chargingOverview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robske110/IDDataLogger/3c6cd0d92e40ec9defd06dc7efc0056a26c1e576/docs/img/widget_and_chargingOverview.png -------------------------------------------------------------------------------- /docs/img/widgetcharging.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robske110/IDDataLogger/3c6cd0d92e40ec9defd06dc7efc0056a26c1e576/docs/img/widgetcharging.png -------------------------------------------------------------------------------- /docs/img/widgets.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robske110/IDDataLogger/3c6cd0d92e40ec9defd06dc7efc0056a26c1e576/docs/img/widgets.png -------------------------------------------------------------------------------- /docs/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | #Install script for clean debian-based systems. 3 | 4 | if [ "${EUID:-$(id -u)}" -eq 0 ]; then 5 | echo "This install script is not to be run as root or with sudo." 6 | echo "Please run it with sudo installed and from a normal user who is in sudoers!" 7 | exit 8 | fi 9 | cd ~ || exit 10 | wget -q -O - https://packages.sury.org/php/README.txt | bash -s - 11 | sudo apt -y install php8.0 php8.0-pgsql php8.0-curl php8.0-gd php8.0-dom 12 | sudo apt -y install postgresql 13 | pg_pw=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1) 14 | sudo su postgres -c "psql -c \"DO \\$\\$ 15 | BEGIN 16 | IF EXISTS (SELECT FROM pg_roles WHERE rolname='iddatalogger') THEN 17 | ALTER ROLE iddatalogger WITH PASSWORD '$pg_pw'; 18 | ELSE 19 | CREATE USER iddatalogger WITH PASSWORD '$pg_pw'; 20 | END IF; 21 | END \\$\\$;\"; createdb vwid" 22 | sudo apt -y install git 23 | git clone https://github.com/robske110/IDDataLogger.git --recursive 24 | cd IDDataLogger || exit 25 | ./config-wizard.sh --host localhost --user iddatalogger --dbname vwid --password "$pg_pw" --driver pgsql --allow-insecure-http --quiet 26 | sudo rm /var/www/html/index.html #remove default "It works!" page 27 | sudo mkdir /var/www/html/vwid/ 28 | sudo cp -r ./public/. /var/www/html/vwid 29 | sudo ln -s "$PWD/.env" /var/www/ 30 | echo "[Unit] 31 | Description=ID DataLogger php backend 32 | After=network.target 33 | Requires=postgresql.service 34 | 35 | [Service] 36 | ExecStart=/home/$(whoami)/IDDataLogger/start.sh 37 | WorkingDirectory=/home/$(whoami)/IDDataLogger 38 | StandardOutput=syslog 39 | StandardError=syslog 40 | SyslogIdentifier=iddatalogger 41 | User=$(whoami) 42 | RestartSec=5 43 | Restart=always 44 | 45 | [Install] 46 | WantedBy=multi-user.target" | sudo tee /etc/systemd/system/iddatalogger.service > /dev/null 47 | sudo systemctl enable iddatalogger.service 48 | echo "Installation complete! You can now enter cd IDDataLogger && ./start.sh to finish setting up." -------------------------------------------------------------------------------- /docs/ioswidget.md: -------------------------------------------------------------------------------- 1 | # Setting up the iOS Widget 2 | 3 | The iOS widget is created using Scriptable App. 4 | 5 | ### Steps to get the script into the Scriptable App 6 | 7 | 1. Download Scriptable from the iOS App Store. 8 | 2. Download the file `Car.scriptable` from the latest [release](https://github.com/robske110/IDDatalogger/releases) 9 | 4. If you own a mac airdrop this file to your iOS device and open it in Scriptable. 10 | 11 | On other operating system you'll need to send the file to your iOS device in another way, for example by e-mail. 12 | 3. Edit the file *in scriptable* according to the instructions in [Changing settings in the script](#changing-settings-in-the-script) 13 | 14 | If you prefer to edit the settings on your PC you will need to edit [Car.js](../Car.js) and copy the contents of it in 15 | scriptable. 16 | 17 | ### Changing settings in the script 18 | 19 | #### Initial setup 20 | 21 | To put the necessary API key and base URL into the script you'll need to open it in Scriptable. 22 | To do this click the three dots on the Script entry in the Scriptable app. 23 | 24 | You'll now need to find the line `const apiKey = ""` and place the API key you got during the setup phase of the IDDataLogger between the "". 25 | 26 | After that you need to place the baseURL between the "" in the line which says `const baseURL = ""`. 27 | 28 | If you have used the install script on a raspberry pi the baseURL will be 29 | `http://xxx.xxx.xxx.xxx/vwid/` 30 | where xxx.xxx.xxx.xxx is the IP of your raspberry pi. 31 | If you've already made the ID DataLogger availible from the internet use `https://your-hostname.tld/vwid/` instead. 32 | 33 | #### More settings 34 | 35 | Further settings available are described in the following table: 36 | 37 | | setting | explanation | 38 | | ------- | ----------- | 39 | | rangeInMiles | set this to true if you want to have the range displayed in miles | 40 | | showFinishTime | set this to false if you want to hide the charge finish time and only display charge time remaining | 41 | | forceImageRefresh | set this to true after you've changed the carPicture (data/carPic.png) to force the widget to update it | 42 | | exampleData | set this to true to force the widget to display example data and not to fetch from baseURL | 43 | | timetravel | set this to an unix timestamp (in seconds) if you want to display values as if the widget was last updated at that time. Mostly useful for debugging. | 44 | | socThreshold | not implemented | -------------------------------------------------------------------------------- /docs/update.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | #Update script for debian-based systems. 3 | 4 | cd ~ || exit 5 | cd IDDataLogger || exit 6 | git pull --rebase 7 | git submodule update 8 | sudo rm -r /var/www/html/vwid/ 9 | sudo cp -r ./public/. /var/www/html/vwid 10 | sudo systemctl restart iddatalogger.service 11 | echo "Update complete!" -------------------------------------------------------------------------------- /public/DatabaseConnection.php: -------------------------------------------------------------------------------- 1 | host = $host; 27 | $this->db = $db; 28 | $this->username = $username; 29 | $this->password = $password; 30 | $this->driver = $driver; 31 | $this->connect(); 32 | } 33 | 34 | public function connect(){ 35 | $this->connection = new PDO( 36 | $this->driver.":host=".$this->host.";dbname=".$this->db, $this->username, $this->password 37 | ); 38 | } 39 | 40 | public function getConnection(): PDO{ 41 | return $this->connection; 42 | } 43 | 44 | public function query(string $sql): array{ 45 | $res = $this->connection->query($sql); 46 | return $res->fetchAll(PDO::FETCH_ASSOC); 47 | } 48 | 49 | public function queryStatement(string $sql): PDOStatement{ 50 | return $this->connection->query($sql); 51 | } 52 | 53 | public function prepare(string $sql): PDOStatement{ 54 | return $this->connection->prepare($sql); 55 | } 56 | } -------------------------------------------------------------------------------- /public/carPicture.php: -------------------------------------------------------------------------------- 1 | format(DateTime::RFC1123)); 6 | define("ALLOW_KEY_AUTHENTICATION", true); 7 | require "login/loginCheck.php"; 8 | require_once "DatabaseConnection.php"; 9 | 10 | $carPics = DatabaseConnection::getInstance()->query("SELECT carpicture FROM carPictures WHERE pictureID = 'default'"); 11 | 12 | $pic = base64_decode($carPics[0]["carpicture"]); 13 | header("Content-Type: image/png"); 14 | header("Content-Length: ".strlen($pic)); 15 | echo($pic); -------------------------------------------------------------------------------- /public/carStatus.php: -------------------------------------------------------------------------------- 1 | format("Y-m-d\TH:i:s")."'"; 11 | } 12 | 13 | if($attemptRefresh){ 14 | if($statusAt !== null){ 15 | error("unsupported argument combination!"); 16 | } 17 | echo("not implemented"); 18 | } 19 | 20 | $columns = "time, batterySOC, remainingRange, remainingChargingTime, chargeState, chargePower, chargeRateKMPH, targetSOC, plugConnectionState, plugLockState, remainClimatisationTime, hvacState, hvacTargetTemp"; 21 | $sqlCmd = "SELECT ".$columns." FROM carStatus".($statusAt ?? "")." ORDER BY time DESC LIMIT 1"; 22 | 23 | $sqlChargeStart = "WITH chargeState_wprev AS ( 24 | SELECT time, chargeState, lag(chargeState) over(ORDER BY time ASC) AS prev_chargeState 25 | FROM carStatus".($statusAt ?? "")." 26 | ORDER BY time DESC 27 | ) 28 | SELECT time, chargeState, prev_chargeState 29 | FROM chargeState_wprev 30 | WHERE prev_chargeState IN ('readyForCharging', 'notReadyForCharging') AND chargeState = 'charging' LIMIT 1"; 31 | 32 | if(($_ENV["DB_DRIVER"] ?? "pgsql") != "pgsql"){ 33 | if($statusAt !== null){ 34 | $statusAt = "WHERE startTime ".substr($statusAt, 11); 35 | } 36 | $sqlChargeStart = "SELECT startTime AS time FROM chargingSessions ".($statusAt ?? "")." ORDER BY startTime DESC LIMIT 1;"; 37 | } 38 | 39 | 40 | $chargeStartRes = DatabaseConnection::getInstance()->queryStatement($sqlChargeStart)->fetch(PDO::FETCH_ASSOC); 41 | 42 | $carStatus = DatabaseConnection::getInstance()->queryStatement($sqlCmd)->fetch(PDO::FETCH_ASSOC); 43 | if(empty($carStatus)){ 44 | error("no data"); 45 | } 46 | $columns = explode(", ", $columns); 47 | foreach($carStatus as $key => $value){ 48 | foreach($columns as $columnName){ 49 | if($key === strtolower($columnName) && $key !== $columnName){ 50 | $carStatus[$columnName] = $value; 51 | unset($carStatus[$key]); 52 | break; 53 | } 54 | } 55 | } 56 | $carStatus["lastChargeStartTime"] = (new DateTime($chargeStartRes["time"] ?? $carStatus["time"], new DateTimeZone("UTC")))->format(DateTimeInterface::ATOM); 57 | $carStatus["time"] = (new DateTime($carStatus["time"], new DateTimeZone("UTC")))->format(DateTimeInterface::ATOM); 58 | #var_dump($carStatus); 59 | 60 | echo(json_encode($carStatus)); 61 | 62 | function error(string $msg){ 63 | echo(json_encode(["error" => $msg])); 64 | exit; 65 | } -------------------------------------------------------------------------------- /public/chargingSessionDataProvider.php: -------------------------------------------------------------------------------- 1 | $this->id, 27 | "startTime" => $this->startTime, 28 | "endTime" => $this->endTime, 29 | "chargeStartTime" => $this->chargeStartTime, 30 | "chargeEndTime" => $this->chargeEndTime, 31 | "duration" => $this->chargeDuration, 32 | "avgChargePower" => $this->avgChargePower, 33 | "maxChargePower" => $this->maxChargePower, 34 | "minChargePower" => $this->minChargePower, 35 | "chargeEnergy" => $this->integralChargeEnergy, 36 | "rangeStart" => $this->rangeStart, 37 | "rangeEnd" => $this->rangeEnd, 38 | "targetSOC" => $this->targetSOC, 39 | "socStart" => $this->socStart, 40 | "socEnd" => $this->socEnd 41 | ]; 42 | } 43 | } 44 | 45 | class chargingSessionDataProvider{ 46 | private int $beginTime; 47 | private int $endTime; 48 | 49 | private float $took; 50 | 51 | public function __construct(int $beginTime, int $endTime){ 52 | $this->beginTime = $beginTime; 53 | $this->endTime = $endTime; 54 | } 55 | 56 | private static function formatDate(string $dbDate): string{ 57 | return ((new DateTime($dbDate, new DateTimeZone("UTC")))->format(DateTimeInterface::ATOM)); 58 | } 59 | 60 | private static function nullableFormatDate(?string $dbDate): ?string{ 61 | return $dbDate === null ? null : self::formatDate($dbDate); 62 | } 63 | 64 | public function getChargingSessions(): array{ 65 | $beginTime = microtime(true); 66 | $data = $this->fetchFromDB($this->beginTime, $this->endTime); 67 | 68 | $chargingSessions = []; 69 | foreach($data as $chargeSessionData){ 70 | $chargingSession = new ChargingSession(); 71 | $chargingSession->id = (int) $chargeSessionData["sessionid"]; 72 | $chargingSession->startTime = self::formatDate($chargeSessionData["starttime"]); 73 | $chargingSession->endTime = self::nullableFormatDate($chargeSessionData["endtime"]); 74 | $chargingSession->chargeStartTime = self::nullableFormatDate($chargeSessionData["chargestarttime"]); 75 | $chargingSession->chargeEndTime = self::nullableFormatDate($chargeSessionData["chargeendtime"]); 76 | $chargingSession->chargeDuration = (int) $chargeSessionData["duration"]; 77 | $chargingSession->avgChargePower = (float) $chargeSessionData["avgchargepower"]; 78 | $chargingSession->maxChargePower = (float) $chargeSessionData["maxchargepower"]; 79 | $chargingSession->minChargePower = (float) $chargeSessionData["minchargepower"]; 80 | $chargingSession->integralChargeEnergy = (float) $chargeSessionData["chargeenergy"]; 81 | $chargingSession->rangeStart = (int) $chargeSessionData["rangestart"]; 82 | $chargingSession->rangeEnd = (int) $chargeSessionData["rangeend"]; 83 | $chargingSession->targetSOC = (int) $chargeSessionData["targetsoc"]; 84 | $chargingSession->socStart = (int) $chargeSessionData["socstart"]; 85 | $chargingSession->socEnd = (int) $chargeSessionData["socend"]; 86 | 87 | $chargingSessions[] = $chargingSession; 88 | } 89 | 90 | $endTime = microtime(true); 91 | $this->took = $endTime - $beginTime; 92 | 93 | return $chargingSessions; 94 | } 95 | 96 | public function getTook(): float{ 97 | return $this->took; 98 | } 99 | 100 | private function fetchFromDB(int $beginTime, int $endTime): array{ 101 | $beginTime = new DateTime("@".$beginTime, new DateTimeZone("UTC")); 102 | $endTime = new DateTime("@".$endTime, new DateTimeZone("UTC")); 103 | $data = DatabaseConnection::getInstance()->query( 104 | "SELECT sessionid, 105 | starttime, 106 | endtime, 107 | chargestarttime, 108 | chargeendtime, 109 | duration, 110 | avgchargepower, 111 | maxchargepower, 112 | minchargepower, 113 | chargeenergy, 114 | rangestart, 115 | rangeend, 116 | targetsoc, 117 | socstart, 118 | socend FROM chargingSessions WHERE starttime >= TIMESTAMP '". 119 | $beginTime->format("Y-m-d\TH:i:s"). 120 | "' AND starttime <= TIMESTAMP '".$endTime->format("Y-m-d\TH:i:s")."' ORDER BY startTime DESC" 121 | ); 122 | #var_dump($data); 123 | return $data; 124 | } 125 | } -------------------------------------------------------------------------------- /public/chargingSessions.php: -------------------------------------------------------------------------------- 1 | getChargingSessions(); 15 | 16 | $chgSessions = []; 17 | foreach($chargingSessions as $chargingSession){ 18 | $chgSessions[] = $chargingSession->toArray(); 19 | } 20 | 21 | echo(json_encode($chgSessions)); -------------------------------------------------------------------------------- /public/env.php: -------------------------------------------------------------------------------- 1 | $var){ 8 | $_ENV[$key] = $var; 9 | } 10 | } 11 | 12 | $required_envvars = ["DB_HOST", "DB_NAME", "DB_USER"]; 13 | foreach($required_envvars as $required_envvar){ 14 | if(!isset($_ENV[$required_envvar])){ 15 | echo("Error: required envvar ".$required_envvar." not set!\n"); 16 | exit; 17 | } 18 | } 19 | 20 | if($_ENV["FORCE_ALLOW_HTTP"] === "true"){ 21 | $_ENV["FORCE_ALLOW_HTTP"] = true; 22 | } 23 | 24 | if(isset($_ENV["TIMEZONE_OVERRIDE"])){ 25 | date_default_timezone_set($_ENV["TIMEZONE_OVERRIDE"]); 26 | } -------------------------------------------------------------------------------- /public/idView/carGraphData.php: -------------------------------------------------------------------------------- 1 | getGraphData()->toArray())); -------------------------------------------------------------------------------- /public/idView/carGraphDataProvider.php: -------------------------------------------------------------------------------- 1 | $this->time, 17 | "batterySOC" => $this->batterySOC, 18 | "targetSOC" => $this->targetSOC, 19 | "remainingRange" => $this->remainingRange, 20 | "remainingChargingTime" => $this->remainingChargingTime, 21 | "chargePower" => $this->chargePower, 22 | "chargeRateKMPH" => $this->chargeRateKMPH 23 | ]; 24 | } 25 | } 26 | 27 | class carGraphDataProvider{ 28 | private int $beginTime; 29 | private int $endTime; 30 | private bool $dataBracketing; 31 | 32 | private float $took; 33 | 34 | public function __construct(int $beginTime, int $endTime, bool $dataBracketing){ 35 | $this->beginTime = $beginTime; 36 | $this->endTime = $endTime; 37 | $this->dataBracketing = $dataBracketing; 38 | } 39 | 40 | public function getGraphData(): ?carGraphData{ 41 | $beginTime = microtime(true); 42 | $data = $this->fetchFromDB($this->beginTime, $this->endTime); 43 | 44 | $carGraphData = new carGraphData; 45 | if(empty($data)){ 46 | return $carGraphData; 47 | } 48 | 49 | $carGraphData->batterySOC = array_column($data, "batterysoc"); 50 | $carGraphData->targetSOC = array_column($data, "targetsoc"); 51 | $carGraphData->remainingRange = array_column($data, "remainingrange"); 52 | $carGraphData->remainingChargingTime = array_column($data, "remainingchargingtime"); 53 | $carGraphData->chargePower = array_column($data, "chargepower"); 54 | $carGraphData->chargeRateKMPH = array_column($data, "chargeratekmph"); 55 | 56 | $carGraphData->time = array_column($data, "time"); 57 | $timeCnt = count($carGraphData->time); 58 | for($i = 0; $i < $timeCnt; $i++){ 59 | $carGraphData->time[$i] = ((new DateTime($carGraphData->time[$i], new DateTimeZone("UTC")))->format(DateTimeInterface::ATOM)); 60 | } 61 | 62 | if($this->dataBracketing){ 63 | $this->performDataBracketing($carGraphData); 64 | } 65 | $endTime = microtime(true); 66 | $this->took = $endTime - $beginTime; 67 | 68 | return $carGraphData; 69 | } 70 | 71 | public function getTook(): float{ 72 | return $this->took; 73 | } 74 | 75 | const MAX_DATA_POINT_DISTANCE = 2*60; 76 | const MAX_GROUP_TIME_LEN = 5*60; 77 | 78 | private function performDataBracketing(carGraphData $data){ 79 | //step 1: build groups 80 | $groups = []; 81 | $dataCnt = count($data->time); 82 | for($i = 0; $i < $dataCnt; $i++){ 83 | #out("Begining group at $i"); 84 | $groupStartTime = (new DateTime($data->time[$i]))->getTimestamp(); 85 | $groups[$groupStartTime] = [$i]; 86 | $groupStart = $i; 87 | $lastTime = PHP_INT_MAX; 88 | for($groupPos = $i+1; $groupPos < $dataCnt; ++$groupPos){ 89 | $currTime = (new DateTime($data->time[$groupPos]))->getTimestamp(); 90 | if($currTime - $groupStartTime >= self::MAX_GROUP_TIME_LEN || $currTime - $lastTime >= self::MAX_DATA_POINT_DISTANCE){ 91 | #out("Exiting group at $groupPos: ".($currTime - $groupStartTime)."s from groupStart and ".($currTime - $lastTime)."s from last point in group. (start: $time[$i] end: $time[$groupPos])"); 92 | break; 93 | } 94 | $groups[$groupStartTime][] = $groupPos; 95 | $lastTime = $currTime; 96 | } 97 | $i = $groupPos-1; 98 | } 99 | //step 2: only use last value of each group 100 | foreach($groups as $group){ 101 | for($i = 0; $i < count($group)-1; ++$i){ 102 | #var_dump($group); 103 | #echo("rem at $group[$i] ::".count($group)); 104 | unset($data->time[$group[$i]]); 105 | unset($data->batterySOC[$group[$i]]); 106 | unset($data->targetSOC[$group[$i]]); 107 | unset($data->remainingRange[$group[$i]]); 108 | unset($data->remainingChargingTime[$group[$i]]); 109 | unset($data->chargePower[$group[$i]]); 110 | unset($data->chargeRateKMPH[$group[$i]]); 111 | } 112 | } 113 | $data->time = array_values($data->time); 114 | $data->batterySOC = array_values($data->batterySOC); 115 | $data->targetSOC = array_values($data->targetSOC); 116 | $data->remainingRange = array_values($data->remainingRange); 117 | $data->remainingChargingTime = array_values($data->remainingChargingTime); 118 | $data->chargePower = array_values($data->chargePower); 119 | $data->chargeRateKMPH = array_values($data->chargeRateKMPH); 120 | } 121 | 122 | private function fetchFromDB(int $beginTime, int $endTime): array{ 123 | $beginTime = new DateTime("@".$beginTime, new DateTimeZone("UTC")); 124 | $endTime = new DateTime("@".$endTime, new DateTimeZone("UTC")); 125 | $data = DatabaseConnection::getInstance()->query( 126 | "SELECT time, batterysoc, targetsoc, remainingrange, remainingchargingtime, chargepower, chargeratekmph FROM carStatus WHERE time >= TIMESTAMP '". 127 | $beginTime->format("Y-m-d\TH:i:s"). 128 | "' AND time <= TIMESTAMP '".$endTime->format("Y-m-d\TH:i:s")."' ORDER BY time ASC" 129 | ); 130 | #var_dump($data); 131 | return $data; 132 | } 133 | } -------------------------------------------------------------------------------- /public/idView/chargingOverview.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | chargingOverview 11 | 12 | 13 | 80 | 81 | 82 | 83 | 84 | -------------------------------------------------------------------------------- /public/idView/css/chargingOverview.css: -------------------------------------------------------------------------------- 1 | *{ 2 | padding: 0; 3 | margin: 0; 4 | } 5 | :root{ 6 | --chargeSession-mobile-display-label: none; 7 | } 8 | [data-chargeSessions-mobile-label="display"]{ 9 | --chargeSession-mobile-display-label: initial; 10 | } 11 | 12 | .chargeSessions{ 13 | list-style-type: none; 14 | padding-bottom: 0.5em; 15 | } 16 | .chargeSession{ 17 | padding: 1.5vw; 18 | margin-top: 0.5em; 19 | margin-left: 0.5em; 20 | margin-right: 0.5em; 21 | background: var(--accent-color-main); 22 | border-radius: 0.5em; 23 | display: flex; 24 | justify-content: space-between; 25 | } 26 | .chargeSessionElements{ 27 | flex-grow: 4; 28 | flex-basis: 0; 29 | display: flex; 30 | flex-wrap: wrap; 31 | justify-content: space-between; 32 | } 33 | .chargeSessionElement{ 34 | border-left: solid thin var(--accent-color-tertiary); 35 | flex-grow: 1; 36 | flex-basis: 0; 37 | display: flex; 38 | flex-direction: column; 39 | justify-content: center; 40 | align-items: center; 41 | } 42 | .value{ 43 | font-size: 2.25vw; 44 | } 45 | .value.big{ 46 | font-size: 4vw; 47 | } 48 | .value.small{ 49 | font-size: 1.75vw; 50 | } 51 | .unit{ 52 | font-size: 2vw; 53 | color: var(--font-color-secondary); 54 | } 55 | .unit.small{ 56 | font-size: 1.5vw; 57 | } 58 | .chargeSessionElement > label{ 59 | font-size: 2vw; 60 | align-self: flex-start; 61 | margin-left: 1vw; 62 | display: none; 63 | color: var(--font-color-secondary); 64 | } 65 | .chargeSessionTimeDate{ 66 | display: flex; 67 | flex-direction: column; 68 | align-items: center; 69 | } 70 | @media only screen and (max-width: 480px) { 71 | .chargeSessionElement{ 72 | border: none; 73 | min-width: 115px; 74 | } 75 | .chargeSessionElement:first-child{ 76 | border-right: solid thin var(--accent-color-tertiary); 77 | } 78 | .chargeSessionElement:nth-child(3){ 79 | border-top: solid thin var(--accent-color-tertiary); 80 | } 81 | .chargeSessionElement:last-child{ 82 | border-top: solid thin var(--accent-color-tertiary); 83 | border-left: solid thin var(--accent-color-tertiary); 84 | } 85 | #chargeSessionHeader{ 86 | display: none; 87 | } 88 | .chargeSessionElement > label{ 89 | display: var(--chargeSession-mobile-display-label); 90 | } 91 | .chargeSessionTimeDate{ 92 | flex-direction: row; 93 | } 94 | #date{ 95 | margin-left: 1vw; 96 | } 97 | .value{ 98 | font-size: 4vw; 99 | } 100 | .value.big{ 101 | font-size: 6.5vw; 102 | } 103 | .value.small{ 104 | font-size: 3vw; 105 | } 106 | .unit{ 107 | font-size: 3vw; 108 | } 109 | .unit.small{ 110 | font-size: 2.5vw; 111 | } 112 | } 113 | @media only screen and (max-width: 370px) { 114 | .chargeSessionElement{ 115 | border: none !important; 116 | border-bottom: solid thin var(--accent-color-tertiary) !important; 117 | } 118 | .chargeSessionElement:last-child{ 119 | border: none !important; 120 | } 121 | } 122 | .chargeSessionElement.doughnut{ 123 | width: 15%; 124 | border: none !important; 125 | } -------------------------------------------------------------------------------- /public/idView/css/datepicker.css: -------------------------------------------------------------------------------- 1 | input, button { 2 | background-color: var(--accent-color-secondary); 3 | border: thin solid var(--accent-color-tertiary); 4 | color: var(--font-color); 5 | padding: 0.25em 1em; 6 | margin-right: 0.25em; 7 | text-align: center; 8 | text-decoration: none; 9 | display: inline-block; 10 | font-size: 1em; 11 | } 12 | 13 | .flatpickr-mobile{ 14 | -webkit-appearance: button; 15 | max-width: 13em; 16 | padding: 0.25em; 17 | } 18 | 19 | #timetravel{ 20 | max-width: 12em; 21 | } 22 | #timetravelClear{ 23 | max-width: 1em; 24 | } 25 | #graphDateRange{ 26 | max-width: 17.5em; 27 | } 28 | 29 | button:hover{ 30 | cursor: pointer; 31 | background-color: var(--accent-color-tertiary); 32 | } 33 | 34 | .flatpickr-current-month .flatpickr-monthDropdown-months{ 35 | background: var(--background-color); 36 | -webkit-appearance: list-button; 37 | } 38 | 39 | .flatpickr-months .flatpickr-prev-month, .flatpickr-months .flatpickr-next-month{ 40 | fill: var(--font-color); 41 | } 42 | 43 | .flatpickr-months .flatpickr-month{ 44 | color: var(--font-color); 45 | } 46 | .flatpickr-calendar{ 47 | background: var(--background-color); 48 | } 49 | span.flatpickr-weekday{ 50 | color: var(--font-color); 51 | } 52 | .flatpickr-day{ 53 | color: var(--font-color); 54 | } 55 | .flatpickr-day.inRange, .flatpickr-day.prevMonthDay.inRange, .flatpickr-day.nextMonthDay.inRange, .flatpickr-day.today.inRange, .flatpickr-day.prevMonthDay.today.inRange, .flatpickr-day.nextMonthDay.today.inRange, .flatpickr-day:hover, .flatpickr-day.prevMonthDay:hover, .flatpickr-day.nextMonthDay:hover, .flatpickr-day:focus, .flatpickr-day.prevMonthDay:focus, .flatpickr-day.nextMonthDay:focus{ 56 | background: var(--accent-color-tertiary); 57 | border-color: transparent; 58 | border-left-color: transparent !important; 59 | } 60 | span.flatpickr-day.startRange, span.flatpickr-day.prevMonthDay.startRange, span.flatpickr-day.nextMonthDay.startRange, span.flatpickr-day.endRange, span.flatpickr-day.prevMonthDay.endRange, span.flatpickr-day.nextMonthDay.endRange{ 61 | border-color: transparent; 62 | } 63 | .flatpickr-day.flatpickr-disabled, .flatpickr-day.flatpickr-disabled:hover, .flatpickr-day.prevMonthDay, .flatpickr-day.nextMonthDay, .flatpickr-day.notAllowed, .flatpickr-day.notAllowed.prevMonthDay, .flatpickr-day.notAllowed.nextMonthDay{ 64 | color: var(--font-color-secondary); 65 | } 66 | .flatpickr-day.inRange{ 67 | -webkit-box-shadow: none; 68 | box-shadow: none; 69 | } -------------------------------------------------------------------------------- /public/idView/css/idView.css: -------------------------------------------------------------------------------- 1 | body{ 2 | padding-top: env(safe-area-inset-top); 3 | padding-left: env(safe-area-inset-left); 4 | padding-bottom: env(safe-area-inset-bottom); 5 | padding-right: env(safe-area-inset-right); 6 | /*min-height: calc(100vh - env(safe-area-inset-top, 50vh) - env(safe-area-inset-bottom, 50vh)); /* iOS is weird */ 7 | min-height: 100vh; 8 | } 9 | .top{ 10 | position: fixed; 11 | top: 0; 12 | left: 0; 13 | height: env(safe-area-inset-top, 0px); 14 | width: 100%; 15 | backdrop-filter: blur(6px); 16 | -webkit-backdrop-filter: blur(6px); 17 | background-color: rgba(0,0,0,0.75); 18 | } 19 | .container{ 20 | display: flex; 21 | flex-direction: column; 22 | padding-left: 1em; 23 | padding-right: 1em; 24 | } 25 | .row{ 26 | display: flex; 27 | justify-content: space-between; 28 | align-content: stretch; 29 | margin-bottom: 1em; 30 | } 31 | .row > *{ 32 | flex-grow: 1; 33 | flex-shrink: 1; 34 | min-width: 0; 35 | width: 33%; 36 | display: flex; 37 | flex-direction: column; 38 | justify-content: center; 39 | align-items: center; 40 | } 41 | #timetravel, .flatpickr-mobile{ 42 | margin-left: auto; 43 | } 44 | #carUpdate{ 45 | width: auto; 46 | flex-grow: 0; 47 | color: var(--font-color-secondary); 48 | margin-right: 10px; 49 | } 50 | .bigvalue{ 51 | font-size: 5vw; 52 | text-align: center; 53 | } 54 | .responsive { 55 | width: 100%; 56 | height: auto; 57 | } 58 | .heat{ 59 | color: red; 60 | } 61 | .cool{ 62 | color: aqua; 63 | } 64 | #versionInfo{ 65 | color: var(--font-color-secondary); 66 | 67 | } 68 | .hidden{ 69 | display: none; 70 | } 71 | iframe{ 72 | border: none; 73 | width: 100%; 74 | height: 100%; 75 | } 76 | @media screen and (min-width: 480px) { 77 | .container { 78 | padding: 1em; 79 | } 80 | } 81 | @media screen and (min-width: 1500px) { 82 | .bigvalue { 83 | font-size: 5em; 84 | } 85 | } -------------------------------------------------------------------------------- /public/idView/css/pageNavigation.css: -------------------------------------------------------------------------------- 1 | .pagenav{ 2 | margin-top: 0; 3 | padding: 0; 4 | top: 0; 5 | left: 0; 6 | position: fixed; 7 | width: 170px; 8 | height: 100%; 9 | border-right: thin solid var(--accent-color-secondary); 10 | backdrop-filter: blur(6px); 11 | -webkit-backdrop-filter: blur(6px); 12 | background-color: var(--transparent-accent); 13 | } 14 | .pagenav a{ 15 | display: block; 16 | padding: 16px; 17 | padding-bottom: 10px; 18 | cursor: pointer; 19 | } 20 | /* Active page */ 21 | .pagenav a.active { 22 | background-color: var(--accent-color-secondary); 23 | } 24 | 25 | .pagenav a:hover:not(.active) { 26 | background-color: var(--accent-color-tertiary); 27 | } 28 | 29 | .page{ 30 | padding-left: 170px; 31 | margin: auto; 32 | max-width: 1500px; 33 | } 34 | 35 | ul .horizontal > li { 36 | display: inline-block; 37 | /* You can also add some margins here to make it look prettier */ 38 | zoom: 1; 39 | } 40 | 41 | @media only screen and (max-width: 750px){ 42 | .pagenav{ 43 | display: flex; 44 | justify-content: space-between; 45 | bottom: 0; 46 | width: 100%; 47 | height: auto; 48 | top: auto; 49 | white-space: nowrap; 50 | overflow-y: auto; 51 | border-top: thin solid var(--accent-color-secondary); 52 | margin-bottom: 0; 53 | padding-bottom: env(safe-area-inset-bottom); 54 | } 55 | .pagenav > li{ 56 | display: inline-block; 57 | } 58 | .page{ 59 | padding-left: 0; 60 | } 61 | } -------------------------------------------------------------------------------- /public/idView/css/theme.css: -------------------------------------------------------------------------------- 1 | :root{ 2 | --font-color: white; 3 | --font-color-secondary: #777; 4 | --background-color: black; 5 | --accent-color-main: #111; 6 | --accent-color-secondary: #222; 7 | --accent-color-tertiary: #2c2c2c; 8 | --transparent-accent: rgba(25, 25, 25, 0.75); 9 | } 10 | [data-theme="dimmed"]{ 11 | --font-color: white; 12 | --font-color-secondary: #999; 13 | --background-color: #111; 14 | --accent-color-main: #1c1c1c; 15 | --accent-color-secondary: #222; 16 | --accent-color-tertiary: #333; 17 | } 18 | body{ 19 | color: var(--font-color); 20 | background: var(--background-color); 21 | margin: 0; 22 | font-family: "Avenir", sans-serif; 23 | } 24 | *, ::before, ::after { box-sizing: border-box; } -------------------------------------------------------------------------------- /public/idView/idView.php: -------------------------------------------------------------------------------- 1 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | IDView 27 | 28 | 29 |
30 |
31 |
32 |
33 |
34 | Car 35 |
36 |
37 | 38 |
39 |
40 | 000km 41 | ------- 42 | 00.0°C 43 |
44 |
45 | 57 |
58 |
59 | 60 | 65 |
66 |
67 | sub(new DateInterval("P7D"))->setTime(0, 0)->getTimestamp(), time(), true))->getGraphData(); 69 | 70 | $graph = (new Graph("carGraph", GraphDisplayType::LINE))->setLineTension(0); 71 | 72 | $xAxis = new Xaxis($carGraphData->time); 73 | 74 | $batterySOC = new Dataset("batterySOC", $carGraphData->batterySOC, new Colour(0, 255, 255)); 75 | $targetSOC = new Dataset("targetSOC", $carGraphData->targetSOC, new Colour(255, 0, 0), null, true); 76 | $xAxis->addDataset($batterySOC)->addDataset($targetSOC); 77 | $graph->addYaxis((new Yaxis("e", "%"))->addDataset($batterySOC)->addDataset($targetSOC)->setMinMax(0, 100)->displayGridLines(false)->display(false)); 78 | 79 | $remainingRange = new Dataset("remainingRange", $carGraphData->remainingRange, new Colour(0, 128, 255)); 80 | $xAxis->addDataset($remainingRange); 81 | $graph->addYaxis((new Yaxis("r", "km"))->addDataset($remainingRange)->displayGridLines(false)); 82 | 83 | $remainingChargingTime = new Dataset("remainingChargingTime", $carGraphData->remainingChargingTime, new Colour(128, 0, 255), null, true); 84 | $xAxis->addDataset($remainingChargingTime); 85 | $graph->addYaxis((new Yaxis("t", "min"))->addDataset($remainingChargingTime)->displayGridLines(false)->display(false)); 86 | 87 | $chargePower = new Dataset("chargePower", $carGraphData->chargePower, new Colour(0, 255, 0)); 88 | $chargePower->setSteppedLine(); 89 | $xAxis->addDataset($chargePower); 90 | $graph->addYaxis((new Yaxis("p", "kW"))->addDataset($chargePower)->setMinMax(0)); 91 | 92 | $chargeRateKMPH = new Dataset("chargeRateKMPH", $carGraphData->chargeRateKMPH, new Colour(0, 255, 0), null, true); 93 | $chargeRateKMPH->setSteppedLine(); 94 | $xAxis->addDataset($chargeRateKMPH); 95 | $graph->addYaxis((new Yaxis("k", "km/h"))->addDataset($chargeRateKMPH)->setMinMax(0)->display(false)); 96 | 97 | $graph->setXaxis($xAxis); 98 | 99 | $graph->canvas(); 100 | ?> 101 |
102 |
103 | 104 |
105 |
106 | 107 | 111 |
112 | 113 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | render(); 133 | ?> 134 | 135 | 136 | 137 | -------------------------------------------------------------------------------- /public/idView/js/AnimatedValue.js: -------------------------------------------------------------------------------- 1 | class AnimatedValue{ 2 | constructor(displayElement, startValue, unit, decimals = 0, scale = 1, animationLength = 1){ 3 | this.element = displayElement; 4 | this.value = 0; 5 | this.setValue(startValue); 6 | this.unit = unit; 7 | 8 | this.decimals = decimals; 9 | this.scale = scale; 10 | this.animationLength = animationLength; 11 | } 12 | 13 | animate(timestamp){ 14 | if(this.start == -1){ 15 | this.start = timestamp; 16 | } 17 | let elapsed = timestamp - this.start; 18 | let animationProgress = elapsed / (this.animationLength*1000); 19 | 20 | this.element.innerHTML = (Math.round(this.value + ((this.newValue-this.value)*Chart.helpers.easing.effects.easeOutQuart(animationProgress))) / this.scale).toFixed(this.decimals) + this.unit; 21 | 22 | if(animationProgress > 1){ 23 | this.value = this.newValue; 24 | this.element.innerHTML = (this.value / this.scale).toFixed(this.decimals) + this.unit; 25 | this.start = -1; 26 | }else{ 27 | window.requestAnimationFrame(this.animate.bind(this)); 28 | } 29 | } 30 | 31 | setValue(value){ 32 | this.start = -1; 33 | this.newValue = parseFloat(value); 34 | window.requestAnimationFrame(this.animate.bind(this)); 35 | 36 | } 37 | } -------------------------------------------------------------------------------- /public/idView/js/DoughnutValue.js: -------------------------------------------------------------------------------- 1 | Chart.defaults.global.defaultFontColor = "#fff"; 2 | class DoughnutValue{ 3 | constructor(canvas, value, max, unit, legendName){ 4 | this.canvas = canvas; 5 | this.value = value; 6 | this.max = max; 7 | this.unit = unit; 8 | this.legendName = legendName; 9 | 10 | this.animationProgress = 0; 11 | 12 | this.createChart(); 13 | window.addEventListener('resize', function(){ 14 | setTimeout(this.update.bind(this), 100); 15 | }.bind(this)); 16 | } 17 | 18 | createChart(){ 19 | let plugin = { 20 | afterDraw: function(chart){ 21 | let ctx = chart.ctx; 22 | ctx.textAlign = 'center'; 23 | ctx.textBaseline = 'middle'; 24 | ctx.fillStyle = 'white'; 25 | ctx.font = Chart.helpers.fontString(this.canvas.height/(5*this.chart.currentDevicePixelRatio), Chart.defaults.global.defaultFontStyle, Chart.defaults.global.defaultFontFamily) 26 | let legendOffset = this.chart.options.legend.display ? 15 : 0; 27 | ctx.fillText( 28 | Math.round(this.getInnerDisplayValue() * Chart.helpers.easing.effects.easeOutQuart(this.animationProgress))+this.unit, 29 | this.canvas.width/(2*this.chart.currentDevicePixelRatio), this.canvas.height/(2*this.chart.currentDevicePixelRatio)-legendOffset); 30 | }.bind(this) 31 | }; 32 | 33 | console.log("creating chart..."); 34 | this.chart = new Chart(this.canvas.getContext('2d'), { 35 | type: 'doughnut', 36 | data: this.generateData(), 37 | plugins: [plugin], 38 | options: { 39 | aspectRatio: window.innerWidth >= 480 ? 1.6 : 1.1, 40 | cutoutPercentage: 80, 41 | tooltips: { 42 | custom: function(tooltipModel) { 43 | if(!tooltipModel.body || tooltipModel.body.length < 1){ 44 | tooltipModel.caretSize = 0; 45 | tooltipModel.xPadding = 0; 46 | tooltipModel.yPadding = 0; 47 | tooltipModel.cornerRadius = 0; 48 | tooltipModel.width = 0; 49 | tooltipModel.height = 0; 50 | } 51 | }, 52 | filter: this.onTooltipFilterCallback, 53 | callbacks: { 54 | label: this.onTooltipLabelCallback.bind(this), 55 | } 56 | }, 57 | animation: { 58 | onProgress: function(animation){ 59 | if(this.animationProgress == 1){ 60 | return; 61 | } 62 | this.animationProgress = animation.animationObject.currentStep / animation.animationObject.numSteps; 63 | }.bind(this), 64 | onComplete: function(animation){ 65 | this.animationProgress = 1; 66 | }.bind(this) 67 | }, 68 | legend: { 69 | position: 'bottom', 70 | onClick: this.onLegendClick.bind(this) 71 | }, 72 | } 73 | }); 74 | } 75 | 76 | onTooltipFilterCallback(tooltipItem, data) { 77 | return tooltipItem.index < 1; 78 | } 79 | 80 | onTooltipLabelCallback(tooltipItem, data){ 81 | let label = data.labels[tooltipItem.index] || ''; 82 | 83 | let value = data.datasets[tooltipItem.datasetIndex].data[tooltipItem.index]; 84 | if(label){ 85 | label += ': '; 86 | } 87 | label += value; 88 | label += this.unit; 89 | 90 | return label; 91 | } 92 | 93 | onLegendClick(e, legendItem){ 94 | return; 95 | } 96 | 97 | generateData(){ 98 | return { 99 | datasets: [{ 100 | data: [this.value, Math.max(this.max-this.value, 0)], 101 | backgroundColor: ['rgba(0,255,0,1)', 'rgba(0,0,0,1)'], 102 | borderColor: ['rgba(0,255,0,1)', 'rgba(255,0,0,0)'] 103 | }], 104 | labels: [this.legendName] 105 | } 106 | } 107 | 108 | getInnerDisplayValue(){ 109 | return this.value; 110 | } 111 | 112 | updateData(){ 113 | this.chart.data.datasets[0].data=[this.value, Math.max(this.max-this.value, 0)]; 114 | } 115 | 116 | update(){ 117 | if( 118 | (window.innerWidth >= 480 && this.chart.options.aspectRatio != 1.6) || 119 | (window.innerWidth < 480 && this.chart.options.aspectRatio != 1.1) 120 | ){ 121 | const parentEle = this.canvas.parentElement; 122 | parentEle.removeChild(this.canvas); 123 | this.canvas = document.createElement('canvas'); 124 | this.canvas.id = "soc"; 125 | parentEle.appendChild(this.canvas); 126 | this.createChart(); 127 | }else{ 128 | this.updateData(); 129 | } 130 | this.chart.options.legend.display = this.canvas.width >= 480; 131 | this.chart.update(); 132 | } 133 | } 134 | class InvertedDoughnutValue extends DoughnutValue{ 135 | getInnerDisplayValue(){ 136 | return this.max - this.value; 137 | } 138 | } 139 | class SOCDoughnutValue extends DoughnutValue{ 140 | constructor(canvas, value, max, unit, legendName){ 141 | super(canvas, value, max, unit, legendName) 142 | this.targetSOC = value 143 | } 144 | 145 | onTooltipFilterCallback(tooltipItem, data) { 146 | return tooltipItem.index < 2; 147 | } 148 | 149 | onTooltipLabelCallback(tooltipItem, data){ 150 | let label = data.labels[tooltipItem.index] || ''; 151 | 152 | let value = data.datasets[tooltipItem.datasetIndex].data[tooltipItem.index]; 153 | if(label == "targetSOC"){ 154 | value = this.targetSOC; 155 | } 156 | if(label){ 157 | label += ': '; 158 | } 159 | label += value; 160 | label += this.unit; 161 | 162 | return label; 163 | } 164 | 165 | onLegendClick(e, legendItem){ 166 | let index = legendItem.index; 167 | if(index == 0){ 168 | return; 169 | } 170 | let n, i, a; 171 | for(n = 0, i = (this.chart.data.datasets || []).length; n < i; ++n){ 172 | (a = this.chart.getDatasetMeta(n)).data[index] && (a.data[index].hidden = !a.data[index].hidden); 173 | if(index == 1){ 174 | if(a.data[index].hidden){ 175 | this.chart.data.datasets[0].data[2] = this.max-this.value; 176 | }else{ 177 | this.chart.data.datasets[0].data[2] = this.max-Math.max(this.value, this.targetSOC); 178 | } 179 | } 180 | } 181 | this.chart.update(); 182 | } 183 | 184 | generateData(){ 185 | return { 186 | datasets: [{ 187 | data: [this.value, Math.max(this.targetSOC-this.value, 0), this.max-Math.max(this.value, this.targetSOC)], 188 | backgroundColor: ['rgba(0,255,0,1)', 'rgba(0,0,0,1)', 'rgba(0,0,0,0)'], 189 | borderColor: ['rgba(0,255,0,1)', 'rgba(255,0,0,1)', 'rgba(0,0,0,0)'] 190 | }], 191 | labels: [this.legendName, 'targetSOC'] 192 | } 193 | } 194 | 195 | updateData(){ 196 | this.chart.data.datasets[0].data=[this.value, Math.max(this.targetSOC-this.value, 0), this.max-Math.max(this.value, this.targetSOC)]; 197 | } 198 | } 199 | class SOCchargeDoughnutValue extends DoughnutValue{ 200 | constructor(canvas, start, end, legendName){ 201 | super(canvas, start, 100, "%", legendName) 202 | this.end = end 203 | this.displayStart = false 204 | this.chart.options.onHover = function(ev, objects){ 205 | if(objects.length > 0){ 206 | const index = objects[0]._index 207 | this.displaySlice = index 208 | this.update() 209 | }else{ 210 | this.displaySlice = undefined; 211 | } 212 | }.bind(this) 213 | this.update() 214 | } 215 | 216 | onLegendClick(e, legendItem){ 217 | let index = legendItem.index; 218 | if(index == 0){ 219 | return; 220 | } 221 | let n, i, a; 222 | for(n = 0, i = (this.chart.data.datasets || []).length; n < i; ++n){ 223 | (a = this.chart.getDatasetMeta(n)).data[index] && (a.data[index].hidden = !a.data[index].hidden); 224 | if(index == 1){ 225 | if(a.data[index].hidden){ 226 | this.chart.data.datasets[0].data[2] = this.max-this.value; 227 | this.displaySlice = 0; 228 | }else{ 229 | this.chart.data.datasets[0].data[2] = this.max-this.end; 230 | this.displaySlice = undefined; 231 | } 232 | } 233 | } 234 | this.chart.update(); 235 | } 236 | 237 | onTooltipFilterCallback(tooltipItem, data) { 238 | return false; 239 | } 240 | 241 | getInnerDisplayValue(){ 242 | switch(this.displaySlice){ 243 | case 0: 244 | return this.value; 245 | case 1: 246 | return this.end-this.value; 247 | default: 248 | return this.end 249 | } 250 | } 251 | 252 | generateData(){ 253 | return { 254 | datasets: [{ 255 | data: [this.value, this.end-this.value, this.max-this.end], 256 | backgroundColor: ['rgba(0,255,0,1)', 'rgba(0,200,200,1)', 'rgba(0,0,0,0)'], 257 | borderColor: ['rgba(0,255,0,1)', 'rgba(0,200,200,1)', 'rgba(0,0,0,0)'] 258 | }], 259 | labels: [this.legendName, 'charged'] 260 | } 261 | } 262 | 263 | updateData(){ 264 | this.chart.data.datasets[0].data=[this.value, this.end-this.value, this.max-this.end]; 265 | } 266 | } -------------------------------------------------------------------------------- /public/idView/js/SelectableList.js: -------------------------------------------------------------------------------- 1 | class SelectableList{ 2 | constructor(listElement, onSelectFunc){ 3 | this.currSelection = null; 4 | this.listElement = listElement; 5 | this.onSelectFunc = onSelectFunc; 6 | this.initListeners(); 7 | } 8 | 9 | initListeners(){ 10 | for(let preset of this.listElement.children){ 11 | if(!preset.classList.contains("selectablePage")){ 12 | continue; 13 | } 14 | let me = this; 15 | preset.onclick = function(){ 16 | me.select(this) 17 | } 18 | } 19 | } 20 | 21 | select(element){ 22 | if(this.currSelection !== element){ 23 | element.children[0].classList.add("active"); 24 | if(this.currSelection !== null){ 25 | this.currSelection.children[0].classList.remove("active"); 26 | } 27 | this.onSelectFunc(this.currSelection === null ? null : this.currSelection.id, element.id); 28 | this.currSelection = element; 29 | } 30 | } 31 | 32 | } -------------------------------------------------------------------------------- /public/idView/js/chargingOverview.js: -------------------------------------------------------------------------------- 1 | //document.documentElement.setAttribute('data-theme', 'dimmed'); 2 | document.documentElement.setAttribute('data-chargeSessions-mobile-label', 'display'); 3 | let dateLocaleSetting = "de-DE"; 4 | 5 | let doughnuts = {}; 6 | function createChargeSession(data, last = false){ 7 | const chargeSessions = document.querySelector("#chargeSessions"); 8 | 9 | let didExist = true; 10 | let cS = chargeSessions.querySelector("#chargeSession-"+data.id); 11 | if(cS === null){ 12 | didExist = false; 13 | cS = document.querySelector("#chargeSession").content.firstElementChild.cloneNode(true); 14 | } 15 | 16 | cS.id = "chargeSession-"+data.id; 17 | cS.querySelector("#duration").textContent = Math.floor(data.duration / 3600)+":"+String(Math.floor(data.duration % 3600 / 60)).padStart(2, '0'); 18 | const timeOpts = { 19 | hour: '2-digit', 20 | minute: '2-digit' 21 | }; 22 | cS.querySelector("#time").textContent = 23 | (new Date(data.startTime)).toLocaleString(dateLocaleSetting, timeOpts)+" - "+ 24 | (new Date(data.endTime ?? Date.now())).toLocaleString(dateLocaleSetting, timeOpts) 25 | ; 26 | cS.querySelector("#date").textContent = (new Date(data.startTime)).toLocaleString(dateLocaleSetting, { 27 | day: '2-digit', 28 | month: '2-digit', 29 | year: '2-digit' 30 | }); 31 | cS.querySelector("#avgchargepower").textContent = data.avgChargePower; 32 | cS.querySelector("#minChargePower").textContent = data.minChargePower 33 | cS.querySelector("#maxChargePower").textContent = data.maxChargePower; 34 | cS.querySelector("#kWh").textContent = Math.round(data.chargeEnergy/360, 2)/10; 35 | cS.querySelector("#range").textContent = (data.rangeEnd - data.rangeStart); 36 | 37 | if(!didExist){ 38 | if(!last){ 39 | chargeSessions.querySelector("#chargeSessionHeader").after(cS); 40 | }else{ 41 | chargeSessions.appendChild(cS); 42 | } 43 | doughnuts[data.id] = new SOCchargeDoughnutValue(cS.querySelector("#chargesoc"), data.socStart, data.socEnd, "soc"); 44 | }else{ 45 | doughnuts[data.id].value = data.socStart; 46 | doughnuts[data.id].end = data.socEnd; 47 | doughnuts[data.id].update(); 48 | } 49 | } 50 | 51 | async function getJSON(link){ 52 | return (await fetch(link)).json(); 53 | } 54 | 55 | let beginTime = null; 56 | let endTime = null; 57 | 58 | async function updateChargingSessions(inital = false){ 59 | let chargingSessions; 60 | try{ 61 | chargingSessions = await getJSON( 62 | "../chargingSessions.php"+ 63 | (beginTime == null ? "" : "?beginTime="+Math.round(beginTime.getTime()/1000))+ 64 | (endTime == null ? "" : "&endTime="+Math.round(endTime.getTime()/1000)) 65 | ); 66 | }catch(SyntaxError){ 67 | window.location.replace("../login/login.php?destination=../idView/chargingOverview.html"); 68 | return; 69 | } 70 | processChargingSession(chargingSessions, inital); 71 | } 72 | 73 | function processChargingSession(chargingSessions, last = false){ 74 | for(const cid in chargingSessions){ 75 | createChargeSession(chargingSessions[cid], last); 76 | } 77 | } 78 | 79 | setInterval(updateChargingSessions, 30000); 80 | 81 | updateChargingSessions(true); -------------------------------------------------------------------------------- /public/idView/js/idView.js: -------------------------------------------------------------------------------- 1 | function carGraphAspectRatio(){ 2 | const chart = chartStore.carGraph.chart; 3 | chart.aspectRatio = window.innerWidth >= 480 ? 2 : 1; 4 | chart.resize(); 5 | } 6 | window.addEventListener('resize', carGraphAspectRatio); 7 | carGraphAspectRatio(); 8 | 9 | const dateLocaleSetting = "de-DE"; 10 | flatpickr.localize(flatpickr.l10ns.de); 11 | let timetravelPicker = flatpickr("#timetravel", {dateFormat: "d.m.Y H:i", onChange: timetravelUser, enableTime: true, time_24hr: true}); 12 | flatpickr("#graphDateRange", {mode: "range", dateFormat: "d.m.Y", onChange: carGraphRangeUser}); 13 | 14 | let chargePower = new AnimatedValue(document.getElementById("chargePower"), 0, "kW", 1, 10); 15 | let chargeKMPH = new AnimatedValue(document.getElementById("chargeKMPH"), 0, "km/h"); 16 | let range = new AnimatedValue(document.getElementById("range"), 0, "km"); 17 | let targetTemp = new AnimatedValue(document.getElementById("hvactargettemp"), 0, "°C", 1, 10); 18 | let soc = new SOCDoughnutValue(document.getElementById("soc"), 0, 100, "%", "soc"); 19 | let chargeTimeRemaining = null; 20 | 21 | async function getJSON(link){ 22 | return (await fetch(link)).json(); 23 | } 24 | 25 | function timetravelUser(selectedDates, dateStr, instance){ 26 | timetravel(selectedDates[0]); 27 | } 28 | 29 | let timetravelStatus = false; 30 | let timetravelDate; 31 | 32 | function timetravel(date){ 33 | console.log(date); 34 | if(date == false || date == undefined || date == null){ 35 | timetravelStatus = false; 36 | updateCarStatus(); 37 | return; 38 | } 39 | timetravelStatus = true; 40 | timetravelDate = "@"+Math.round(date.getTime() / 1000); 41 | updateCarStatus(); 42 | } 43 | 44 | updateCarStatus(); 45 | 46 | setInterval(updateCarStatus, 15000); 47 | 48 | async function updateCarStatus(){ 49 | const carStatus = await getJSON("../carStatus.php"+(timetravelStatus ? "?at="+timetravelDate : "")); 50 | if(carStatus == undefined){ 51 | alert("failed to decode carStatus JSON"); 52 | return; 53 | } 54 | if(carStatus.error != undefined){ 55 | alert(carStatus.error); 56 | return; 57 | } 58 | processCarStatus(carStatus); 59 | } 60 | 61 | function processCarStatus(carStatus){ 62 | if(carStatus.plugConnectionState == "connected"){ 63 | document.getElementById("chargingDisplay").style.display = "flex"; 64 | let chargeState; 65 | switch(carStatus.chargeState){ 66 | case "charging": 67 | chargeState = "charging..."; 68 | break; 69 | case "chargePurposeReachedAndConservation": 70 | chargeState = "holding charge"; 71 | break; 72 | case "chargePurposeReachedAndNotConservationCharging": 73 | chargeState = "charged"; 74 | break; 75 | case "readyForCharging": 76 | chargeState = "not charging"; 77 | break; 78 | case "notReadyForCharging": 79 | chargeState = "not charging"; 80 | break; 81 | default: 82 | chargeState = "unknown: "+carStatus.chargeState; 83 | } 84 | document.getElementById("chargingState").innerHTML = chargeState + "
" + "Plug " + carStatus.plugLockState; 85 | //setTimeout(.bind(chargeTimeRemaining), 50); 86 | }else{ 87 | document.getElementById("chargingDisplay").style.display = "none"; 88 | } 89 | soc.value = carStatus.batterySOC; 90 | soc.targetSOC = carStatus.targetSOC; 91 | soc.update(); 92 | 93 | const carStatusTime = new Date(carStatus.time); 94 | document.getElementById("carUpdate").textContent = carStatusTime.toLocaleString(dateLocaleSetting, { 95 | day: '2-digit', 96 | month: '2-digit' 97 | }) + " " + carStatusTime.toLocaleString(dateLocaleSetting, { 98 | hour: '2-digit', 99 | minute: '2-digit' 100 | }); 101 | let now; 102 | if(timetravel){ 103 | now = Date.parse(carStatus.time); 104 | }else{ 105 | now = Date.now(); 106 | } 107 | const elapsedMinutes = Math.round((now - Date.parse(carStatus.lastChargeStartTime)) / 60000); 108 | //console.log(elapsedMinutes); 109 | //console.log(carStatus.remainingchargingtime); 110 | //console.log(carStatus.lastChargeStartTime); 111 | const realTimeRemaining = now - Date.parse(carStatus.time) + parseInt(carStatus.remainingChargingTime); 112 | if(chargeTimeRemaining === null){ 113 | chargeTimeRemaining = new InvertedDoughnutValue(document.getElementById("chargingTimeRemaining"), 0, 0, "min", "charging progress") 114 | } 115 | chargeTimeRemaining.max = elapsedMinutes + realTimeRemaining; 116 | chargeTimeRemaining.value = chargeTimeRemaining.max - realTimeRemaining; 117 | chargeTimeRemaining.update(); 118 | 119 | range.setValue(carStatus.remainingRange); 120 | chargePower.setValue(carStatus.chargePower*10); 121 | chargeKMPH.setValue(carStatus.chargeRateKMPH); 122 | targetTemp.setValue(carStatus.hvacTargetTemp*10); 123 | 124 | let hvacstate; 125 | document.getElementById("hvacstate").classList.remove("heat"); 126 | document.getElementById("hvacstate").classList.remove("cool"); 127 | switch(carStatus.hvacState){ 128 | case "heating": 129 | hvacstate = "heating"; 130 | document.getElementById("hvacstate").classList.add("heat"); 131 | break; 132 | case "cooling": 133 | hvacstate = "cooling"; 134 | document.getElementById("hvacstate").classList.add("cool"); 135 | break; 136 | case "off": 137 | hvacstate = "hvac off"; 138 | break; 139 | case "ventilation": 140 | hvacstate = "ventilating"; 141 | break; 142 | default: 143 | hvacstate = "unknown: "+carStatus.hvacstate; 144 | } 145 | document.getElementById("hvacstate").innerHTML = hvacstate; 146 | } 147 | 148 | function carGraphRangeUser(selectedDates, dateStr, instance){ 149 | if(selectedDates[0] != null){ 150 | beginTime = selectedDates[0]; 151 | }else{ 152 | beginTime = new Date(); 153 | beginTime.setDate(beginTime.getDate()-7); 154 | beginTime.setHours(0,0,0,0); 155 | } 156 | if(selectedDates.length > 1){ 157 | endTime = selectedDates[1]; 158 | endTime.setHours(24); 159 | }else{ 160 | endTime = null; 161 | } 162 | updateCarGraph(); 163 | } 164 | 165 | let currUCStimeout; 166 | chartStore.carGraph.chart.options.onHover = function(ev, objects){ 167 | if(objects.length === 0){ 168 | return; 169 | } 170 | chart = objects[0]._chart; 171 | index = objects[0]._index; 172 | timetravelPicker.setDate(Date.parse(chart.options.scales.xAxes[0].labels[index])); 173 | let timestamp = "@"+Math.round(Date.parse(chart.options.scales.xAxes[0].labels[index]) / 1000); 174 | if(timetravelDate != timestamp){ 175 | timetravelDate = timestamp; 176 | timetravelStatus = true; 177 | clearTimeout(currUCStimeout); 178 | currUCStimeout = setTimeout(updateCarStatus, 100); 179 | } 180 | }; 181 | chartStore.carGraph.chart.update(); 182 | 183 | let beginTime = new Date(); 184 | beginTime.setDate(beginTime.getDate()-7); 185 | beginTime.setHours(0,0,0,0); 186 | let endTime = null; 187 | 188 | async function updateCarGraph(){ 189 | console.log(beginTime); 190 | const graphData = await getJSON( 191 | "carGraphData.php?beginTime="+Math.round(beginTime.getTime()/1000)+ 192 | (endTime == null ? "" : "&endTime="+Math.round(endTime.getTime()/1000))+ 193 | "&dataBracketing=true" 194 | ); 195 | if(graphData == undefined){ 196 | alert("failed to decode carGraphData JSON"); 197 | return; 198 | } 199 | processCarGraphUpdate(graphData); 200 | } 201 | 202 | setInterval(updateCarGraph, 60000); 203 | 204 | function processCarGraphUpdate(graphData){ 205 | const chart = chartStore.carGraph.chart; 206 | chart.options.scales.xAxes[0].labels = graphData.time; 207 | chart.data.datasets[0].data = graphData.batterySOC; 208 | chart.data.datasets[1].data = graphData.targetSOC; 209 | chart.data.datasets[2].data = graphData.remainingRange; 210 | chart.data.datasets[3].data = graphData.remainingChargingTime; 211 | chart.data.datasets[4].data = graphData.chargePower; 212 | chart.data.datasets[5].data = graphData.chargeRateKMPH; 213 | chart.update(); 214 | } -------------------------------------------------------------------------------- /public/idView/js/pageNavigation.js: -------------------------------------------------------------------------------- 1 | const pageList = new SelectableList(document.querySelector("#pagenav"), function(oldid, newid){ 2 | if(oldid !== null){ 3 | let oldPage = document.querySelector("#"+oldid+"Page"); 4 | if(oldPage === null){ 5 | alert("Could not find #"+oldid+"Page"); 6 | }else{ 7 | oldPage.classList.add("hidden"); 8 | } 9 | } 10 | let newPage = document.querySelector("#"+newid+"Page"); 11 | if(newPage === null){ 12 | alert("Could not find #"+newid+"Page"); 13 | }else{ 14 | newPage.classList.remove("hidden"); 15 | //we are loading chargingOverview here, because loading it without displaying it causes Chart.js to break on Firefox 16 | //todo: should we limit this just to firefox, since it introduces an unnecessary loading time 17 | if(newid == "chargingOverview" && (newPage.src === undefined || !newPage.src.includes("chargingOverview.html"))){ 18 | newPage.src = "chargingOverview.html"; 19 | newPage.onload = function(){ 20 | observer.observe(document.querySelector("#chargingOverviewPage").contentWindow.document.body, { attributes: true, childList: true, subtree: true }); 21 | }; 22 | } 23 | newPage.focus(); //fixes some scrolling issues on iOS 24 | } 25 | }); 26 | 27 | pageList.select(document.querySelector("#IDView")); 28 | 29 | const observer = new MutationObserver(function(mutationsList, observer){ 30 | const chargingOverview = document.querySelector("#chargingOverviewPage"); 31 | let height = chargingOverview.contentDocument.body.scrollHeight; 32 | if(window.innerWidth <= 750){ 33 | height += 48; //menu at bottom 34 | } 35 | chargingOverview.style.height = "calc("+height+"px + 0.5em)"; //0.5em for margin in chargingSessions 36 | console.log("found mutation!"); 37 | }); -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | Login 11 | 12 | 13 | 48 | "); 58 | echo("CLICK HERE TO USE HTTPS ADRESS."); 59 | exit; 60 | } 61 | } 62 | $uri .= $_SERVER['HTTP_HOST'].dirname($_SERVER['REQUEST_URI'], 2); 63 | ?> 64 | 65 |
66 |

Login

67 | 68 | 69 |
70 | 71 |
72 | 73 | -------------------------------------------------------------------------------- /public/login/loginCheck.php: -------------------------------------------------------------------------------- 1 | query("SELECT authkey FROM authKeys"); 20 | foreach($keys as $key){ 21 | if(hash_equals($key["authkey"], $_GET['key'])){ 22 | return; 23 | } 24 | } 25 | } 26 | } 27 | 28 | ini_set("session.gc_maxlifetime", 7*24*60*60); 29 | ini_set("session.cookie_lifetime", 7*24*60*60); 30 | if(!session_start()){ 31 | fail(); 32 | } 33 | 34 | $loggedIn = false; 35 | 36 | if(!isset($_COOKIE['PHPSESSID']) || empty($_COOKIE['PHPSESSID'])){ 37 | fail(); 38 | } 39 | 40 | if(isset($_SESSION['loggedin']) && $_SESSION['loggedin'] === true){ 41 | $loggedIn = true; 42 | } 43 | 44 | if(!$loggedIn){ 45 | fail(); 46 | } 47 | 48 | function fail(){ 49 | global $uri; 50 | ?> 51 | 58 | prepare("SELECT * FROM users WHERE username = ?"); 38 | 39 | $getUser->execute([$username]); 40 | $users = $getUser->fetchAll(); 41 | 42 | if(!isset($users[0])){ 43 | authFail(); 44 | } 45 | $hash = $users[0]["hash"]; 46 | 47 | if(password_verify($password, $hash)){ 48 | if(password_needs_rehash($hash, PASSWORD_DEFAULT)){ 49 | $newHash = password_hash($password, PASSWORD_DEFAULT); 50 | 51 | $putUser = DatabaseConnection::getInstance()->prepare("INSERT INTO users(username, hash) VALUES(?, ?)"); 52 | 53 | $putUser->execute([$username, $newHash]); 54 | } 55 | session_start(); 56 | $_SESSION['loggedin'] = true; 57 | echo("success"); 58 | exit; 59 | } 60 | authFail(); 61 | function authFail(){ 62 | global $start; 63 | echo("Authentication failure!"); 64 | $took = microtime(true) - $start; 65 | $wait = 0.1-$took; 66 | usleep($wait*1000000); 67 | exit; 68 | } -------------------------------------------------------------------------------- /src/Autoloader.php: -------------------------------------------------------------------------------- 1 | getTrace(); 27 | } 28 | } 29 | $messages = []; 30 | $j = 0; 31 | for($i = (int) $start; isset($trace[$i]); ++$i, ++$j){ 32 | $params = ""; 33 | if(isset($trace[$i]["args"]) or isset($trace[$i]["params"])){ 34 | if(isset($trace[$i]["args"])){ 35 | $args = $trace[$i]["args"]; 36 | }else{ 37 | $args = $trace[$i]["params"]; 38 | } 39 | foreach($args as $name => $value){ 40 | $params .= (is_object($value) ? get_class($value) . " " . (method_exists($value, "__toString") ? $value->__toString() : "object") : gettype($value) . " " . (is_array($value) ? "Array()" : ErrorUtils::printable(@strval($value)))) . ", "; 41 | } 42 | } 43 | $messages[] = "#$j " . (isset($trace[$i]["file"]) ? $trace[$i]["file"] : "") . "(" . (isset($trace[$i]["line"]) ? $trace[$i]["line"] : "") . "): " . (isset($trace[$i]["class"]) ? $trace[$i]["class"] . (($trace[$i]["type"] === "dynamic" or $trace[$i]["type"] === "->") ? "->" : "::") : "") . $trace[$i]["function"] . "(" . ErrorUtils::printable(substr($params, 0, -2)) . ")"; 44 | } 45 | return $messages; 46 | } 47 | 48 | public static function getErrorString(\Throwable $e, $trace = null){ 49 | if($trace === null){ 50 | $trace = $e->getTrace(); 51 | } 52 | $errstr = $e->getMessage(); 53 | $errfile = $e->getFile(); 54 | $errno = $e->getCode(); 55 | $errline = $e->getLine(); 56 | $errorConversion = [ 57 | 0 => "EXCEPTION", 58 | E_ERROR => "E_ERROR", 59 | E_WARNING => "E_WARNING", 60 | E_PARSE => "E_PARSE", 61 | E_NOTICE => "E_NOTICE", 62 | E_CORE_ERROR => "E_CORE_ERROR", 63 | E_CORE_WARNING => "E_CORE_WARNING", 64 | E_COMPILE_ERROR => "E_COMPILE_ERROR", 65 | E_COMPILE_WARNING => "E_COMPILE_WARNING", 66 | E_USER_ERROR => "E_USER_ERROR", 67 | E_USER_WARNING => "E_USER_WARNING", 68 | E_USER_NOTICE => "E_USER_NOTICE", 69 | E_STRICT => "E_STRICT", 70 | E_RECOVERABLE_ERROR => "E_RECOVERABLE_ERROR", 71 | E_DEPRECATED => "E_DEPRECATED", 72 | E_USER_DEPRECATED => "E_USER_DEPRECATED", 73 | ]; 74 | $errno = $errorConversion[$errno] ?? $errno; 75 | $errstr = preg_replace('/\s+/', ' ', trim($errstr)); 76 | return get_class($e) . ": \"$errstr\" ($errno) in \"$errfile\" at line $errline"; 77 | } 78 | 79 | public static function logException(\Throwable $t, $trace = null){ 80 | if($trace === null){ 81 | $trace = $t->getTrace(); 82 | } 83 | Logger::critical(self::getErrorString($t, $trace)); 84 | foreach(self::getTrace(0, $trace) as $i => $line){ 85 | Logger::debug($line); 86 | } 87 | } 88 | } -------------------------------------------------------------------------------- /src/utils/Logger.php: -------------------------------------------------------------------------------- 1 | format("H:i:s.u "); 64 | if($msg[0] === "\r"){ 65 | $p = "\r"; 66 | $msg[0] = ""; 67 | }else{ 68 | $p = ""; 69 | } 70 | if($lBbefore){ 71 | $p .= "\n"; 72 | } 73 | $p .= "\e[97m"; 74 | $p .= $currTime; 75 | $p .= "\e[0m"; 76 | $r = "\e[0m"; 77 | if($lBafter){ 78 | $r .= "\n"; 79 | } 80 | switch($logLvl){ 81 | case self::LOG_LVL_INFO: $lvl = "\e[96m[INFO] "; break; 82 | case self::LOG_LVL_NOTICE: $lvl = "\e[93m[NOTICE] "; break; 83 | case self::LOG_LVL_WARNING: $lvl = "\e[91m[WARNING] "; break; 84 | case self::LOG_LVL_CRITICAL: $lvl = "\e[31m[CRITICAL] "; break; 85 | case self::LOG_LVL_EMERGENCY: $lvl = "\e[41m\e[39m[EMERGENCY] "; break; 86 | case self::LOG_LVL_DEBUG: $lvl = "[DEBUG] "; break; 87 | } 88 | if(self::$outputEnabled){ 89 | $msg = self::filterString($msg); 90 | echo($p.$lvl.$msg.$r); 91 | } 92 | if($exportDebug){ 93 | self::debug($p.$lvl.$msg.$r, self::DEBUG_TYPE_IMPORTED); 94 | } 95 | flush(); 96 | } 97 | 98 | public static function notice(string $msg){ 99 | self::log($msg, self::LOG_LVL_NOTICE); 100 | } 101 | 102 | public static function warning(string $msg){ 103 | self::log($msg, self::LOG_LVL_WARNING); 104 | } 105 | public static function critical(string $msg){ 106 | self::log($msg, self::LOG_LVL_CRITICAL); 107 | } 108 | public static function emergency(string $msg){ 109 | self::log($msg, self::LOG_LVL_EMERGENCY); 110 | } 111 | 112 | public static function debug(string $msg, int $debugType = self::DEBUG_TYPE_NORMAL){ 113 | if($debugType !== self::DEBUG_TYPE_IMPORTED){ 114 | if(self::$debugEnabled){ 115 | self::log($msg, self::LOG_LVL_DEBUG); 116 | } 117 | return; 118 | } 119 | if(self::$debugFileEnabled){ 120 | $msg = self::filterString($msg); 121 | fwrite(self::$debugFile, $msg); 122 | } 123 | } 124 | 125 | public static function var_dump($data, ?string $name = null, bool $return = false): string{ 126 | ob_start(); 127 | var_dump($data); 128 | $str = ob_get_clean(); 129 | if(self::$debugEnabled && !$return){ 130 | if($name !== null){ 131 | self::debug($name.":"); 132 | } 133 | echo(self::filterString($str)); 134 | self::debug($str, self::DEBUG_TYPE_IMPORTED); 135 | } 136 | return $str; 137 | } 138 | } -------------------------------------------------------------------------------- /src/utils/QueryCreationHelper.php: -------------------------------------------------------------------------------- 1 | config = $config; 23 | @mkdir(BASE_DIR."data/"); 24 | if(!file_exists(BASE_DIR."data/carPic.png")){ 25 | Logger::log("Fetching carPicture (this will take a while...)"); 26 | try{ 27 | $this->fetchCarPicture(); 28 | }catch(RuntimeException $e){ 29 | ErrorUtils::logException($e); 30 | Logger::warning("Failed to automatically fetch the carPicture! You can substitute data/carPic.png manually."); 31 | $im = imagecreate(100, 100); 32 | imagecolorallocate($im, 255, 0, 0); 33 | imagepng($im, self::PICTURE_LOCATION, 9, PNG_NO_FILTER); 34 | } 35 | } 36 | $this->db = $db; 37 | $this->writeCarPictureToDB(); 38 | } 39 | 40 | public function fetchCarPicture(){ 41 | $websiteAPI = new WebsiteAPI(new LoginInformation($this->config["username"], $this->config["password"])); 42 | 43 | $cars = $websiteAPI->apiGetAP("https://myvwde.cloud.wholesaleservices.de/api/tbo/cars"); 44 | $vin = $cars[0]["vin"]; 45 | if(!empty($this->config["vin"])){ 46 | foreach($cars as $car){ 47 | if($car["vin"] === $this->config["vin"]){ 48 | $vin = $car["vin"]; 49 | } 50 | } 51 | if($vin !== $this->config["vin"]){ 52 | Logger::var_dump($cars, "cars"); 53 | Logger::warning( 54 | "Could not find the vehicle with the specified vin ('".$this->config["vin"] ."')!". 55 | "Will fetch image for default car, please check config and try again by deleting data/carPic.png!" 56 | ); 57 | } 58 | } 59 | $images = $websiteAPI->apiGetAP( 60 | "https://myvw-vilma-proxy-prod.apps.mega.cariad.cloud/vehicleimages/exterior/".$vin 61 | )["images"]; 62 | foreach($images as $image){ 63 | if( 64 | strtolower($image["viewDirection"]) == ($this->config["viewDirection"] ?? "front") && 65 | strtolower($image["angle"]) == ($this->config["angle"] ?? "right") 66 | ){ 67 | $imageUrl = $image["url"]; 68 | } 69 | } 70 | if(!isset($imageUrl)){ 71 | Logger::var_dump($images); 72 | throw new RuntimeException( 73 | "Unable to fetch a car picture: Could not find uri for vin: ". 74 | $vin.", viewDirection: ".$this->config["viewDirection"].", angle: ".$this->config["angle"] 75 | ); 76 | } 77 | file_put_contents(self::PICTURE_LOCATION, file_get_contents($imageUrl)); 78 | 79 | $im = imagecreatefrompng(self::PICTURE_LOCATION); 80 | if($im === false){ 81 | throw new RuntimeException("Failed to fetch car picture: Failed to download picture. (Check for up to date CA definitions!)"); 82 | } 83 | imagealphablending($im, false); 84 | imagesavealpha($im, true); 85 | 86 | $cropped = imagecropauto($im, IMG_CROP_SIDES); 87 | imagedestroy($im); 88 | if($cropped === false){ 89 | return; 90 | } 91 | imagealphablending($cropped, false); 92 | imagesavealpha($cropped, true); 93 | if($this->config["carpic"]["flip"] == true){ 94 | imageflip($cropped, IMG_FLIP_HORIZONTAL); 95 | } 96 | imagepng($cropped, self::PICTURE_LOCATION, 9, PNG_NO_FILTER); 97 | imagedestroy($cropped); 98 | Logger::log("Successfully cropped and saved picture"); 99 | } 100 | 101 | public function writeCarPictureToDB(){ 102 | $this->db->query( 103 | "INSERT INTO carPictures(pictureID, carPicture) VALUES('default', '".base64_encode(file_get_contents(self::PICTURE_LOCATION))."') ". 104 | QueryCreationHelper::createUpsert($this->db->getDriver(), "pictureID", ["carPicture"]) 105 | ); 106 | } 107 | } -------------------------------------------------------------------------------- /src/vwid/CarStatusFetcher.php: -------------------------------------------------------------------------------- 1 | [ 32 | "batteryStatus" => [ 33 | "currentSOC_pct" => "batterySOC", 34 | "cruisingRangeElectric_km" => "remainingRange" 35 | ], 36 | "chargingStatus" => [ 37 | "remainingChargingTimeToComplete_min" => "remainingChargingTime", 38 | "chargingState" => "chargeState", 39 | "chargePower_kW" => "chargePower", 40 | "chargeRate_kmph" => "chargeRateKMPH" 41 | ], 42 | "chargingSettings" => [ 43 | "maxChargeCurrentAC" => null, 44 | "autoUnlockPlugWhenCharged" => null, 45 | "targetSOC_pct" => "targetSOC" 46 | ], 47 | "plugStatus" => [ 48 | "plugConnectionState" => "plugConnectionState", 49 | "plugLockState" => "plugLockState" 50 | ] 51 | ], 52 | "climatisation" => [ 53 | "climatisationStatus" => [ 54 | "remainingClimatisationTime_min" => "remainClimatisationTime", 55 | "climatisationState" => "hvacState" 56 | ], 57 | "climatisationSettings" => [ 58 | "targetTemperature_C" => "hvacTargetTemp", 59 | "climatisationWithoutExternalPower" => "hvacWithoutExternalPower", 60 | "climatizationAtUnlock" => "hvacAtUnlock", 61 | "windowHeatingEnabled" => null, 62 | "zoneFrontLeftEnabled" => null, 63 | "zoneFrontRightEnabled" => null, 64 | "zoneRearLeftEnabled" => null, 65 | "zoneRearRightEnabled" => null 66 | ], 67 | "windowHeatingStatus" => [ 68 | "windowHeatingStatus" => self::WINDOW_HEATING_STATUS_DYN 69 | ] 70 | ], 71 | "measurements" => [ 72 | "odometerStatus" => [ 73 | "odometer" => null 74 | ] 75 | ] 76 | ]; 77 | 78 | const JOBS = [ 79 | "access", 80 | "activeVentilation", 81 | "auxiliaryHeating", 82 | "batteryChargingCare", 83 | "batterySupport", 84 | "charging", 85 | "chargingProfiles", 86 | "climatisation", 87 | "climatisationTimers", 88 | "departureProfiles", 89 | "fuelStatus", 90 | "honkAndFlash", 91 | "hybridCarAuxiliaryHeating", 92 | "userCapabilities", 93 | "vehicleHealthWarnings", 94 | "vehicleHealthInspection", 95 | "vehicleLights", 96 | "measurements", 97 | "departureTimers", 98 | "lvBattery", 99 | "readiness" 100 | ]; 101 | 102 | public function __construct(array $config){ 103 | $this->config = $config; 104 | 105 | Logger::log("Logging in..."); 106 | $loginInformation = new LoginInformation($this->config["username"], $this->config["password"]); 107 | $this->idAPI = new MobileAppAPI($loginInformation); 108 | $this->login(); 109 | } 110 | 111 | public function registerUpdateReceiver(CarStatusUpdateReceiver $updateReceiver){ 112 | $this->updateReceivers[] = $updateReceiver; 113 | } 114 | 115 | public function tick(int $tickCnter){ 116 | if($tickCnter % $this->currentUpdateRate == 0){ 117 | if(!$this->fetchCarStatus()){ 118 | return; 119 | } 120 | //increase update rate while charging or hvac active or when last update was less than 6 minutes ago 121 | $timestamp = CarStatusWriter::getCarStatusTimestamp($this->carStatusData); //TODO: Refactor? 122 | if( 123 | ( 124 | $this->carStatusData["chargeState"] == "notReadyForCharging" || 125 | $this->carStatusData["chargeState"] == "readyForCharging" //pre 3.0 126 | ) && 127 | $this->carStatusData["hvacState"] == "off" && 128 | (time() - $timestamp?->getTimestamp()) > 60 * 6 129 | ){ 130 | $this->currentUpdateRate = $this->config["base-updaterate"] ?? 60 * 10; 131 | }else{ 132 | $this->currentUpdateRate = $this->config["increased-updaterate"] ?? 60; 133 | } 134 | foreach($this->updateReceivers as $updateReceiver){ 135 | $updateReceiver->carStatusUpdate($this->carStatusData); 136 | } 137 | } 138 | } 139 | 140 | private function login(){ 141 | $this->idAPI->login(); 142 | 143 | $vehicles = $this->idAPI->apiGet("vehicles")["data"]; 144 | 145 | $vehicleToUse = $vehicles[0]; 146 | if(!empty($this->config["vin"])){ 147 | foreach($vehicles as $vehicle){ 148 | if($vehicle["vin"] === $this->config["vin"]){ 149 | $vehicleToUse = $vehicle; 150 | } 151 | } 152 | if($vehicleToUse["vin"] !== $this->config["vin"]){ 153 | Logger::var_dump($vehicles, "vehicles"); 154 | Logger::warning( 155 | "Could not find the vehicle with the specified vin ('".$this->config["vin"] 156 | ."')! If fetching fails, please double check your vin!" 157 | ); 158 | } 159 | } 160 | $this->vin = $vehicleToUse["vin"]; 161 | //$name = $vehicleToUse["nickname"]; 162 | } 163 | 164 | /** 165 | * Fetches the new car status from the vehicles/vin/status endpoint 166 | * 167 | * @return bool Whether the fetching was successful. 168 | */ 169 | private function fetchCarStatus(): bool{ 170 | Logger::log("Fetching car status..."); 171 | try{ 172 | $data = $this->idAPI->apiGet("vehicles/".$this->vin."/selectivestatus?jobs=".implode(",", self::JOBS)); 173 | }catch(IDAuthorizationException $exception){ 174 | Logger::notice("IDAuthorizationException: ".$exception->getMessage()); 175 | Logger::notice("Refreshing tokens..."); 176 | if(!$this->idAPI->refreshToken()){ 177 | Logger::notice("Failed to refresh token, trying to re-login"); 178 | $this->login(); 179 | }else{ 180 | Logger::log("Successfully refreshed token"); 181 | } 182 | $this->currentUpdateRate = 1; //trigger update on next tick 183 | return false; 184 | }catch(IDAPIException $idAPIException){ 185 | Logger::critical("IDAPIException while trying to fetch car status"); 186 | ErrorUtils::logException($idAPIException); 187 | return false; 188 | }catch(CurlError $curlError){ 189 | Logger::critical("CurlError while trying to fetch car status"); 190 | ErrorUtils::logException($curlError); 191 | return false; 192 | } 193 | 194 | if(($error = $data["error"] ?? null) !== null || ($error = $data["userCapabilities"]["capabilitiesStatus"]["error"] ?? null) !== null){ 195 | Logger::var_dump($data, "decoded Data"); 196 | Logger::warning("VW API reported error while fetching car status: ".print_r($error, true)); 197 | Logger::notice("Ignoring these errors and continuing to attempt to decode data..."); 198 | } 199 | 200 | $carStatusData = []; 201 | 202 | if(API::$VERBOSE){ 203 | Logger::var_dump($data); 204 | } 205 | $this->readValues($data, self::DATA_MAPPING, $carStatusData); 206 | $this->carStatusData = $carStatusData; 207 | #var_dump($carStatusData); 208 | return true; 209 | } 210 | 211 | private function readValues(array $data, array $dataMap, array &$resultData, ?string $lastLevelName = null){ 212 | foreach($data as $key => $content){ 213 | if(array_key_exists($key, $dataMap)){ 214 | if(is_array($dataMap[$key])){ 215 | $this->readValues($content, $dataMap[$key], $resultData, $key); 216 | }else{ 217 | if(!is_int($dataMap[$key])){ 218 | $resultData[$dataMap[$key] ?? $key] = $content; 219 | }else{ 220 | switch($dataMap[$key]){ 221 | case self::WINDOW_HEATING_STATUS_DYN: 222 | foreach ($content as $window){ 223 | $resultData[$window["windowLocation"] . "WindowHeatingState"] = $window["windowHeatingState"]; 224 | } 225 | break; 226 | default: 227 | Logger::notice("Unable to read content at ".$key.": ".print_r($content, true)); 228 | } 229 | } 230 | } 231 | }elseif($key == "value"){ //filter "value" key 232 | $this->readValues($content, $dataMap, $resultData, $lastLevelName); //skip over value key level 233 | }elseif($lastLevelName !== "" && $key == "carCapturedTimestamp"){ 234 | $resultData[$lastLevelName."Timestamp"] = new DateTime($content); 235 | }else{ 236 | Logger::debug("Ignored content at ".$key/*.": ".print_r($content, true)*/); 237 | } 238 | } 239 | } 240 | 241 | public function getCarStatusData(): array{ 242 | return $this->carStatusData; 243 | } 244 | } -------------------------------------------------------------------------------- /src/vwid/CarStatusUpdateReceiver.php: -------------------------------------------------------------------------------- 1 | db = $db; 53 | $this->initQuery(); 54 | } 55 | 56 | private function initQuery(){ 57 | $query = "INSERT INTO carStatus(time"; 58 | foreach(self::DB_FIELDS as $dbField){ 59 | $query .= ", ".$dbField; 60 | } 61 | $query .= ") VALUES("; 62 | for($i = 1; $i <= count(self::DB_FIELDS); ++$i){ 63 | $query .= "?, "; 64 | } 65 | $query .= "?) "; 66 | $query .= QueryCreationHelper::createUpsert($this->db->getDriver(), "time", self::DB_FIELDS); 67 | Logger::debug("Preparing query ".$query."..."); 68 | $this->carStatusWrite = $this->db->prepare($query); 69 | } 70 | 71 | public function registerUpdateReceiver(CarStatusWrittenUpdateReceiver $updateReceiver){ 72 | $this->updateReceivers[] = $updateReceiver; 73 | } 74 | 75 | public function carStatusUpdate(array $carStatusData){ 76 | $this->writeCarStatus($carStatusData); 77 | } 78 | 79 | public static function getCarStatusTimestamp(array $carStatusData): ?DateTime{ 80 | $dateTime = null; 81 | foreach(array_merge(...array_values(CarStatusFetcher::DATA_MAPPING)) as $key => $val){ 82 | if(!isset($carStatusData[$key."Timestamp"])){ 83 | Logger::notice("Could not find expected key '".$key."Timestamp' in carStatusData. Unexpected changes in older or newer car software can cause this!"); 84 | continue; 85 | } 86 | if($dateTime === null){ 87 | $dateTime = $carStatusData[$key."Timestamp"]; 88 | }else{ 89 | if($carStatusData[$key."Timestamp"]->getTimestamp() > $dateTime->getTimestamp()){ 90 | $dateTime = $carStatusData[$key."Timestamp"]; 91 | } 92 | } 93 | } 94 | return $dateTime; 95 | } 96 | 97 | /** 98 | * @param array $carStatusData The carStatusData to write to the db 99 | */ 100 | public function writeCarStatus(array $carStatusData, bool $retry = true){ 101 | $data = []; 102 | $dateTime = self::getCarStatusTimestamp($carStatusData); 103 | if($dateTime == null){ 104 | Logger::var_dump($carStatusData, "carStatusData"); 105 | throw new RuntimeException("Data does not contain any timestamps, unable to write to db!"); 106 | } 107 | $data[] = $dateTime->format('Y\-m\-d\TH\:i\:s'); 108 | foreach(self::DB_FIELDS as $dbField){ 109 | if(!isset($carStatusData[$dbField])){ 110 | Logger::notice("Could not find expected key '".$dbField."' in carStatusData. Unexpected changes in older or newer car software can cause this!"); 111 | $data[] = null; 112 | continue; 113 | } 114 | if(is_bool($carStatusData[$dbField])){ 115 | $data[] = $carStatusData[$dbField] ? "1" : "0"; 116 | continue; 117 | } 118 | $data[] = $carStatusData[$dbField]; 119 | } 120 | if($data === $this->lastWrittenCarStatus){ 121 | return; 122 | } 123 | Logger::log("Writing new data for timestamp ".$data[0]); 124 | #var_dump($data); 125 | 126 | try{ 127 | $this->carStatusWrite->execute($data); 128 | }catch(PDOException $e){ 129 | if(!$retry){ 130 | throw $e; 131 | } 132 | ErrorUtils::logException($e); 133 | Logger::critical("Could not write to db, attempting reconnect..."); 134 | $this->db->connect(); 135 | $this->initQuery(); 136 | $this->writeCarStatus($carStatusData, false); 137 | } 138 | $written = []; 139 | $pos = 0; 140 | $written["time"] = $data[$pos++]; 141 | foreach(self::DB_FIELDS as $dbField){ 142 | $written[$dbField] = $data[$pos++]; 143 | } 144 | foreach($this->updateReceivers as $updateReceiver){ 145 | $updateReceiver->carStatusWrittenUpdate($written); 146 | } 147 | 148 | $this->lastWrittenCarStatus = $data; 149 | } 150 | } -------------------------------------------------------------------------------- /src/vwid/CarStatusWrittenUpdateReceiver.php: -------------------------------------------------------------------------------- 1 | config = json_decode(file_get_contents(BASE_DIR."config/config.json"), true, 512, JSON_THROW_ON_ERROR); 30 | }catch(\JsonException $exception){ 31 | ErrorUtils::logException($exception); 32 | Logger::warning("Unable to parse config.json! Most likely invalid format."); 33 | forceShutdown(); 34 | } 35 | Logger::addOutputFilter($this->config["password"]); 36 | if(!empty($this->config["integrations"]["abrp"]["user-token"])){ 37 | Logger::addOutputFilter($this->config["integrations"]["abrp"]["user-token"]); 38 | } 39 | 40 | Logger::log("Connecting to db..."); 41 | $this->db = new DatabaseConnection( 42 | $this->config["db"]["host"], $this->config["db"]["dbname"], 43 | $this->config["db"]["user"], $this->config["db"]["password"] ?? null, 44 | $this->config["db"]["driver"] ?? "pgsql" 45 | ); 46 | QueryCreationHelper::initDefaults($this->config["db"]["driver"] ?? "pgsql"); 47 | 48 | $didWizard = false; 49 | if(strtolower($this->db->query( 50 | "SELECT table_name FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'carstatus' OR TABLE_NAME = 'carStatus'" 51 | )[0]["table_name"] ?? "") !== "carstatus" 52 | ){ 53 | self::initializeTables($this->db); 54 | new DBmigrator($this->db, true); 55 | if(($_SERVER['argv'][1] ?? "") != "--no-wizard"){ 56 | new SetupWizard($this); 57 | $didWizard = true; 58 | } 59 | }else{ 60 | new DBmigrator($this->db); 61 | } 62 | if(!$didWizard && ($_SERVER['argv'][1] ?? "") === "--wizard"){ 63 | new SetupWizard($this); 64 | } 65 | 66 | API::$VERBOSE = $this->config["logging"]["curl-verbose"] ?? false; 67 | 68 | new CarPictureHandler($this->db, $this->config); 69 | 70 | $this->chargeSessionHandler = new ChargeSessionHandler($this->db); 71 | $this->carStatusFetcher = new CarStatusFetcher($this->config); 72 | $carStatusWriter = new CarStatusWriter($this->db); 73 | $carStatusWriter->registerUpdateReceiver($this->chargeSessionHandler); 74 | $this->carStatusFetcher->registerUpdateReceiver($carStatusWriter); 75 | 76 | if(!empty($this->config["integrations"]["abrp"]["user-token"])){ 77 | $abrpIntegration = new ABRP( 78 | $this->config["integrations"]["abrp"]["user-token"], 79 | $this->config["integrations"]["abrp"]["api-key"] ?? null, 80 | $this->chargeSessionHandler 81 | ); 82 | $carStatusWriter->registerUpdateReceiver($abrpIntegration); 83 | } 84 | } 85 | 86 | public function getDB(): DatabaseConnection{ 87 | return $this->db; 88 | } 89 | 90 | public static function initializeTables(DatabaseConnection $db){ 91 | Logger::log("Initializing db tables..."); 92 | $sqlFilename = match($db->getDriver()){ 93 | 'mysql' => 'db_mysql.sql', 94 | 'pgsql' => 'db.sql' 95 | }; 96 | $db->getConnection()->exec(file_get_contents(BASE_DIR.$sqlFilename)); 97 | } 98 | 99 | public function tick(int $tickCnter){ 100 | if($this->firstTick === true){ 101 | Logger::log("Ready!"); 102 | $this->firstTick = false; 103 | } 104 | $this->carStatusFetcher->tick($tickCnter); 105 | } 106 | 107 | public function shutdown(){ 108 | } 109 | } -------------------------------------------------------------------------------- /src/vwid/Server.php: -------------------------------------------------------------------------------- 1 | 0){ 12 | echo("At least PHP ".MIN_PHP_VERSION." is required. Your installed version is ".PHP_VERSION."!"); 13 | exit(1); 14 | } 15 | if(php_sapi_name() !== "cli"){ 16 | echo("This script is to be run in cli!"); 17 | exit(1); 18 | } 19 | $extensions = ["curl", "json", "gd", "dom", "pdo", "pcntl", ["pdo_pgsql", "pdo_mysql"]]; 20 | 21 | $missingExtensions = ""; 22 | foreach($extensions as $ext){ 23 | if(is_array($ext)){ 24 | $ok = false; 25 | foreach($ext as $possibleExt){ 26 | $ok = $ok ? true : extension_loaded($possibleExt); 27 | } 28 | if(!$ok){ 29 | $missingExtensions .= "At least one of the following php extensions is required: ".implode(", ", $ext); 30 | } 31 | continue; 32 | } 33 | if(!extension_loaded($ext)){ 34 | $missingExtensions .= "The php extension ".$ext." is required!".PHP_EOL; 35 | } 36 | } 37 | if(!empty($missingExtensions)){ 38 | echo($missingExtensions); 39 | exit(1); 40 | } 41 | 42 | #ini_set('memory_limit','1024M'); 43 | 44 | const BASE_DIR = __DIR__."/../../"; 45 | 46 | require(BASE_DIR."src/Autoloader.php"); 47 | 48 | if(($_SERVER['argv'][1] ?? "") == "--configwizard"){ 49 | new \robske_110\vwid\wizard\ConfigWizard(); 50 | exit; 51 | } 52 | 53 | $config = json_decode(file_get_contents(BASE_DIR."config/config.json"), true); 54 | 55 | if(isset($config["timezone"])){ 56 | ini_set("date.timezone", $config["timezone"]); 57 | } 58 | 59 | if(!isset($config["debug"])){ 60 | $config["debug"] = []; 61 | } 62 | Logger::init( 63 | $config["logging"]["debug-enable"] ?? true, $config["logging"]["file-enable"] ?? true, 64 | ($config["logging"]["log-dir"] ?? BASE_DIR."/log/") 65 | ); 66 | 67 | const VERSION = "v0.0.6"; 68 | const IS_RELEASE = false; 69 | 70 | $hash = exec("git -C \"".BASE_DIR."\" rev-parse HEAD 2>/dev/null"); 71 | $exitCode = -1; 72 | exec("git -C \"".BASE_DIR."\" diff --quiet 2>/dev/null", $out, $exitCode); 73 | if($exitCode == 1){ 74 | $hash .= "-dirty"; 75 | } 76 | Logger::log("Starting ID DataLogger Version ".VERSION.(IS_RELEASE ? "" : "-InDev").(!empty($hash) ? " (".$hash.")" : "")."..."); 77 | 78 | function handleException(Throwable $t, $trace = null){ 79 | if($trace === null){ 80 | $trace = $t->getTrace(); 81 | } 82 | ErrorUtils::logException($t, $trace); 83 | global $main; 84 | if($main === null){ 85 | Logger::emergency("CRASHED WHILE STARTING; TRYING TO SHUTDOWN SAFELY"); 86 | } 87 | forceShutdown(); 88 | } 89 | 90 | set_exception_handler("handleException"); 91 | 92 | function handleSignal(int $signo){ 93 | if($signo === SIGTERM or $signo === SIGINT or $signo === SIGHUP){ 94 | shutdown(); 95 | } 96 | } 97 | 98 | $signals = [SIGTERM, SIGINT, SIGHUP]; 99 | foreach($signals as $signal){ 100 | pcntl_signal($signal, "handleSignal"); 101 | } 102 | 103 | $doExit = false; 104 | $main = new Main; 105 | $tickCnter = 0; 106 | Logger::log("Done. Startup took ".(microtime(true) - $startTime)."s"); 107 | $nextTick = 0; 108 | while(!$doExit){ 109 | if($nextTick > microtime(true)){ 110 | time_sleep_until($nextTick); 111 | } 112 | 113 | $nextTick = microtime(true) + 1; 114 | $main->tick($tickCnter); 115 | pcntl_signal_dispatch(); 116 | 117 | $tickCnter++; 118 | } 119 | forceShutdown(); 120 | 121 | function shutdown(){ 122 | Logger::debug("Requested SHUTDOWN"); 123 | global $doExit; 124 | $doExit = true; 125 | } 126 | 127 | function forceShutdown(){ 128 | Logger::log("Shutting down..."); 129 | global $main; 130 | if($main === null){ 131 | Logger::critical("Forcibly shutting down while starting!"); 132 | }else{ 133 | $main->shutdown(); 134 | } 135 | Logger::debug(">Closing Logger..."); 136 | Logger::close(); 137 | exit(); 138 | } -------------------------------------------------------------------------------- /src/vwid/api/API.php: -------------------------------------------------------------------------------- 1 | getCh(), CURLINFO_RESPONSE_CODE); 24 | if($httpCode === 401){ 25 | throw new IDAuthorizationException("Not authorized to execute API request '".$apiEndpoint."' (httpCode 401)"); 26 | } 27 | if($httpCode !== 200 && $httpCode !== 202 && $httpCode !== 207){ 28 | throw new IDAPIException("API request '".$apiEndpoint."' failed with httpCode ".$httpCode); 29 | } 30 | if(str_contains($response, "Unauthorized")){ 31 | Logger::debug("Got: ".$response); 32 | throw new IDAuthorizationException("Not authorized to execute API request '".$apiEndpoint."' (response contained Unauthorized)"); 33 | } 34 | try{ 35 | return json_decode($response, true, 512, JSON_THROW_ON_ERROR); 36 | }catch(\JsonException $jsonException){ 37 | Logger::critical("Error while json decoding API request '".$apiEndpoint."'"); 38 | ErrorUtils::logException($jsonException); 39 | throw new IDAPIException("Could not decode json for request '".$apiEndpoint."'"); 40 | } 41 | } 42 | 43 | const LOGIN_HANDLER_BASE = "https://identity.vwgroup.io"; 44 | 45 | public function emailLoginStep(string $loginPage, LoginInformation $loginInformation){ 46 | $dom = new DOMDocument(); 47 | $dom->strictErrorChecking = false; 48 | $dom->loadHTML($loginPage); 49 | 50 | $form = new Form($dom->getElementById("emailPasswordForm")); 51 | $fields = $form->getHiddenFields(); 52 | if(self::$VERBOSE) Logger::var_dump($fields, "emailPasswordForm"); 53 | $fields["email"] = $loginInformation->username; 54 | 55 | Logger::debug("Sending email..."); 56 | $pwdPage = $this->postRequest(self::LOGIN_HANDLER_BASE.$form->getAttribute("action"), $fields); 57 | 58 | $dom = new DOMDocument(); 59 | $dom->strictErrorChecking = false; 60 | $dom->loadHTML($pwdPage); 61 | 62 | if($dom->getElementById("emailPasswordForm") !== null){ 63 | Logger::var_dump($pwdPage, "pwdPage"); 64 | throw new IDLoginException("Unable to login. Check login information (e-mail)! (Still found emailPasswordForm)"); 65 | } 66 | $fields["password"] = $loginInformation->password; 67 | 68 | $errorString = 69 | "Unable to login. Most likely caused by an unexpected change on VW's side.". 70 | " Check login information. If issue persists, open an issue!"; 71 | $hmac = preg_match("/\"hmac\":\"([^\"]*)\"/", $pwdPage, $matches); 72 | if(!$hmac){ 73 | Logger::var_dump($pwdPage, "pwdPage"); 74 | throw new IDLoginException($errorString." (could not find hmac)"); 75 | } 76 | $fields["hmac"] = $matches[1]; 77 | 78 | //Note: this could also be parsed from postAction 79 | $action = preg_replace("/(?<=\/)[^\/]*$/", "authenticate", $form->getAttribute("action")); 80 | if($action === $form->getAttribute("action") || $action === null){ 81 | Logger::var_dump($pwdPage, "pwdPage"); 82 | throw new IDLoginException($errorString." (action did not match expected format)"); 83 | } 84 | 85 | return [$action, $fields]; 86 | } 87 | } -------------------------------------------------------------------------------- /src/vwid/api/LoginInformation.php: -------------------------------------------------------------------------------- 1 | username = $username; 12 | $this->password = $password; 13 | } 14 | } -------------------------------------------------------------------------------- /src/vwid/api/MobileAppAPI.php: -------------------------------------------------------------------------------- 1 | loginInformation = $loginInformation; 26 | } 27 | 28 | public function login(){ 29 | Logger::debug("Loading login Page..."); 30 | 31 | libxml_use_internal_errors(true); 32 | 33 | $loginPage = $this->getRequest(self::LOGIN_BASE."/authorize", [ 34 | "nonce" => base64_encode(random_bytes(12)), 35 | "redirect_uri" => "weconnect://authenticated" 36 | ]); 37 | 38 | [$action, $fields] = $this->emailLoginStep($loginPage, $this->loginInformation); 39 | 40 | Logger::debug("Sending password ..."); 41 | try{ 42 | $this->postRequest(self::LOGIN_HANDLER_BASE.$action, $fields); 43 | }catch(CurlError $curlError){ 44 | if($curlError->curlErrNo !== 1){ 45 | throw $curlError; 46 | } 47 | } 48 | 49 | if(empty($this->weConnectRedirFields)){ 50 | Logger::var_dump($resultPage ?? "not defined", "loggedInPage"); 51 | throw new IDLoginException("Unable to login. Check login information (password)! See FAQ if issue persists. (Could not find location header)"); 52 | } 53 | if(self::$VERBOSE) Logger::var_dump($this->weConnectRedirFields, "weConnectRedirFields"); 54 | Logger::debug("Getting real token..."); 55 | $this->appTokens = $this->apiPost("login/v1", json_encode([ 56 | "state" => $this->weConnectRedirFields["state"], 57 | "id_token" => $this->weConnectRedirFields["id_token"], 58 | "redirect_uri" => "weconnect://authenticated", 59 | "region" => "emea", 60 | "access_token" => $this->weConnectRedirFields["access_token"], 61 | "authorizationCode" => $this->weConnectRedirFields["code"] 62 | ]),self::LOGIN_BASE, [ 63 | "content-type: application/json" 64 | ]); 65 | } 66 | 67 | protected function onHeaderEntry(string $entryName, string $entryValue){ 68 | if(strtolower($entryName) == "location"){ //curl sometimes has headers in all lowercase 69 | if(str_starts_with($entryValue, "weconnect://")){ 70 | $args = explode("&", substr($entryValue, strlen("weconnect://authenticated#"))); 71 | foreach($args as $field){ 72 | $field = explode("=", $field); 73 | $this->weConnectRedirFields[$field[0]] = $field[1]; 74 | } 75 | } 76 | } 77 | } 78 | 79 | public function apiGet(string $apiEndpoint, array $fields = [], string $apiBase = self::API_BASE, ?array $header = null): array{ 80 | if($header === null){ 81 | $header = [ 82 | "Accept: application/json", 83 | "Authorization: Bearer ".$this->appTokens["accessToken"] 84 | ]; 85 | } 86 | $response = $this->getRequest($apiBase."/".$apiEndpoint, $fields, $header); 87 | return $this->verifyAndDecodeResponse($response, $apiEndpoint); 88 | } 89 | 90 | public function apiPost(string $apiEndpoint, $body, string $apiBase = self::API_BASE, ?array $header = null): array{ 91 | if($header === null){ 92 | $header = [ 93 | "content-type: application/json", 94 | "Authorization: Bearer ".$this->appTokens["accessToken"] 95 | ]; 96 | } 97 | $response = $this->postRequest($apiBase."/".$apiEndpoint, $body, $header); 98 | return $this->verifyAndDecodeResponse($response, $apiEndpoint); 99 | } 100 | 101 | /** 102 | * Tries to refresh the tokens 103 | * 104 | * @return bool Returns whether the token refresh was successful. If false is returned consider calling login() 105 | */ 106 | public function refreshToken(): bool{ 107 | try{ 108 | $this->appTokens = $this->apiGet("refresh/v1", [], self::LOGIN_BASE, [ 109 | "content-type: application/json", 110 | "Authorization: Bearer ".$this->appTokens["refreshToken"] 111 | ]); 112 | Logger::var_dump($this->appTokens, "new appTokens"); 113 | }catch(\RuntimeException $exception){ 114 | Logger::warning("Failed to refresh token"); 115 | ErrorUtils::logException($exception); 116 | return false; 117 | } 118 | return true; 119 | } 120 | 121 | public function getAppTokens(): array{ 122 | return $this->appTokens; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/vwid/api/WebsiteAPI.php: -------------------------------------------------------------------------------- 1 | getRequest(self::LOGIN_PAGE); 25 | 26 | [$action, $fields] = $this->emailLoginStep($loginPage, $loginInformation); 27 | 28 | Logger::debug("Sending password ..."); 29 | $this->postRequest(self::LOGIN_HANDLER_BASE.$action, $fields); 30 | 31 | if(empty($this->csrf)){ 32 | throw new IDLoginException("Failed to login"); 33 | } 34 | } 35 | 36 | public function getAPtoken(){ 37 | if(!isset($this->apToken)){ 38 | sleep(1); //If we just logged in VW servers might not have synced all login information correctly yet 39 | $this->apToken = $this->apiGetCSRF("https://www.volkswagen.de/app/authproxy/vw-de/tokens")["access_token"]; 40 | } 41 | return $this->apToken; 42 | } 43 | 44 | public function apiGetAP(string $uri, array $fields = [], ?array $header = null): array{ 45 | if($header === null){ 46 | $header = [ 47 | "Accept: application/json", 48 | "Authorization: Bearer ".$this->getAPtoken() 49 | ]; 50 | } 51 | $response = $this->getRequest($uri, $fields, $header); 52 | return $this->verifyAndDecodeResponse($response, $uri); 53 | } 54 | 55 | public function apiGetCSRF(string $uri, array $fields = [], ?array $header = null): array{ 56 | if($header === null){ 57 | $header = [ 58 | "Accept: application/json", 59 | "X-csrf-token: ".$this->csrf 60 | ]; 61 | } 62 | $response = $this->getRequest($uri, $fields, $header); 63 | return $this->verifyAndDecodeResponse($response, $uri); 64 | } 65 | 66 | protected function onCookie(string $name, string $content){ 67 | if($name == "csrf_token"){ 68 | $this->csrf = $content; 69 | } 70 | } 71 | 72 | public function getCsrf(): string{ 73 | return $this->csrf; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/vwid/api/exception/IDAPIException.php: -------------------------------------------------------------------------------- 1 | chargeEndTime = $endTime; 28 | $this->chargeDuration = $this->chargeEndTime->getTimestamp() - $this->chargeStartTime->getTimestamp(); 29 | } 30 | 31 | private int $entryCount = 0; 32 | private float $chargePowerAccum = 0; 33 | private float $lastChargePower; 34 | private int $lastTime; 35 | private string $lastChargeState = ""; 36 | 37 | /** 38 | * @param array $entry 39 | * 40 | * @return bool Whether this charge session is finished 41 | */ 42 | public function processEntry(array $entry): bool{ 43 | if(!isset($this->startTime)){ 44 | $this->startTime = new DateTime($entry["time"]); 45 | } 46 | 47 | if(!isset($this->rangeStart)){ 48 | $this->rangeStart = (int) $entry["remainingrange"]; 49 | } 50 | if(!isset($this->socStart)){ 51 | $this->socStart = (int) $entry["batterysoc"]; 52 | } 53 | 54 | $this->rangeEnd = (int) $entry["remainingrange"]; 55 | $this->socEnd = (int) $entry["batterysoc"]; 56 | $this->targetSOC = (int) $entry["targetsoc"]; 57 | 58 | if($entry["plugconnectionstate"] == "disconnected"){ 59 | Logger::log("Unplugged car at ".$entry["time"]); 60 | $this->endTime = new DateTime($entry["time"]); 61 | } 62 | if($this->endTime !== null){ 63 | return true; 64 | } 65 | 66 | if($this->chargeStartTime === null){ 67 | if($entry["chargestate"] == "charging"){ 68 | Logger::log("Started charging at ".$entry["time"]); 69 | $this->chargeStartTime = new DateTime($entry["time"]); 70 | }else{ 71 | return false; 72 | } 73 | } 74 | if( 75 | $entry["chargestate"] == "notReadyForCharging" && $this->lastChargeState != "notReadyForCharging" || 76 | $entry["chargestate"] == "readyForCharging" && $this->lastChargeState != "readyForCharging" //pre 3.0 77 | ){ 78 | Logger::log("Ended charging at ".$entry["time"]); 79 | Logger::debug("lCS".$this->lastChargeState." cs:".$entry["chargestate"]); 80 | $this->setChargeEndTime(new DateTime($entry["time"])); 81 | } 82 | 83 | ++$this->entryCount; 84 | $currTime = (new DateTime($entry["time"]))->getTimestamp(); 85 | 86 | $this->chargeDuration = $currTime - $this->chargeStartTime->getTimestamp(); 87 | 88 | if(isset($this->lastTime)){ 89 | $this->integralChargeEnergy += ($currTime - $this->lastTime) * $this->lastChargePower; 90 | } 91 | $this->lastTime = $currTime; 92 | 93 | $this->lastChargePower = (float) $entry["chargepower"]; 94 | if($entry["chargepower"] == 0){ 95 | Logger::debug("Charging at ".$entry["time"]." with 0kW!"); 96 | return false; 97 | } 98 | $this->maxChargePower = max($this->maxChargePower ?? 0, (float) $entry["chargepower"]); 99 | $this->minChargePower = min($this->minChargePower ?? PHP_INT_MAX, (float) $entry["chargepower"]); 100 | $this->chargePowerAccum += $entry["chargepower"]; 101 | $this->chargeKMRaccum += $entry["chargeratekmph"]; 102 | $this->maxChargeKMR = max($this->maxChargeKMR, (float) $entry["chargeratekmph"]); 103 | $this->minChargeKMR = min($this->minChargeKMR, (float) $entry["chargepower"]); 104 | 105 | $this->avgChargePower = $this->chargePowerAccum / $this->entryCount; 106 | $this->avgChargeKMR = $this->chargeKMRaccum / $this->entryCount; 107 | return false; 108 | } 109 | 110 | private float $chargeKMRaccum = 0; 111 | public float $avgChargeKMR; 112 | public float $maxChargeKMR = 0; 113 | public float $minChargeKMR = PHP_INT_MAX; 114 | 115 | public function niceOut(){ 116 | Logger::log("Charge session: ".PHP_EOL. 117 | "range: start: ".$this->rangeStart."km end: ".$this->rangeEnd."km".PHP_EOL. 118 | "duration: ".round($this->chargeDuration/3600, 1)."h".PHP_EOL. 119 | "SOC: start: ".$this->socStart."% end: ".$this->socEnd."% target: ".$this->targetSOC."%".PHP_EOL. 120 | "chargeEnergy:".round($this->integralChargeEnergy / 3600, 2)."kWh ". 121 | "cE_soc_calc".round((58*($this->socEnd-$this->socStart))/100, 2)."kWh".PHP_EOL. 122 | ( 123 | $this->avgChargePower === null ? "NO chargePower" : 124 | "POWER: avg: ".round($this->avgChargePower, 1)."kW min: ".$this->minChargePower. 125 | "kW max: ".$this->maxChargePower."kW" 126 | ) 127 | ); 128 | if(!isset($this->avgChargeKMR)){ 129 | return; 130 | } 131 | Logger::debug( 132 | "chgKMR: min".round($this->minChargeKMR, 1)." max".round($this->maxChargeKMR, 1)." avg". round($this->avgChargeKMR, 1).PHP_EOL. 133 | ( 134 | $this->avgChargeKMR != 0 && $this->avgChargePower !== null ? 135 | "avgChgKMR/avgChgPower: ".round($this->avgChargePower/$this->avgChargeKMR, 2).PHP_EOL : "" 136 | ). 137 | "range/soc: start".round($this->rangeStart/max($this->socStart, 1), 1). 138 | " end".round($this->rangeEnd/max($this->socEnd, 1), 1) 139 | ); 140 | } 141 | } -------------------------------------------------------------------------------- /src/vwid/chargesession/ChargeSessionHandler.php: -------------------------------------------------------------------------------- 1 | db = $db; 38 | 39 | $query = QueryCreationHelper::createInsert("chargingSessions", self::DB_FIELDS); 40 | Logger::debug("Preparing query ".$query."..."); 41 | $this->chargeSessionWrite = $db->prepare($query); 42 | 43 | $res = $db->query("SELECT starttime, endtime FROM chargingSessions WHERE endtime IS NOT NULL ORDER BY startTime DESC LIMIT 1"); 44 | 45 | $db->query("DELETE FROM chargingSessions WHERE endTime IS NULL"); 46 | 47 | Logger::log("Building past charge sessions from ".($res[0]["endtime"] ?? "beginning of data logging")."..."); 48 | $this->buildAll(new DateTime($res[0]["endtime"] ?? "@0", new DateTimeZone("UTC"))); 49 | } 50 | 51 | public function carStatusWrittenUpdate(array $carStatusData){ 52 | $this->processCarStatus($carStatusData); 53 | } 54 | 55 | public function processCarStatus(array $carStatus, bool $alwaysWrite = true){ 56 | foreach($carStatus as $key => $value){ 57 | if(strtolower($key) !== $key){ 58 | $carStatus[strtolower($key)] = $value; 59 | unset($carStatus[$key]); 60 | } 61 | } 62 | 63 | if($this->chargeSession === null && $carStatus["plugconnectionstate"] == "connected"){ 64 | Logger::log("Plugged car in at ".$carStatus["time"]); 65 | $this->chargeSession = new ChargeSession(); 66 | } 67 | Logger::debug($carStatus["time"].": ".$carStatus["chargestate"]); 68 | if($this->chargeSession !== null){ 69 | if($this->chargeSession->processEntry($carStatus)){ 70 | $this->chargeSession->niceOut(); 71 | $this->writeChargeSession(); 72 | $this->chargeSession = null; 73 | }elseif($alwaysWrite){ 74 | $this->writeChargeSession(); 75 | } 76 | } 77 | } 78 | 79 | public function buildAll(?DateTime $from = null){ 80 | $from = " WHERE time > '".$from->format("Y-m-d\TH:i:s")."'"; 81 | $res = $this->db->query("SELECT time, batterysoc, remainingrange, chargestate, chargepower, chargeratekmph, targetsoc, plugconnectionstate FROM carStatus ".$from." ORDER BY time ASC"); 82 | 83 | foreach($res as $entry){ 84 | $this->processCarStatus($entry, false); 85 | } 86 | 87 | if($this->chargeSession !== null){ 88 | Logger::debug("Continuing charging session!"); 89 | } 90 | } 91 | 92 | /** 93 | * Returns a ChargeSession if one is currently active (or being processed at startup) 94 | */ 95 | public function getCurrentChargingSession(): ?ChargeSession{ 96 | return $this->chargeSession; 97 | } 98 | 99 | private function writeChargeSession(){ 100 | $this->chargeSessionWrite->execute([ 101 | $this->chargeSession->startTime->format('Y\-m\-d\TH\:i\:s'), 102 | $this->chargeSession->endTime?->format('Y\-m\-d\TH\:i\:s'), 103 | $this->chargeSession->chargeStartTime?->format('Y\-m\-d\TH\:i\:s'), 104 | $this->chargeSession->chargeEndTime?->format('Y\-m\-d\TH\:i\:s'), 105 | $this->chargeSession->chargeDuration, 106 | $this->chargeSession->avgChargePower, 107 | $this->chargeSession->maxChargePower, 108 | $this->chargeSession->minChargePower, 109 | $this->chargeSession->integralChargeEnergy, 110 | $this->chargeSession->rangeStart, 111 | $this->chargeSession->rangeEnd, 112 | $this->chargeSession->targetSOC, 113 | $this->chargeSession->socStart, 114 | $this->chargeSession->socEnd 115 | ]); 116 | } 117 | } -------------------------------------------------------------------------------- /src/vwid/db/DBmigrator.php: -------------------------------------------------------------------------------- 1 | db = $db; 17 | if(!$initialStart){ 18 | $this->doAutoMigration(); 19 | }else{ 20 | $this->setDBversion("1.1"); 21 | Logger::log("Successfully initialized database with schema version V".$this->dbSchemaVersion); 22 | } 23 | } 24 | 25 | public function doAutoMigration(){ 26 | try{ 27 | $this->dbSchemaVersion = $this->db->query( 28 | "SELECT settingvalue FROM settings WHERE settingKey = 'dbschemaversion'" 29 | )[0]["settingvalue"] ?? null; 30 | }catch(RuntimeException){ 31 | Logger::notice("Could not find settings table, upgrading schema to V1"); 32 | Main::initializeTables($this->db); 33 | $this->setDBversion("1"); 34 | } 35 | $startupDbSchemaVersion = $this->dbSchemaVersion; 36 | // Old versions of IDDataLogger (up to 4e44ce8) wrote the V1 DB schema, but didn't write a dbschemaversion. 37 | if($this->dbSchemaVersion === null){ 38 | Logger::warning( 39 | "Could not read dbSchemaVersion, but settings table is present. Ignoring and setting to V1." 40 | ); 41 | $this->setDBversion("1"); 42 | } 43 | if(version_compare($this->dbSchemaVersion, "1.1") < 0){ 44 | Logger::notice("Upgrading schema to V1.1 (Adding odometer column)"); 45 | $this->db->query("ALTER TABLE carStatus ADD COLUMN odometer integer"); 46 | $this->setDBversion("1.1"); 47 | } 48 | if($startupDbSchemaVersion !== $this->dbSchemaVersion){ 49 | Logger::log( 50 | "Successfully upgraded from database schema version ". 51 | ($startupDbSchemaVersion ?? "unknown")." to V".$this->dbSchemaVersion 52 | ); 53 | } 54 | } 55 | 56 | private function setDBversion(string $dbVersion){ 57 | $this->db->query( 58 | "INSERT INTO settings(settingKey, settingValue) VALUES('dbschemaversion', $dbVersion) ". 59 | QueryCreationHelper::createUpsert($this->db->getDriver(), "settingKey", ["settingValue"]) 60 | ); 61 | $this->dbSchemaVersion = $dbVersion; 62 | } 63 | } -------------------------------------------------------------------------------- /src/vwid/db/DatabaseConnection.php: -------------------------------------------------------------------------------- 1 | host = $host; 23 | $this->db = $db; 24 | $this->username = $username; 25 | $this->password = $password; 26 | $this->driver = $driver; 27 | $this->connect(); 28 | } 29 | 30 | public function connect(){ 31 | try{ 32 | $this->connection = new PDO( 33 | $this->driver.":host=".$this->host.";dbname=".$this->db, $this->username, $this->password 34 | ); 35 | }catch(PDOException $e){ 36 | throw $this->handlePDOexception($e, "Failed to connect to db (check db connection params)"); 37 | } 38 | } 39 | 40 | public function getConnection(): PDO{ 41 | return $this->connection; 42 | } 43 | 44 | public function getDriver(): string{ 45 | return $this->driver; 46 | } 47 | 48 | public function query(string $sql){ 49 | try{ 50 | $res = $this->connection->query($sql); 51 | }catch(PDOException $e){ 52 | throw $this->handlePDOexception($e, "Query ".$sql." failed"); 53 | } 54 | return $res->fetchAll(PDO::FETCH_ASSOC); 55 | } 56 | 57 | public function prepare(string $sql): PDOStatement{ 58 | try{ 59 | $pdoStatement = $this->connection->prepare($sql); 60 | }catch(PDOException $e){ 61 | throw $this->handlePDOexception($e, "Preparing query ".$sql." failed"); 62 | } 63 | return $pdoStatement; 64 | } 65 | 66 | public function queryStatement(string $sql): PDOStatement{ 67 | try{ 68 | $pdoStatement = $this->connection->query($sql); 69 | }catch(PDOException $e){ 70 | throw $this->handlePDOexception($e, "Running query ".$sql." failed"); 71 | } 72 | return $pdoStatement; 73 | } 74 | 75 | public function handlePDOexception(PDOException $e, ?string $what = null): RuntimeException{ 76 | Logger::var_dump($e->errorInfo, "errorInfo"); 77 | return new RuntimeException(($what.": " ?? "").$e->getMessage()." [".$e->getCode()."]"); 78 | } 79 | } -------------------------------------------------------------------------------- /src/vwid/integrations/ABRP.php: -------------------------------------------------------------------------------- 1 | chargeSessionHandler = $chargeSessionHandler; 26 | $this->userToken = $userToken; 27 | $this->apiKey = $apiKey; 28 | } 29 | 30 | public function carStatusWrittenUpdate(array $carStatus){ 31 | $postData = ["token" => $this->userToken, "tlm" => json_encode([ 32 | "utc" => (new DateTime($carStatus["time"], new DateTimeZone("UTC")))->getTimestamp(), 33 | "soc" => $carStatus["batterySOC"], 34 | "power" => - (float) $carStatus["chargePower"], //this will only be charge power (sent negative), otherwise zero 35 | "is_charging" => (int) ($carStatus["chargeState"] == "charging"), 36 | "kwh_charged" => round( 37 | $this->chargeSessionHandler->getCurrentChargingSession()?->integralChargeEnergy / 3600, 2 38 | ), 39 | "est_battery_range" => $carStatus["remainingRange"] 40 | ])]; 41 | 42 | Logger::var_dump($postData, "ABRP PostData"); 43 | 44 | $curlWrapper = new CurlWrapper(); 45 | $response = $curlWrapper->postRequest(self::API_SEND_ENDPOINT, http_build_query($postData), [ 46 | "Authorization: APIKEY ".($this->apiKey ?? self::API_KEY) 47 | ]); 48 | 49 | 50 | Logger::var_dump($response, "ABRP response"); 51 | 52 | $httpCode = curl_getinfo($curlWrapper->getCh(), CURLINFO_RESPONSE_CODE); 53 | if($httpCode === 401){ 54 | if(str_contains($response, "Token")){ 55 | throw new RuntimeException( 56 | "ABRP API returned ".$response.": Check user-token, or copy a new one from the ABRP app!" 57 | ); 58 | } 59 | if(str_contains($response, "Key")){ 60 | if($this->apiKey !== null){ 61 | throw new RuntimeException( 62 | "ABRP API returned ".$response.": API key specified in config is invalid, try again without one!" 63 | ); 64 | }else{ 65 | throw new RuntimeException("ABRP API returned ".$response.": Inbuilt API key seems invalid"); 66 | } 67 | } 68 | throw new RuntimeException("ABRP API returned: (httpCode 401) ".$response); 69 | } 70 | if($httpCode !== 200){ 71 | throw new RuntimeException("ABRP API returned: (httpCode ".$httpCode.") ".$response); 72 | } 73 | 74 | try{ 75 | $response = json_decode($response, associative: true, flags: JSON_THROW_ON_ERROR); 76 | }catch(JsonException $jsonException){ 77 | Logger::critical("Error while decoding json in ABRP response"); 78 | ErrorUtils::logException($jsonException); 79 | throw new RuntimeException("ABRP API: Could not decode response"); 80 | } 81 | 82 | if($response["status"] !== "ok"){ 83 | Logger::warning("ABRP API: status is not ok"); 84 | } 85 | } 86 | } -------------------------------------------------------------------------------- /src/vwid/wizard/ConfigWizard.php: -------------------------------------------------------------------------------- 1 | message("Successfully removed FORCE_ALLOW_HTTP from .env. Please make sure to replace the .env file!"); 14 | } 15 | } 16 | $this->writeDotEnv($ini); 17 | exit; 18 | } 19 | if(isset(getopt("", ["use-env"])["use-env"])){ 20 | exit($this->createConfigFromEnvironment() ? 0 : 1); 21 | } 22 | if(isset(getopt("", ["setup-abrp"])["setup-abrp"])){ 23 | if(!file_exists(BASE_DIR."config/config.json")){ 24 | $this->interactiveDBconfig(); 25 | } 26 | $this->interactiveABRPconfig(); 27 | exit; 28 | } 29 | $this->interactiveDBconfig(); 30 | } 31 | 32 | public function createConfigFromEnvironment(): bool{ 33 | $options = getopt("", ["fill-defaults", "quiet"]); 34 | $quiet = isset($options["quiet"]); 35 | if(isset($options["fill-defaults"])){ 36 | $config = json_decode(file_get_contents(BASE_DIR."config/config.example.json"), true); 37 | }else{ 38 | if(file_exists(BASE_DIR."config/config.json")){ 39 | $config = json_decode(file_get_contents(BASE_DIR."config/config.json"), true); 40 | }else{ 41 | if(!$quiet) $this->message("No config.json found! To create from config.example.json specify --fill-defaults!"); 42 | return false; 43 | } 44 | } 45 | $_ENV = getenv(); //For configurations where the $_ENV superglobal is disabled, manually populate it. 46 | $config = $this->readEnv($config); 47 | file_put_contents(BASE_DIR."config/config.json", json_encode($config, JSON_PRETTY_PRINT)); 48 | if(!$quiet) $this->message("Successfully created/updated config.json from the environment variables"); 49 | return true; 50 | } 51 | 52 | const CONFIG_BOOLEANS = [ 53 | "IDDATALOGGER_CARPIC_FLIP", 54 | "IDDATALOGGER_LOGGING_DEBUG_ENABLE", 55 | "IDDATALOGGER_LOGGING_FILE_ENABLE", 56 | "IDDATALOGGER_LOGGING_CURL_VERBOSE" 57 | ]; 58 | 59 | const CONFIG_INTEGERS = [ 60 | "IDDATALOGGER_BASE_UPDATERATE", 61 | "IDDATALOGGER_INCREASED_UPDATERATE" 62 | ]; 63 | 64 | public function readEnv(array $config, string $path = ""): array{ 65 | foreach($config as $key => $value){ 66 | if(is_array($value)){ 67 | $config[$key] = $this->readEnv($value, $path.strtoupper(str_replace("-", "_", $key))."_"); 68 | continue; 69 | } 70 | $envName = "IDDATALOGGER_".$path.strtoupper(str_replace("-", "_", $key)); 71 | if(!empty($_ENV[$envName])){ 72 | if(in_array($envName, self::CONFIG_BOOLEANS)){ 73 | $config[$key] = $_ENV[$envName] == "true"; 74 | }elseif(in_array($envName, self::CONFIG_INTEGERS)){ 75 | $config[$key] = (int) $_ENV[$envName]; 76 | }else{ 77 | $config[$key] = $_ENV[$envName]; 78 | } 79 | } 80 | } 81 | return $config; 82 | } 83 | 84 | public function interactiveDBconfig(){ 85 | $this->message("Welcome to the config wizard for the ID DataLogger!"); 86 | $this->message("This wizard makes it easy to enter the VW account login and database connection information into the respective config files."); 87 | 88 | $this->message("You will now be asked for your VW account login information"); 89 | $idUsername = $this->get("What is the username of your VW ID account?"); 90 | $idPassword = $this->get("What is the password of your VW ID account?"); 91 | 92 | $options = getopt("", ["host:", "dbname:", "user:", "password:", "driver:", "allow-insecure-http", "quiet"]); 93 | if(empty($options)){ 94 | $this->message("You will now be asked for your database connection information"); 95 | } 96 | if(isset($options["host"])){ 97 | $hostname = $options["host"]; 98 | }else{ 99 | $hostname = $this->get("What is the hostname of the database server?", "localhost"); 100 | } 101 | if(isset($options["dbname"])){ 102 | $dbname = $options["dbname"]; 103 | }else{ 104 | $dbname = $this->get("What is the name of the database for the ID DataLogger?"); 105 | } 106 | if(isset($options["user"])){ 107 | $username = $options["user"]; 108 | }else{ 109 | $username = $this->get("What is the username of the user to connect to the database server?"); 110 | } 111 | if(isset($options["password"])){ 112 | $password = $options["password"]; 113 | }else{ 114 | $password = $this->get("What is the password of the user to connect to the database server? (If no password is needed leave this blank or enter null.)", "null"); 115 | } 116 | if($password == "null" || empty($password)){ 117 | $password = null; 118 | } 119 | if(isset($options["driver"])){ 120 | $driver = $options["driver"]; 121 | }else{ 122 | $driver = $this->get("What is the driver we should use for connecting to the database server?", "pgsql"); 123 | } 124 | 125 | if(file_exists(BASE_DIR."config/config.json")){ 126 | if($this->get("A config.json already exists. Do you want to overwrite it with the defaults from config.example.json?", "N", ["Y", "N"]) != "Y"){ 127 | if($this->get("Do you want to update your existing config with the parameters you entered in this wizard? (Answering N will exit this Wizard)", "Y", ["Y", "N"]) == "Y"){ 128 | $this->writeJsonConfig($idUsername, $idPassword, $hostname, $dbname, $username, $password, $driver, "config.json"); 129 | }else{ 130 | exit; 131 | } 132 | }else{ 133 | $this->writeJsonConfig($idUsername, $idPassword, $hostname, $dbname, $username, $password, $driver); 134 | } 135 | }else{ 136 | $this->writeJsonConfig($idUsername, $idPassword, $hostname, $dbname, $username, $password, $driver); 137 | } 138 | 139 | $ini = parse_ini_file(BASE_DIR.".env.example", false, INI_SCANNER_TYPED); 140 | if(isset($options["allow-insecure-http"])){ 141 | $this->message(<<writeDotEnv($ini); 156 | 157 | if(!isset($options["quiet"])){ 158 | $this->message("Perfect! The configuration has been written to config/config.json and the .env file."); 159 | $this->message(<<message("Welcome to the config wizard for enabling the ABRP integration"); 169 | $this->message("You need to get a token for the generic data source from within the ABRP app."); 170 | $this->message("See the wiki page \"ABRP integration\" for details!"); 171 | 172 | $abrpUserToken = $this->get("What is your abrp user token?"); 173 | 174 | if(file_exists(BASE_DIR."config/config.json")){ 175 | $this->writeJsonConfigABRP($abrpUserToken, "config.json"); 176 | }else{ 177 | $this->writeJsonConfigABRP($abrpUserToken); 178 | } 179 | 180 | $this->message("Perfect! The abrp user token has been written to config/config.json. You need to restart IDDataLogger now."); 181 | } 182 | 183 | private function writeDotEnv(array $iniValues){ 184 | $newIni = ""; 185 | foreach($iniValues as $key => $value){ 186 | if(is_string($value)){ 187 | $value = "\"".$value."\""; 188 | } 189 | if($value === null){ 190 | $value = "null"; 191 | } 192 | if(is_bool($value)){ 193 | $value = $value ? "true" : "false"; 194 | } 195 | $newIni .= $key."=".$value."\n"; 196 | } 197 | file_put_contents(BASE_DIR.".env", $newIni); 198 | } 199 | 200 | private function writeJsonConfig( 201 | ?string $idUsername, ?string $idPassword, string $hostname, string $dbname, string $username, ?string $password, 202 | string $driver, string $filename = "config.example.json" 203 | ): array{ 204 | $config = json_decode(file_get_contents(BASE_DIR."config/".$filename), true); 205 | if(!empty($idUsername)){ 206 | $config["username"] = $idUsername; 207 | } 208 | if(!empty($idPassword)){ 209 | $config["password"] = $idPassword; 210 | } 211 | $config["db"]["host"] = $hostname; 212 | $config["db"]["dbname"] = $dbname; 213 | $config["db"]["user"] = $username; 214 | $config["db"]["password"] = $password; 215 | $config["db"]["driver"] = $driver; 216 | 217 | file_put_contents(BASE_DIR."config/config.json", json_encode($config, JSON_PRETTY_PRINT)); 218 | return $config; 219 | } 220 | 221 | private function writeJsonConfigABRP( 222 | string $abrpUserToken, string $filename = "config.example.json" 223 | ): array{ 224 | $config = json_decode(file_get_contents(BASE_DIR."config/".$filename), true); 225 | 226 | $config["integrations"]["abrp"]["user-token"] = $abrpUserToken; 227 | 228 | file_put_contents(BASE_DIR."config/config.json", json_encode($config, JSON_PRETTY_PRINT)); 229 | return $config; 230 | } 231 | } -------------------------------------------------------------------------------- /src/vwid/wizard/InteractiveWizard.php: -------------------------------------------------------------------------------- 1 | ".$msg; 17 | 18 | 19 | if(!empty($options)){ 20 | $out .= " (".implode(",", $options).")"; 21 | } 22 | if($default !== null){ 23 | $out .= "\n[".$default."]"; 24 | } 25 | $out .= ": "; 26 | 27 | echo $out; 28 | 29 | $input = $this->readLine(); 30 | if(!empty($options) && $input !== ""){ 31 | if(!in_array($input, $options, true)){ 32 | $this->message("Please answer with one of the following options (case-sensitive!): ".implode(",", $options)); 33 | return $this->get($msg, $default, $options); 34 | } 35 | } 36 | 37 | return $input === "" ? $default : $input; 38 | } 39 | } -------------------------------------------------------------------------------- /src/vwid/wizard/SetupWizard.php: -------------------------------------------------------------------------------- 1 | main = $main; 15 | $this->initialSetup(); 16 | } 17 | 18 | public function initialSetup(){ 19 | $this->message("Welcome to the ID DataLogger! This setup wizard guides you through the last setup steps!"); 20 | $this->message("A connection to the database has already been established and tables have been initialized."); 21 | 22 | $options = getopt("", ["frontend-username:", "frontend-password:", "frontend-apikey:"]); 23 | if(empty($options["frontend-apikey"])){ 24 | $additional = reset($this->main->getDB()->query("SELECT count(*) FROM authKeys")[0]) > 0; 25 | $this->message( 26 | "We can now generate an ".($additional ? "additional" : ""). 27 | " API key for accessing the carStatus and carPicture API. It is required for the iOS widget." 28 | ); 29 | $generateAPIkey = $this->get( 30 | "Do you want to generate an ".($additional ? "additional" : "")." API key?", 31 | $additional ? "N": "Y", ["Y", "N"] 32 | ); 33 | if($generateAPIkey == "Y"){ 34 | $this->generateAPIkey(); 35 | } 36 | }else{ 37 | $this->addApiKey($options["frontend-apikey"]); 38 | } 39 | 40 | if(empty($options["frontend-username"]) || empty($options["frontend-password"])){ 41 | $this->message("We can now create a user for the IDView (website with statistics about the car)"); 42 | $generateAPIkey = $this->get("Do you want to create an user?", "Y", ["Y", "N"]); 43 | if($generateAPIkey == "Y"){ 44 | $this->setupUser(); 45 | } 46 | }else{ 47 | $this->addUser($options["frontend-username"], $options["frontend-password"]); 48 | } 49 | 50 | $this->message("Perfect! Server will now continue starting..."); 51 | } 52 | 53 | private function generateAPIkey(){ 54 | $apiKey = bin2hex(random_bytes(32)); 55 | $this->addApiKey($apiKey); 56 | $this->message("Successfully generated the API key ".$apiKey.""); 57 | $this->message("Please enter this API key in the apiKey setting at the top of the iOS widget!"); 58 | } 59 | 60 | private function addApiKey(string $apiKey){ 61 | if(reset($this->main->getDB()->query("SELECT count(*) FROM authKeys WHERE authkey = '".$apiKey."'")[0]) == 0){ 62 | $this->main->getDB()->query("INSERT INTO authKeys(authKey) VALUES('".$apiKey."')"); 63 | } 64 | } 65 | 66 | private function setupUser(){ 67 | $username = $this->get("Enter the username for the new user"); 68 | $password = $this->get("Now enter the password for the new user"); 69 | if(strlen($username) > 64 && $this->main->getDB()->getDriver() !== "pgsql"){ 70 | throw new RuntimeException("The username cannot be longer than 64 chars!"); 71 | } 72 | $this->addUser($username, $password); 73 | } 74 | 75 | private function addUser(string $username, string $password){ 76 | $putUser = $this->main->getDB()->prepare( 77 | "INSERT INTO users(username, hash) VALUES(?, ?)". 78 | QueryCreationHelper::createUpsert($this->main->getDB()->getDriver(), "username", ["username", "hash"]) 79 | ); 80 | 81 | $res = $putUser->execute([$username, password_hash($password, PASSWORD_DEFAULT)]); 82 | if($res !== false){ 83 | $this->message("Successfully created the user! Please remember the username and password!"); 84 | }else{ 85 | throw new RuntimeException("Failed to create the user"); 86 | } 87 | } 88 | } -------------------------------------------------------------------------------- /src/webutils/CurlError.php: -------------------------------------------------------------------------------- 1 | ch, $curlOption, $value)){ 15 | throw new Exception("Failed to set curl option ".$curlOption." to ".print_r($value, true)); 16 | } 17 | } 18 | 19 | public function __construct($enableVerbose = false){ 20 | $this->ch = curl_init(); 21 | $this->setCurlOption(CURLOPT_VERBOSE, $enableVerbose); 22 | $this->setCurlOption(CURLOPT_COOKIEFILE, ""); //enable cookie handling 23 | $this->setCurlOption(CURLOPT_FOLLOWLOCATION, true); 24 | } 25 | 26 | private static function parseHeaderLine(string $headerLine): ?array{ 27 | $colonPos = strpos($headerLine, ":"); 28 | if($colonPos === false){ 29 | return null; 30 | } 31 | $name = substr($headerLine, 0, $colonPos); 32 | return [$name, substr($headerLine, $colonPos+2)]; 33 | } 34 | 35 | protected function onHeaderEntry(string $headerEntryName, string $headerEntryValue){ 36 | 37 | } 38 | 39 | protected function onCookie(string $cookieName, string $cookieContent){ 40 | 41 | } 42 | 43 | private function curlHeader(CurlHandle $ch, string $headerLine){ 44 | //Logger::debug("Curl header: ".$headerLine); 45 | $headerEntry = self::parseHeaderLine($headerLine); 46 | if($headerEntry !== null){ 47 | $this->onHeaderEntry(...$headerEntry); 48 | if(strtolower($headerEntry[0]) == "set-cookie"){ 49 | $cookieStr = $headerEntry[1]; 50 | $posAssign = strpos($cookieStr, "="); 51 | $cookieName = substr($cookieStr, 0, $posAssign); 52 | $posSemicolon = strpos($cookieStr, ";"); 53 | $cookieContent = substr($cookieStr, $posAssign+1, $posSemicolon-$posAssign-1); 54 | $this->onCookie($cookieName, $cookieContent); 55 | } 56 | } 57 | return strlen($headerLine); 58 | } 59 | 60 | /** 61 | * Performs curl_exec with returntransfer and the curlHeader callbacks on the CURL resource $this->ch 62 | * 63 | * @return string Returns the result 64 | */ 65 | protected function curl_exec(): string{ 66 | $this->setCurlOption(CURLOPT_RETURNTRANSFER, true); 67 | $this->setCurlOption(CURLOPT_HEADERFUNCTION, [$this, "curlHeader"]); 68 | $result = curl_exec($this->ch); 69 | if($result === false){ 70 | if(curl_errno($this->ch)){ 71 | $curlError = new CurlError("A curl request failed: ".curl_error($this->ch)." [".curl_errno($this->ch)."]"); 72 | $curlError->curlErrNo = curl_errno($this->ch); 73 | throw $curlError; 74 | }else{ 75 | throw new Exception("A curl request failed with an unknown reason."); 76 | } 77 | } 78 | return $result; 79 | } 80 | 81 | public function getRequest(string $url, array $fields = [], array $header = []){ 82 | Logger::debug("GET request to ".$url.HTTPUtils::makeFieldStr($fields)); 83 | curl_setopt($this->ch, CURLOPT_POST, false); 84 | curl_setopt($this->ch, CURLOPT_URL, $url.HTTPUtils::makeFieldStr($fields)); 85 | #$header[] = "Connection: keep-alive"; 86 | if(!empty($header)){ 87 | Logger::var_dump($header, "header"); 88 | } 89 | curl_setopt($this->ch, CURLOPT_HTTPHEADER, $header); 90 | return $this->curl_exec(); 91 | } 92 | 93 | public function postRequest(string $url, $body, array $header = []){ 94 | Logger::debug("POST request to ".$url." body:".print_r($body, true)); 95 | curl_setopt($this->ch, CURLOPT_POST, true); 96 | curl_setopt($this->ch, CURLOPT_POSTFIELDS, $body); 97 | curl_setopt($this->ch, CURLOPT_URL, $url); 98 | #$header[] = "Connection: keep-alive"; 99 | if(!empty($header)){ 100 | Logger::var_dump($header, "header"); 101 | } 102 | curl_setopt($this->ch, CURLOPT_HTTPHEADER, $header); 103 | return $this->curl_exec(); 104 | 105 | } 106 | 107 | public function getCh(){ 108 | return $this->ch; 109 | } 110 | 111 | public function __destruct(){ 112 | curl_close($this->ch); 113 | } 114 | } -------------------------------------------------------------------------------- /src/webutils/Form.php: -------------------------------------------------------------------------------- 1 | formElement = $ele; 13 | } 14 | 15 | public function getAttribute(string $name): string{ 16 | return $this->formElement->attributes->getNamedItem($name)->textContent; 17 | } 18 | 19 | public function getHiddenFields(): array{ 20 | $fields = []; 21 | foreach($this->formElement->childNodes as $childNode){ 22 | if($childNode->nodeName == "input" && $childNode->attributes->getNamedItem("type")->textContent == "hidden"){ 23 | $fields[$childNode->attributes->getNamedItem("name")->textContent] = $childNode->attributes->getNamedItem("value")->textContent; 24 | } 25 | } 26 | return $fields; 27 | } 28 | } -------------------------------------------------------------------------------- /src/webutils/HTTPUtils.php: -------------------------------------------------------------------------------- 1 | $fieldValue){ 13 | $fieldStr.= $fieldName."=".$fieldValue."&"; 14 | } 15 | return $fieldStr; 16 | } 17 | } -------------------------------------------------------------------------------- /start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | DIR="$(cd -P "$( dirname "${BASH_SOURCE[0]}" )" && pwd)" 4 | cd "$DIR" || exit 1 5 | 6 | exec "php" "./src/vwid/Server.php" "$@" --------------------------------------------------------------------------------