├── .gitignore ├── images ├── Grid.png ├── InUse.jpg ├── tower.png ├── EnergyBar.png ├── PowerLine.png ├── CarCharging.png ├── SolarProduction.png ├── HouseConsumption.png └── PowerwallSelfPowered.png ├── .stylelintrc ├── .eslintrc.json ├── package.json ├── LICENSE.txt ├── refresh.py ├── Gruntfile.js ├── translations ├── ps.json ├── de.json ├── en.json └── it.json ├── auth.njk ├── scratch.txt ├── MMM-Powerwall.css ├── MMM-Powerwall.njk ├── powerwall.js ├── README.md ├── node_helper.js └── MMM-Powerwall.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode/ 3 | tokens.json 4 | localpw.json 5 | -------------------------------------------------------------------------------- /images/Grid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MikeBishop/MMM-Powerwall/HEAD/images/Grid.png -------------------------------------------------------------------------------- /images/InUse.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MikeBishop/MMM-Powerwall/HEAD/images/InUse.jpg -------------------------------------------------------------------------------- /images/tower.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MikeBishop/MMM-Powerwall/HEAD/images/tower.png -------------------------------------------------------------------------------- /images/EnergyBar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MikeBishop/MMM-Powerwall/HEAD/images/EnergyBar.png -------------------------------------------------------------------------------- /images/PowerLine.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MikeBishop/MMM-Powerwall/HEAD/images/PowerLine.png -------------------------------------------------------------------------------- /images/CarCharging.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MikeBishop/MMM-Powerwall/HEAD/images/CarCharging.png -------------------------------------------------------------------------------- /images/SolarProduction.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MikeBishop/MMM-Powerwall/HEAD/images/SolarProduction.png -------------------------------------------------------------------------------- /images/HouseConsumption.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MikeBishop/MMM-Powerwall/HEAD/images/HouseConsumption.png -------------------------------------------------------------------------------- /images/PowerwallSelfPowered.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MikeBishop/MMM-Powerwall/HEAD/images/PowerwallSelfPowered.png -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-standard", 3 | "font-family-name-quotes": "double-where-recommended", 4 | "block-no-empty": false 5 | } 6 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "indent": ["error", "tab"], 4 | "quotes": ["error", "double"], 5 | "max-len": ["error", 250], 6 | "curly": "error", 7 | "camelcase": ["error", {"properties": "never"}], 8 | "no-trailing-spaces": ["error"], 9 | "no-irregular-whitespace": ["error"] 10 | }, 11 | "env": { 12 | "browser": true, 13 | "node": true, 14 | "es6": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mmm-powerwall", 3 | "version": "1.0.0", 4 | "description": "MagicMirror module for Tesla Powerwall", 5 | "main": "MMM-Powerwall.js", 6 | "author": "Mike Bishop", 7 | "license": "MIT", 8 | "dependencies": { 9 | "async-mqtt": "^2.6.1", 10 | "async-mutex": "^0.3.1", 11 | "await-spawn": "^4.0.1", 12 | "axios": "^1.6.0", 13 | "chart.js": "^3.9.1", 14 | "chartjs-adapter-luxon": "^1.3.1", 15 | "chartjs-plugin-annotation": "^1.0.2", 16 | "chartjs-plugin-datalabels": "^2.0.0", 17 | "express-validator": "^6.12.0", 18 | "glob-parent": ">=6.0.1", 19 | "luxon": "^3.7.1", 20 | "node-fetch": "^3.2.10", 21 | "querystring": "^0.2.0", 22 | "tough-cookie": "^4.0.0", 23 | "validator": ">=13.7.0" 24 | }, 25 | "devDependencies": { 26 | "pseudoloc-js": "^1.2.2", 27 | "typescript": "^4.3.5" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Mike Bishop 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /refresh.py: -------------------------------------------------------------------------------- 1 | #!/bin/env python3 2 | 3 | import argparse 4 | import os 5 | import json 6 | import sys 7 | import requests 8 | 9 | MAX_ATTEMPTS = 7 10 | CLIENT_ID = "81527cff06843c8634fdc09e8ac0abefb46ac849f38fe1e431c2ef2106796384" 11 | UA = "PostmanRuntime/7.26.10" #"Mozilla/5.0 (Linux; Android 10; Pixel 3 Build/QQ2A.200305.002; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/85.0.4183.81 Mobile Safari/537.36" 12 | X_TESLA_USER_AGENT = "TeslaApp/3.10.9-433/adff2e065/android/10" 13 | 14 | def vprint(*args, **kwargs): 15 | print(*args, file=sys.stderr, **kwargs) 16 | 17 | def refresh(args): 18 | token = args.token 19 | session = requests.Session() 20 | 21 | headers = {"user-agent": UA} #"x-tesla-user-agent": X_TESLA_USER_AGENT} 22 | payload = { 23 | "grant_type": 'refresh_token', 24 | "client_id": 'ownerapi', 25 | "refresh_token": token, 26 | "scope": 'openid email offline_access' 27 | } 28 | 29 | resp = session.post("https://auth.tesla.com/oauth2/v3/token", headers=headers, json=payload) 30 | 31 | if not resp.ok: 32 | vprint("Refresh failed") 33 | sys.exit(1) 34 | 35 | # Return tokens 36 | tokens = resp.json() 37 | print(json.dumps(tokens)) 38 | 39 | 40 | if __name__ == "__main__": 41 | parser = argparse.ArgumentParser() 42 | parser.add_argument("token", type=str, help="Tesla refresh token") 43 | 44 | args = parser.parse_args() 45 | refresh(args) 46 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | module.exports = function(grunt) { 2 | require("time-grunt")(grunt); 3 | grunt.initConfig({ 4 | pkg: grunt.file.readJSON("package.json"), 5 | eslint: { 6 | options: { 7 | configFile: ".eslintrc.json" 8 | }, 9 | target: ["*.js"] 10 | }, 11 | stylelint: { 12 | simple: { 13 | options: { 14 | configFile: ".stylelintrc" 15 | }, 16 | src: ["*.css"] 17 | } 18 | }, 19 | jsonlint: { 20 | main: { 21 | src: ["package.json", "translations/*.json"], 22 | options: { 23 | reporter: "jshint" 24 | } 25 | } 26 | }, 27 | markdownlint: { 28 | all: { 29 | options: { 30 | config: { 31 | "default": true, 32 | "line-length": false, 33 | "blanks-around-headers": false, 34 | "no-duplicate-header": false, 35 | "no-inline-html": false, 36 | "MD010": false, 37 | "MD001": false, 38 | "MD031": false, 39 | "MD040": false, 40 | "MD002": false, 41 | "MD029": false, 42 | "MD041": false, 43 | "MD032": false, 44 | "MD036": false, 45 | "MD037": false, 46 | "MD009": false, 47 | "MD018": false, 48 | "MD012": false, 49 | "MD026": false, 50 | "MD038": false 51 | } 52 | }, 53 | src: ["README.md", "CHANGELOG.md", "LICENSE.txt"] 54 | } 55 | }, 56 | yamllint: { 57 | all: [".travis.yml"] 58 | } 59 | }); 60 | grunt.loadNpmTasks("grunt-eslint"); 61 | grunt.loadNpmTasks("grunt-stylelint"); 62 | grunt.loadNpmTasks("grunt-jsonlint"); 63 | grunt.loadNpmTasks("grunt-yamllint"); 64 | grunt.loadNpmTasks("grunt-markdownlint"); 65 | grunt.registerTask("default", ["eslint", "stylelint", "jsonlint", "markdownlint", "yamllint"]); 66 | }; 67 | -------------------------------------------------------------------------------- /translations/ps.json: -------------------------------------------------------------------------------- 1 | { 2 | "_header": "[!!*******************𠲖🐔𠜎𠸏🐔!!]", 3 | "_section": "[!!Ġŗȁƥĥ ȁxĩş𠜱🤙🐔!!]", 4 | "solar": "[!!Ŝŏĺåř𠸏!!]", 5 | "battery": "[!!Ƥŏŵėŕŵǻłł🏆𠸏!!]", 6 | "grid": "[!!Ĝŗıď𢺳!!]", 7 | "house": "[!!Ļơčȃļ Ųšȃģě𠜎𢺳𠲖!!]", 8 | "car": "[!!Ćàŕ Ćĥàŕĝĭńĝ🐔𠜎𠸏!!]", 9 | "yesterday": "[!!ƴėŝţėŕđǻƴ𠴕𠜎!!]", 10 | "today": "[!!ŧŏđåƴ🐔!!]", 11 | "unknown": "[!!Ůʼnķʼnơŵʼn ģŕãƿħ ţɏƿȇ šȇľȇčţȇđ𢺳𠸏𠜱𠴕🏆𠜱𠜎🏆!!]", 12 | "charging_at": "[!!{NAME} īŝ ĉħǻŕğīʼnğ ǻţ𠴕𠜱𠲖𠜎🤙𠲖!!]", 13 | "charging_at_plural": "[!!{NAME} áņđ {NUM} mŏŗė áŗė ĉħáŗğĭņğ áť𠸏𠸏𠲖𢺳🏆𠜱𠜱🏆𠜱𠸏🤙!!]", 14 | "consuming": "[!!{NAME} ıš čơņšūmıņģ𠜱𠸏𠜎🏆𠲖!!]", 15 | "consuming_plural": "[!!{NAME} ȃƞđ {NUM} mơřē ȃřē čơƞšųmĩƞģ𠜎𠜱𢺳𠲖𠜱𠜎𠸏🤙𠜎𢺳!!]", 16 | "unavailable": "[!!{NAME} īŝ ůʼnǻvǻīļǻƄļē𠜱𠜎🐔🐔𢺳𠸏!!]", 17 | "driving": "[!!{NAME} įś ďŗįvįŋĝ {LOCATION}𠲖𠸏🏆𠜎🤙𠜎𠜎🤙!!]", 18 | "parked": "[!!{NAME} ĭš ƿãŕķȇđ {LOCATION}𠜱𠸏𠜎𠲖𠜱𠲖𠜎𠴕!!]", 19 | "charging": "[!!{NAME} ıŝ ĉħåřğıƞğ {LOCATION}🤙🏆𠜱𠜱𠸏🤙𠜱𠲖!!]", 20 | "not_charging": "[!!{NAME} ĭŝ {LOCATION} áņđ ņŏť ĉħáŗğĭņğ𢺳🏆𠸏𠲖🤙𠜎🐔𠲖𠲖𠸏𠴕!!]", 21 | "_comment": "[!!...ŵħēřē ŧħē ƿĩēčēš ȃřē đřȃŵƞ ƒřơm:𠲖𠲖🤙𢺳🏆🏆𢺳𠲖𠸏𠜎!!]", 22 | "at_home": "[!!ăť ħơmē𠜱𠜎!!]", 23 | "elsewhere": "[!!ıʼn {TOWN}𠜎𠸏!!]", 24 | "completion_time": "[!!Ƒĩņĩšħĩņģ ĩņ {DAYS}{LISTSEP1}{HOURS}{LISTSEP2}{MINUTES}𠜱𢺳𠸏𠸏𠲖𠸏🏆𠴕𠸏𠸏𠜱𢺳𠜎🤙𠸏𠜎!!]", 25 | "listsep": "[!!, !!]", 26 | "1day": "[!!1 đåƴ𠴕!!]", 27 | "multiday": "[!!{NUM} ďȁȳş🏆𠜱𠸏!!]", 28 | "1hour": "[!!1 ĥőũŕ𢺳!!]", 29 | "multihours": "[!!{NUM} ħơųřš𠸏🏆𠸏!!]", 30 | "minutes": "[!!{NUM} mįņūťȇŝ🏆𠜱🤙!!]", 31 | "solar_current": "[!!Şőŀāŕ įş ƥŕőďũċįńġ𠜎🏆𢺳🏆𠜎!!]", 32 | "solar_sameday": "[!!Şőŀāŕ ĥāş ƥŕőďũċěď𢺳𠸏𠜱𠸏𠲖!!]", 33 | "solar_prevday": "[!!Şőłâř ƥřőďŭċēď🐔𠸏🏆𠜱!!]", 34 | "today_during": "[!!śō ƒàŕ ţōďàŷ𠲖𠜎🤙!!]", 35 | "grid_transition": "[!!Ğŕīđ īŝ ĉŏmīʼnğ ŏʼnļīʼnē𠸏𠜱𠸏𠜎𠜎𠸏!!]", 36 | "grid_disconnected": "[!!Ĝřĩď ĩś ďĩśćōňňȇćŧȇď𠲖🐔𠜎🐔𠸏𠜱!!]", 37 | "grid_supply": "[!!Ğřĭđ ĭŝ ŝųƿƿľƴĭƞğ𠸏𠸏𠸏𠸏𠲖!!]", 38 | "grid_receive": "[!!Ğřĭđ ĭŝ řęĉęĭvĭƞğ🏆𠸏𠜱𠜎𠸏!!]", 39 | "grid_idle": "[!!Ĝŕĭď ĭś ĭďľȅ🏆𠸏𢺳!!]", 40 | "stormwatch": "[!!Šŧơřm Ŵȃŧčħ𠸏🤙🐔!!]", 41 | "import_today": "[!!ımƥőřŧēď ŧőďâȳ𠜱🏆𠲖🏆!!]", 42 | "export_today": "[!!ēxƥőřŧēď ŧőďâȳ𢺳🤙𠜱𠜱!!]", 43 | "house_consuming": "[!!Ĥőũşě įş ċőńşũmįńġ𠜎𢺳𠜱𠜎𠸏!!]", 44 | "battery_supply": "[!!Ŝůƿƿłƴıʼnğ𠲖𢺳!!]", 45 | "battery_charging": "[!!Čħȃřģīƞģ ȃŧ𠜎🐔𠜎!!]", 46 | "battery_standby": "[!!ŠťăņđƂɏ𠜎𠸏!!]", 47 | "selfpowered": "[!!Śȅľƒ-Ƥōŵȅŕȅď𠴕𠲖𢺳!!]", 48 | "powerline_label": "[!!Ƥōŵȇř Ŧō / Ƒřōm (ƙŴ)𠲖𠜎𠲖𠜱🏆🤙!!]", 49 | "energybar_label": "[!!Ĕŋĕŗġȳ Ťő / Ƒŗőm (ĸŴĥ)𠸏𠸏𠜎🐔🤙🤙!!]" 50 | } -------------------------------------------------------------------------------- /translations/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "_header": "*******************", 3 | "_section": "Display Categories", 4 | "_header": "*******************", 5 | "solar": "Solar", 6 | "battery": "Powerwall", 7 | "grid": "Stromnetz", 8 | "house": "Haus", 9 | "car": "Auto lädt", 10 | 11 | "_header": "*******************", 12 | "_section": "General UI terms", 13 | "_header": "*******************", 14 | "yesterday": "Gestern", 15 | "today": "Heute", 16 | "unknown": "unbekannte Darstellung!", 17 | 18 | "_header": "*******************", 19 | "_section": "Car status", 20 | "_header": "*******************", 21 | "charging_at": "{NAME} lädt auf mit", 22 | "charging_at_plural": "{NAME} und {NUM} laden auf mit", 23 | "consuming": "{NAME} verbraucht", 24 | "consuming_plural": "{NAME} und {NUM} verbrauchen", 25 | "unavailable": "{NAME} ist nicht verfügbar", 26 | 27 | "driving": "{NAME} fährt {LOCATION}", 28 | "parked": "{NAME} parkt {LOCATION}", 29 | "charging": "{NAME} lädt auf {LOCATION}", 30 | "not_charging": "{NAME} ist hier {LOCATION} und lädt nicht", 31 | "_comment": "...wobei {LOCATION} ist entweder:", 32 | "at_home": "zuhause", 33 | "at_geofence": "bei {GEOFENCE}", 34 | "elsewhere": "in {TOWN}", 35 | 36 | "completion_time": "Abgeschlossen in {DAYS}{LISTSEP1}{HOURS}{LISTSEP2}{MINUTES}", 37 | "_comment": "...wobei die Teile sich wie folgt zusammensetzen:", 38 | "listsep": ", ", 39 | "1day": "1 Tag", 40 | "multiday": "{NUM} Tage", 41 | "1hour": "1 Stunde", 42 | "multihours": "{NUM} Stunden", 43 | "minutes": "{NUM} Minuten", 44 | 45 | "_header": "*******************", 46 | "_section": "Solar Status", 47 | "_header": "*******************", 48 | "solar_current": "Solar erzeugt", 49 | "solar_sameday": "Solar hat erzeugt", 50 | "solar_prevday": "Solar erzeugte", 51 | "today_during": "insgesamt heute", 52 | 53 | "_header": "*******************", 54 | "_section": "Grid Status", 55 | "_header": "*******************", 56 | "grid_transition": "Stromnetz startet", 57 | "grid_disconnected": "Stromausfall, kein Netz !", 58 | "grid_supply": "Stromnetz liefert", 59 | "grid_receive": "Stromnetz empfängt", 60 | "grid_idle": "Stromnetz schläft", 61 | "stormwatch": "Sturmwache", 62 | "import_today": "heute bezogen", 63 | "export_today": "heute geliefert", 64 | 65 | "_header": "*******************", 66 | "_section": "House Status", 67 | "_header": "*******************", 68 | "house_consuming": "Haus verbraucht", 69 | 70 | "_header": "*******************", 71 | "_section": "Battery status", 72 | "_header": "*******************", 73 | "battery_supply": "Liefert", 74 | "battery_charging": "Lädt auf mit", 75 | "battery_standby": "Schläft", 76 | "selfpowered": "Eigenproduktion", 77 | 78 | "_header": "*******************", 79 | "_section": "Graph axis", 80 | "_header": "*******************", 81 | "powerline_label": "Leistung nach / von (kW)", 82 | "energybar_label": "Energie nach / von (kWh)" 83 | } 84 | -------------------------------------------------------------------------------- /auth.njk: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ translations.authTitle }} 4 | 5 | 6 |
7 |

{{ translations.authHeader }}

8 | 13 |
14 | 15 |

16 | English badge 17 |
18 | Get it on Google Play 19 |
20 | Download on the App Store 21 |

22 | 23 |
24 | {% for field in ["username", "token", "refresh_token" ] %} 25 |
26 | 27 | {% if field == "username" %} 28 | 33 | {% else %} 34 | 35 | {% endif %} 36 | 37 | {%- if errors[field] %} 38 |
{{errors[field].msg}}
39 | {% endif %} 40 |
41 | {% endfor %} 42 | 43 |
44 | 45 |
46 |
47 | 48 |
49 |

{{ translations.powerwallHeader }}

50 | 55 |
56 | 57 | 58 |
59 | {% for field in ["ip", "password" ] %} 60 |
61 | 62 | {% if field == "ip" %} 63 | 68 | {% else %} 69 | 74 | {% endif %} 75 | {% if errors[field] %} 76 |
{{errors[field].msg}}
77 | {% endif %} 78 |
79 | {% endfor %} 80 | 81 |
82 | 83 |
84 |
85 | 86 | 87 | -------------------------------------------------------------------------------- /translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "_header": "*******************", 3 | "_section": "Display Categories", 4 | "_header": "*******************", 5 | "solar": "Solar", 6 | "battery": "Powerwall", 7 | "grid": "Grid", 8 | "house": "Local Usage", 9 | "car": "Car Charging", 10 | 11 | "_header": "*******************", 12 | "_section": "General UI terms", 13 | "_header": "*******************", 14 | "yesterday": "yesterday", 15 | "today": "today", 16 | "unknown": "Unknown graph type selected", 17 | 18 | "_header": "*******************", 19 | "_section": "Car status", 20 | "_header": "*******************", 21 | "charging_at": "{NAME} is charging at", 22 | "charging_at_plural": "{NAME} and {NUM} more are charging at", 23 | "consuming": "{NAME} is consuming", 24 | "consuming_plural": "{NAME} and {NUM} more are consuming", 25 | "unavailable": "{NAME} is unavailable", 26 | 27 | "driving": "{NAME} is driving {LOCATION}", 28 | "parked": "{NAME} is parked {LOCATION}", 29 | "charging": "{NAME} is charging {LOCATION}", 30 | "not_charging": "{NAME} is {LOCATION} and not charging", 31 | "_comment": "...where {LOCATION} is one of:", 32 | "at_home": "at home", 33 | "at_geofence": "at {GEOFENCE}", 34 | "elsewhere": "in {TOWN}", 35 | 36 | "completion_time": "Finishing in {DAYS}{LISTSEP1}{HOURS}{LISTSEP2}{MINUTES}", 37 | "_comment": "...where the pieces are drawn from:", 38 | "listsep": ", ", 39 | "1day": "1 day", 40 | "multiday": "{NUM} days", 41 | "1hour": "1 hour", 42 | "multihours": "{NUM} hours", 43 | "minutes": "{NUM} minutes", 44 | 45 | "_header": "*******************", 46 | "_section": "Solar Status", 47 | "_header": "*******************", 48 | "solar_current": "Solar is producing", 49 | "solar_sameday": "Solar has produced", 50 | "solar_prevday": "Solar produced", 51 | "today_during": "so far today", 52 | 53 | "_header": "*******************", 54 | "_section": "Grid Status", 55 | "_header": "*******************", 56 | "grid_transition": "Grid is coming online", 57 | "grid_disconnected": "Grid is disconnected", 58 | "grid_supply": "Grid is supplying", 59 | "grid_receive": "Grid is receiving", 60 | "grid_idle": "Grid is idle", 61 | "stormwatch": "Storm Watch", 62 | "import_today": "imported today", 63 | "export_today": "exported today", 64 | 65 | "_header": "*******************", 66 | "_section": "House Status", 67 | "_header": "*******************", 68 | "house_consuming": "House is consuming", 69 | 70 | "_header": "*******************", 71 | "_section": "Battery status", 72 | "_header": "*******************", 73 | "battery_supply": "Supplying", 74 | "battery_charging": "Charging at", 75 | "battery_standby": "Standby", 76 | "selfpowered": "Self-Powered", 77 | 78 | "_header": "*******************", 79 | "_section": "Graph axis", 80 | "_header": "*******************", 81 | "powerline_label": "Power To / From (kW)", 82 | "energybar_label": "Energy To / From (kWh)", 83 | 84 | "_header": "*******************", 85 | "_section": "Authentication Page", 86 | "_header": "*******************", 87 | "authTitle": "Log in to Tesla API", 88 | "authHeader": "Log in to Tesla API for MMM-Powerwall", 89 | "token": "Authentication Token", 90 | "refresh_token": "Refresh Token", 91 | "invalidtoken": "Token not valid", 92 | "ip": "Powerwall IP", 93 | "fetch": "Log In", 94 | "authNeeded": "Go to /MMM-Powerwall/auth to log in", 95 | "authFailed": "Authentication failed" 96 | } 97 | -------------------------------------------------------------------------------- /translations/it.json: -------------------------------------------------------------------------------- 1 | { 2 | "_header": "*******************", 3 | "_section": "Display Categories", 4 | "_header": "*******************", 5 | "solar": "Pannelli", 6 | "battery": "Powerwall", 7 | "grid": "Rete", 8 | "house": "Casa", 9 | "car": "Auto", 10 | 11 | "_header": "*******************", 12 | "_section": "General UI terms", 13 | "_header": "*******************", 14 | "yesterday": "ieri", 15 | "today": "oggi", 16 | "unknown": "Grafico selezionato sconosciuto", 17 | 18 | "_header": "*******************", 19 | "_section": "Car status", 20 | "_header": "*******************", 21 | "charging_at": "{NAME} sta caricando", 22 | "charging_at_plural": "{NAME} e {NUM} stanno caricando", 23 | "consuming": "{NAME} sta consumando", 24 | "consuming_plural": "{NAME} and {NUM} stanno consumando", 25 | "unavailable": "{NAME} non � disponibile", 26 | 27 | "driving": "{NAME} sta guidando {LOCATION}", 28 | "parked": "{NAME} � parcheggiata {LOCATION}", 29 | "charging": "{NAME} sta caricando {LOCATION}", 30 | "not_charging": "{NAME} � {LOCATION} e non sta caricando", 31 | "_comment": "...dove {LOCATION} � una di:", 32 | "at_home": "a casa", 33 | "at_geofence": "{GEOFENCE}", 34 | "elsewhere": "{TOWN}", 35 | 36 | "completion_time": "Finito in {DAYS}{LISTSEP1}{HOURS}{LISTSEP2}{MINUTES}", 37 | "_comment": "...da dove vengono estratti i pezzi:", 38 | "listsep": ", ", 39 | "1day": "1 giorno", 40 | "multiday": "{NUM} giorni", 41 | "1hour": "1 ora", 42 | "multihours": "{NUM} ore", 43 | "minutes": "{NUM} minuti", 44 | 45 | "_header": "*******************", 46 | "_section": "Solar Status", 47 | "_header": "*******************", 48 | "solar_current": "I Pannelli stanno producendo", 49 | "solar_sameday": "I Pannelli hanno prodotto", 50 | "solar_prevday": "I Pannelli hanno prodotto", 51 | "today_during": "fino ad oggi", 52 | 53 | "_header": "*******************", 54 | "_section": "Grid Status", 55 | "_header": "*******************", 56 | "grid_transition": "La rete � connessa", 57 | "grid_disconnected": "La rete � disconnessa", 58 | "grid_supply": "La rete sta fornendo", 59 | "grid_receive": "La rete sta ricevendo", 60 | "grid_idle": "La rete � inattivae", 61 | "stormwatch": "Allarme", 62 | "import_today": "importata oggi", 63 | "export_today": "esportata oggi", 64 | 65 | "_header": "*******************", 66 | "_section": "House Status", 67 | "_header": "*******************", 68 | "house_consuming": "La casa sta consumando", 69 | 70 | "_header": "*******************", 71 | "_section": "Battery status", 72 | "_header": "*******************", 73 | "battery_supply": "Forniscono", 74 | "battery_charging": "Ricarica", 75 | "battery_standby": "Standby", 76 | "selfpowered": "Autoalimentato", 77 | 78 | "_header": "*******************", 79 | "_section": "Graph axis", 80 | "_header": "*******************", 81 | "powerline_label": "Potenza A / Da (kW)", 82 | "energybar_label": "Energia A / Da (kWh)", 83 | 84 | "_header": "*******************", 85 | "_section": "Authentication Page", 86 | "_header": "*******************", 87 | "authTitle": "Accesso alla API Tesla", 88 | "authHeader": "Accesso alla API Tesla per MMM-Powerwall", 89 | "username": "Utenza", 90 | "password": "Password", 91 | "mfa": "Chiave MFA, se abilitata", 92 | "ip": "Powerwall IP", 93 | "fetch": "Accesso", 94 | "invalidmfa": "Chiave MFA errata", 95 | "needusername": "Utente � richiesto", 96 | "needpassword": "Password � richiesta", 97 | "invalidpassword": "Password errata", 98 | "needmfa": "chiave MFA � richiesta", 99 | "authNeeded": "Vai a /MMM-Powerwall/auth per l'accesso" 100 | } 101 | -------------------------------------------------------------------------------- /scratch.txt: -------------------------------------------------------------------------------- 1 | https://owner-api.teslamotors.com/api/1/energy_sites/2609804/history?kind=power 2 | 3 | response.time_series: [ 4 | { 5 | "timestamp": "2020-05-20T00:00:00-04:00", 6 | "solar_power": 0, 7 | "battery_power": 0, 8 | "grid_power": 2288.938990783691, 9 | "grid_services_power": 0, 10 | "generator_power": 0 11 | }, 12 | ...] 13 | Would need to integrate charger power series as well. 14 | 15 | 16 | https://owner-api.teslamotors.com/api/1/energy_sites/2609804/history?kind=self_consumption&period=day 17 | 18 | "response": { 19 | "period": "day", 20 | "time_series": [ 21 | { 22 | "timestamp": "2020-05-20T00:00:00-04:00", 23 | "solar": 42.80884879901176, 24 | "battery": 19.680123722678967 25 | }, 26 | { 27 | "timestamp": "2020-05-21T00:00:00-04:00", 28 | "solar": 23.332183431052638, 29 | "battery": 5.7802283820363245 30 | } 31 | ] 32 | } 33 | 34 | https://owner-api.teslamotors.com/api/1/energy_sites/2609804/history?period=day&kind=energy 35 | { 36 | "response": { 37 | "serial_number": "1118431-00-L--TG119293000MAK", 38 | "period": "day", 39 | "time_series": [ 40 | { 41 | "timestamp": "2020-05-20T01:00:00-04:00", 42 | "solar_energy_exported": 17664.81138909422, 43 | "generator_energy_exported": 0, 44 | "grid_energy_imported": 9472.999722677516, 45 | "grid_services_energy_imported": 0, 46 | "grid_services_energy_exported": 0, 47 | "grid_energy_exported_from_solar": 143.90483539667912, 48 | "grid_energy_exported_from_generator": 0, 49 | "grid_energy_exported_from_battery": 0, 50 | "battery_energy_exported": 4970, 51 | "battery_energy_imported_from_grid": 0, 52 | "battery_energy_imported_from_solar": 6710, 53 | "battery_energy_imported_from_generator": 0, 54 | "consumer_energy_imported_from_grid": 9472.999722677516, 55 | "consumer_energy_imported_from_solar": 10810.90655369754, 56 | "consumer_energy_imported_from_battery": 4970, 57 | "consumer_energy_imported_from_generator": 0 58 | }, 59 | { 60 | "timestamp": "2020-05-21T01:00:00-04:00", 61 | "solar_energy_exported": 3742.946944481693, 62 | "generator_energy_exported": 0, 63 | "grid_energy_imported": 6747.5805556408595, 64 | "grid_services_energy_imported": 0, 65 | "grid_services_energy_exported": 0, 66 | "grid_energy_exported_from_solar": 31.748638711404055, 67 | "grid_energy_exported_from_generator": 0, 68 | "grid_energy_exported_from_battery": 0, 69 | "battery_energy_exported": 590, 70 | "battery_energy_imported_from_grid": 0, 71 | "battery_energy_imported_from_solar": 1170, 72 | "battery_energy_imported_from_generator": 0, 73 | "consumer_energy_imported_from_grid": 6747.5805556408595, 74 | "consumer_energy_imported_from_solar": 2541.198305770289, 75 | "consumer_energy_imported_from_battery": 590, 76 | "consumer_energy_imported_from_generator": 0 77 | } 78 | ] 79 | } 80 | } 81 | 82 | https://owner-api.teslamotors.com/api/1/energy_sites/2609804/history?end_date=2020-05-03T01:00:00-04:00&kind=self_consumption&period=week 83 | 84 | { 85 | "response": { 86 | "period": "week", 87 | "time_series": [ 88 | { 89 | "timestamp": "2020-05-14T00:00:00-04:00", 90 | "solar": 37.93426179409109, 91 | "battery": 28.52067850947364 92 | } 93 | ] 94 | } 95 | } 96 | 97 | Parameters: 98 | # period # 99 | Level of aggregation; can be "day", "week", "month", "year", "lifetime" 100 | Default is "day" for power/energy; no default for self_consumption (request fails) 101 | 102 | # kind # 103 | Values are "power", "energy", "backup", or "self_consumption" 104 | kind=power is only available for period=day 105 | 106 | # end_date # 107 | UTC Timestamp (2020-01-03T02%3A29%3A20.866Z) 108 | Defaults to providing yesterday and today if not supplied 109 | 110 | Doesn't appear to be respected for kind=power; not sure why. 111 | 112 | 113 | ###################### 114 | 115 | Vehicle images: 116 | 117 | https://static-assets.tesla.com/v1/compositor/?model=m3&view=STUD_3QTR&size=1440&options=DV4W,IN3PW,COL3-PPMR,W38B,MT303&bkba_opt=1&context=design_studio_2 118 | DV4W- AWD 119 | IN3PW- Black interior (incorrect, but sufficient for picture) 120 | COL3-PPMR- Exterior (multicoat - red) 121 | COL2-PPSB- Exterior (metallic - blue) 122 | 123 | W38B- Aero wheels 124 | MT303- LR AWD 125 | https://static-assets.tesla.com/v1/compositor/?model=ms&view=STUD_3QTR&size=1440&options=MI01,COL3-PPSW,W38B&bkba_opt=1&context=design_studio_2 126 | (wheels aren't right) 127 | 128 | https://static-assets.tesla.com/v1/compositor/?model=mx&view=STUD_3QTR&size=1440&options=APPF,CH04,DRLH,FG02,PI00,COL2-PMSS,RFPX,X021,WT20&bkba_opt=1&context=design_studio_2 129 | 130 | Can no longer get options codes from API, but might be able to translate a sufficient set from data_request/vehicle_config values (model, color, wheel). 131 | 132 | ps.json regen: node_modules/pseudoloc-js/bin/pseudoloc -r translations/en.json -w translations/ps.json -S '{' -E '}' -e 0.3 -------------------------------------------------------------------------------- /MMM-Powerwall.css: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * MMM-Powerwall 4 | * 5 | * Mike Bishop 6 | * MIT Licensed. 7 | * 8 | * Custom here your css module 9 | * 10 | */ 11 | .module.MMM-Powerwall:not(.module-content) { 12 | background-color: rgba(0,0,0,0); 13 | } 14 | 15 | .MMM-Powerwall.flexbox { 16 | display: flex; 17 | flex-wrap: nowrap; 18 | justify-content: space-evenly; 19 | } 20 | 21 | .MMM-Powerwall.rflexbox { 22 | display: flex; 23 | flex-wrap: wrap-reverse; 24 | flex-direction: row-reverse; 25 | justify-content: space-evenly; 26 | } 27 | 28 | .MMM-Powerwall.vflexbox { 29 | display: flex; 30 | flex-direction: column; 31 | flex-wrap: nowrap; 32 | justify-content: space-evenly; 33 | height: 100%; 34 | max-height: 250px; 35 | } 36 | 37 | .MMM-Powerwall.flexitem { 38 | margin: 10px; 39 | height: 250px; 40 | width: 430px; 41 | backface-visibility: hidden; 42 | } 43 | 44 | .MMM-Powerwall .spaced { 45 | margin: auto; 46 | } 47 | 48 | .MMM-Powerwall[class*="grid-"] { 49 | display: grid; 50 | grid-template-rows: auto; 51 | } 52 | 53 | .MMM-Powerwall.grid-left { 54 | grid-template-columns: minmax(min-content,40%) auto; 55 | } 56 | 57 | .MMM-Powerwall.grid-right { 58 | grid-template-columns: auto minmax(min-content,40%); 59 | } 60 | 61 | .scene { 62 | margin: 0; 63 | position: relative; 64 | width: 150px; 65 | height: 240px; 66 | perspective: 600px; 67 | } 68 | 69 | .MMM-Powerwall .text { 70 | transition: all 250ms; 71 | text-align: center; 72 | } 73 | 74 | .cube__face { 75 | position: absolute; 76 | border: 2px solid black; 77 | line-height: 200px; 78 | font-size: 40px; 79 | font-weight: bold; 80 | color: black; 81 | text-align: center; 82 | } 83 | 84 | .cube { 85 | width: 100%; 86 | height: 100%; 87 | position: relative; 88 | transform-style: preserve-3d; 89 | transform: translateZ(-100px) rotateY(-35deg); 90 | margin: -10px 0px; 91 | } 92 | 93 | .cube__face { 94 | backface-visibility: hidden; 95 | height: 240px; 96 | } 97 | 98 | .cube__face--front { 99 | background: ghostwhite; 100 | width: 150px; 101 | transform: rotateY( 0deg) translateZ(15px); 102 | } 103 | 104 | .cube__face--right { 105 | background: black; 106 | transform: rotateY( 90deg) translateZ(137px); 107 | width: 30px; 108 | } 109 | 110 | .meter-vertical { 111 | width:80%; 112 | position: absolute; 113 | bottom: 0; 114 | transition: height 1s; 115 | } 116 | 117 | .meter-level { 118 | border-bottom: 2px solid white; 119 | width: 80%; 120 | position: absolute; 121 | display: none; 122 | transition: 1s ease bottom; 123 | } 124 | 125 | .meter-horizontal { 126 | height:97%; 127 | margin: 1px; 128 | transition: width 1s; 129 | float: left 130 | } 131 | 132 | .cbattery:after { 133 | border: 2px solid darkgray; 134 | background: darkgray; 135 | content: ""; 136 | display: block; 137 | height: 16px; 138 | position: absolute; 139 | right: -10px; 140 | top: 29px; 141 | width: 6px; 142 | } 143 | 144 | .cbattery { 145 | border: 2px solid darkgray; 146 | background: black; 147 | height: 80px; 148 | margin-left: auto; 149 | margin-right: auto; 150 | position: relative; 151 | width: 150px; 152 | text-align: center; 153 | border-radius: 5px; 154 | display: flex; 155 | align-items: flex-start; 156 | } 157 | 158 | .MMM-Powerwall.col1 { 159 | grid-column: 1; 160 | } 161 | 162 | .MMM-Powerwall.col2 { 163 | grid-column: 2; 164 | } 165 | 166 | .MMM-Powerwall .solar { 167 | color: gold 168 | } 169 | 170 | .MMM-Powerwall .battery { 171 | background: #0BC60B; 172 | } 173 | 174 | .MMM-Powerwall .battery-cold { 175 | background: lightskyblue; 176 | } 177 | 178 | .MMM-Powerwall .battery-warn { 179 | background: #f5c71a; 180 | } 181 | 182 | .MMM-Powerwall .battery-critical { 183 | background: red; 184 | } 185 | 186 | .MMM-Powerwall .grid { 187 | color: #CACECF 188 | } 189 | 190 | .MMM-Powerwall .house { 191 | color: #09A9E6 192 | } 193 | 194 | .MMM-Powerwall .car { 195 | color: #B91413; 196 | } 197 | 198 | .MMM-Powerwall .big { 199 | font-size: 45px; 200 | line-height: 50px; 201 | } 202 | 203 | .MMM-Powerwall .overlay { 204 | position: absolute; 205 | top: 50%; 206 | left: 50%; 207 | transform: translate(-50%, -50%); 208 | text-align: center; 209 | white-space: nowrap; 210 | } 211 | 212 | .MMM-Powerwall .grid-grid { 213 | position: relative; 214 | display: grid; 215 | grid-template-columns: 50% 50%; 216 | grid-template-rows: 25% 45% 30%; 217 | grid-template-areas: 218 | "icon direction" 219 | "icon number" 220 | "import export"; 221 | gap: 1%; 222 | place-items: center; 223 | height: 100%; 224 | } 225 | 226 | .MMM-Powerwall .graph-container { 227 | position: relative; 228 | height: 250px; 229 | max-width: 280px; 230 | max-height: 250px; 231 | } 232 | 233 | .MMM-Powerwall .big-graph-container { 234 | max-height: 250px; 235 | height: 250px; 236 | position: relative; 237 | max-width: 425px; 238 | } 239 | 240 | .MMM-Powerwall .no-overflow { 241 | overflow: hidden; 242 | white-space: nowrap; 243 | } 244 | 245 | .MMM-Powerwall .border { 246 | border-style: dashed; 247 | border-width: thick; 248 | margin: -5px; 249 | border-radius: 10px; 250 | } 251 | 252 | .MMM-Powerwall .storm-watch { 253 | color: #09A9E6; 254 | margin-top: auto; 255 | } 256 | 257 | .MMM-Powerwall .grid-direction { 258 | grid-area: direction; 259 | align-self: flex-end; 260 | } 261 | 262 | .MMM-Powerwall .grid-number { 263 | grid-area:number; 264 | } 265 | 266 | .MMM-Powerwall .grid-import { 267 | grid-area:import; 268 | } 269 | 270 | .MMM-Powerwall .grid-export { 271 | grid-area:export; 272 | } 273 | 274 | .MMM-Powerwall .grid-error { 275 | color: red; 276 | font-size: 30px; 277 | grid-area: 1 / 2 / 3 / 4; 278 | margin: auto; 279 | } 280 | -------------------------------------------------------------------------------- /MMM-Powerwall.njk: -------------------------------------------------------------------------------- 1 |
2 | {% for graph in graphs %} 3 |
4 | {% if graph == "SolarProduction" -%} 5 |
6 |
7 |
8 |
9 |
{{translations.solar_current}}
10 |
11 | 12 | 13 |
14 |
15 | 16 |
17 |
18 |
19 | 26 |
27 | {%- elif graph == "HouseConsumption" -%} 28 |
29 |
30 | 31 |
32 |
33 |
{{translations.house_consuming}}
34 |
35 | 36 | 37 |
38 |
39 | {%- elif graph == "Grid" -%} 40 |
41 | 42 |
43 | 44 |
45 |
46 |
47 |
48 | 49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | {%- elif graph == "PowerwallSelfPowered" -%} 60 |
61 |
62 |
63 |
64 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 | 74 |
75 |
76 | {{translations.selfpowered}}

77 | 78 |
79 |
80 |
81 | {%- elif graph == "CarCharging" -%} 82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 | 92 |
93 |
94 |
95 |
96 |
97 |
98 | 99 |
100 |
101 | {%- elif graph == "EnergyBar" -%} 102 |
103 | 104 |
105 | {%- elif graph == "PowerLine" -%} 106 |
107 | 108 |
109 | {%- elif graph == "AuthNeeded" -%} 110 |

{{translations.authNeeded}}

111 | {% for account in accountsNeedAuth %} 112 |

{{account}}

113 | {% endfor %} 114 | {%- else -%} 115 | {{translations.unknown}} 116 | {%- endif -%} 117 |
118 | {%- endfor %} 119 |
120 | 121 | -------------------------------------------------------------------------------- /powerwall.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | var axios = require('axios'); 3 | const Https = require("https"); 4 | const unauthenticated_agent = new Https.Agent({ 5 | rejectUnauthorized: false, 6 | keepAlive: true, 7 | }); 8 | var toughcookie = require('tough-cookie'); 9 | var events = require('events'); 10 | 11 | module.exports = { 12 | Powerwall: class extends events.EventEmitter { 13 | constructor(host) { 14 | super(); 15 | this.urlBase = "https://" + host; 16 | this.jar = new toughcookie.CookieJar(); 17 | this.http = axios.create({ 18 | httpsAgent: unauthenticated_agent, 19 | timeout: 5000, 20 | }); 21 | this.authenticated = false; 22 | this.http.interceptors.request.use(config => { 23 | this.jar.getCookies(config.url, {}, (err, cookies) => { 24 | if (err) 25 | return; 26 | config.headers.cookie = cookies.join('; '); 27 | }); 28 | return config; 29 | }); 30 | this.lastUpdate = 0; 31 | this.history = {}; 32 | this.password = null; 33 | this.cookieTimeout = 0; 34 | this.loginTask = null; 35 | this.delayTask = Promise.resolve(); 36 | this.updateTask = null; 37 | } 38 | 39 | login(password) { 40 | let self = this; 41 | if (!this.loginTask || this.password != password) { 42 | this.loginTask = this.loginInner(password).then( 43 | () => { 44 | self.loginTask = null; 45 | self.delayTask = new Promise(resolve => setTimeout(resolve, 30000)); 46 | } 47 | ); 48 | } 49 | else { 50 | this.emit("debug", "Login already in progress; deferring to that attempt"); 51 | } 52 | return this.loginTask; 53 | } 54 | 55 | async loginInner(password) { 56 | let res; 57 | await this.delayTask; 58 | try { 59 | this.emit("debug", "Beginning login attempt"); 60 | res = await this.http.post(this.urlBase + '/api/login/Basic', 61 | { 62 | username: "customer", 63 | password: password, 64 | "force_sm_off": false 65 | }, 66 | { 67 | headers: { 68 | 'Content-Type': 'application/json' 69 | } 70 | } 71 | ); 72 | } 73 | catch (e) { 74 | this.authenticated = false; 75 | this.password = null; 76 | if (e.response && e.response.status === 429) { 77 | this.delayTask = new Promise(resolve => setTimeout(resolve, 30000)); 78 | return await this.loginInner(password); 79 | } 80 | return this.emit('error', 'login failed: ' + e.toString()); 81 | } 82 | if (res.status === 200) { 83 | let foundCookie = false; 84 | if (res.headers['set-cookie'] instanceof Array) { 85 | res.headers['set-cookie'].forEach(c => { 86 | this.jar.setCookie(toughcookie.Cookie.parse(c), res.config.url, () => { }); 87 | foundCookie = true; 88 | }); 89 | } 90 | else { 91 | this.emit("debug", "Login response Set-Cookie header is a " + typeof res.headers["set-cookie"]); 92 | } 93 | if (foundCookie) { 94 | this.authenticated = true; 95 | this.password = password; 96 | this.cookieTimeout = Date.now() + (60 * 60 * 1000); 97 | return this.emit('login'); 98 | } 99 | } 100 | 101 | this.password = null; 102 | return this.emit("error", "login failed; " + JSON.stringify(res.headers)); 103 | } 104 | 105 | update(interval) { 106 | if (!this.updateTask) { 107 | this.updateTask = this.updateInner(interval).then( 108 | () => { 109 | this.updateTask = null; 110 | } 111 | ); 112 | } 113 | else { 114 | this.emit("debug", "Update already in progress; deferring to that attempt"); 115 | } 116 | return this.updateTask; 117 | } 118 | 119 | async updateInner(interval) { 120 | if (!this.authenticated && this.password) { 121 | await this.login(this.password); 122 | } 123 | if (!this.authenticated) { 124 | return this.emit('error', 'not authenticated'); 125 | } 126 | 127 | let now = Date.now(); 128 | if (now > this.cookieTimeout && this.password) { 129 | this.login(this.password) 130 | } 131 | 132 | const requestTypes = [ 133 | ["aggregates", this.urlBase + '/api/meters/aggregates', result => result.data], 134 | ["soe", this.urlBase + "/api/system_status/soe", result => (result.data.percentage - 5) / .95], 135 | ["grid", this.urlBase + "/api/system_status/grid_status", result => result.data.grid_status], 136 | ["operation", this.urlBase + "/api/operation", result => result.data] 137 | ]; 138 | 139 | if (now - this.lastUpdate < interval) { 140 | this.emit("debug", "Using cached data"); 141 | for (const [name, url, mapping] of requestTypes) { 142 | this.emit(name, this.history[name]); 143 | } 144 | return; 145 | } 146 | 147 | let requests = {}; 148 | for (const [name, url, mapping] of requestTypes) { 149 | try { 150 | this.emit("debug", "Requesting " + name); 151 | requests[name] = this.http.get(url); 152 | } 153 | catch (e) { 154 | return this.emit("error", "requests failed to initialize"); 155 | } 156 | } 157 | 158 | let needAuth = false; 159 | for (const [name, url, mapping] of requestTypes) { 160 | try { 161 | let result = await requests[name]; 162 | let data = mapping(result); 163 | this.emit(name, data); 164 | this.history[name] = data; 165 | } 166 | catch (e) { 167 | if (e.response && [401, 403].includes(e.response.status) && this.password) { 168 | needAuth = true; 169 | this.authenticated = false; 170 | } 171 | else { 172 | this.emit("error", name + " failed: " + e.toString()); 173 | } 174 | } 175 | } 176 | 177 | if (needAuth) { 178 | this.emit("debug", "Tokens rejected; need to log in again"); 179 | await this.login(this.password); 180 | await this.updateInner(interval); 181 | } 182 | } 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MMM-Powerwall 2 | 3 | This is a module for the 4 | [MagicMirror²](https://github.com/MichMich/MagicMirror/). It displays data from 5 | your [Tesla Powerwall](https://www.tesla.com/powerwall) on your Magic Mirror, 6 | optionally including car charging data pulled from your 7 | [TWCManager](https://github.com/ngardiner/TWCManager/) (v1.2.0 or later). 8 | 9 | ![](images/InUse.jpg) 10 | 11 | If using all the graphs, this works best in one of the full-width positions 12 | (`upper_third`, `middle_center`, `lower_third`); individual graphs can work 13 | nicely in other positions. 14 | 15 | ## Using the module 16 | 17 | To use this module, clone this repo into ~/MagicMirror/modules, run `npm 18 | install` to get dependencies, and add the following configuration block to the 19 | modules array in the `config/config.js` file: 20 | ```js 21 | var config = { 22 | modules: [ 23 | { 24 | module: 'MMM-Powerwall', 25 | position: 'lower_third', 26 | config: { 27 | // See below for configurable options 28 | } 29 | } 30 | ] 31 | } 32 | ``` 33 | You will need to restart the MagicMirror process to load the node_helper. 34 | 35 | Authentication depends on Python, due to issues with Node and Raspberry Pi. 36 | Make sure you have Python and requests installed: `sudo apt install python3 python3-requests` 37 | 38 | **Do not forget to run `npm install` after updating the module; new dependencies 39 | are being introduced because of Tesla's new authentication model.** 40 | 41 | ## Configuration options 42 | 43 | | Option | Description 44 | |---------------------- |----------- 45 | | `powerwallIP` | *Required* IP address of the Powerwall endpoint to query 46 | | `powerwallPassword` | *Optional* Password for local Powerwall endpoint 47 | | `teslaAPIUsername` | *Recommended* Username for your Tesla account 48 | | `siteID` | *Optional* if your Tesla account has exactly one energy site; required if multiple are present 49 | | `twcManagerIP` | *Optional* IP address or hostname of TWCManager instance; if omitted, Tesla API data will be used 50 | | `twcManagerPort` | *Optional* port of TWCManager's web interface; default is `8080` 51 | | `graphs` | *Optional* Array of tiles to show. Possible values are described below; default is all 52 | | `localUpdateInterval` | *Optional* How often (in milliseconds) to poll local endpoints (Powerwall and TWCManager)
Default 10000 milliseconds (10 seconds) 53 | | `cloudUpdateInterval` | *Optional* How often (in milliseconds) to poll Tesla API
Default 300000 milliseconds (five minutes) 54 | | `home` | *Optional* Coordinates (`[lat, lon]`) of your home; used to indicate when car is at home and to get sunrise/sunset times 55 | | `debug` | *Optional* Enables additional debug output to the browser tools Console and to stderr on the MM, useful for troubleshooting 56 | | `powerlineClip` | *Optional* Controls clipping behavior on PowerLine graph; see below for values 57 | | `teslamate` | *Optional* See below 58 | 59 | ### Graphs 60 | 61 | This module implements several different graphs. Currently, these are: 62 | 63 | - CarCharging
![](images/CarCharging.png) 64 | - Grid
![](images/Grid.png) 65 | - PowerwallSelfPowered
![](images/PowerwallSelfPowered.png) 66 | - SolarProduction
![](images/SolarProduction.png) 67 | - HouseConsumption
![](images/HouseConsumption.png) 68 | - EnergyBar
![](images/EnergyBar.png) 69 | - PowerLine
![](images/PowerLine.png) 70 | 71 | By default, all are displayed. However, as needed by your layout, you can 72 | instantiate multiple instances of this module, each displaying different graphs 73 | or even targeting different Powerwall systems. All data is requested, cached, 74 | and distributed by the node_helper, so multiple instances referencing the same 75 | target will still update simultaneously and will not increase the volume of 76 | requests made to either local or cloud endpoints. 77 | 78 | #### PowerLine clipping 79 | 80 | The PowerLine graph automatically scales to your daily usage; extreme peaks can 81 | throw off this scaling. It can clip the extreme values to enable better view of 82 | the rest of the graph. 83 | 84 | Possible values: 85 | 86 | - false - do not clip 87 | - null/default - clip at most one category 88 | - true - clip extremes 89 | 90 | ### Authentication 91 | 92 | This module relies on being able to access your Powerwall both locally and via 93 | the Tesla API. On older firmware versions, the local endpoint interactions 94 | required no authentication; this changed in 20.49.0. There are two 95 | authentication paths: 96 | 97 | - **Sign in via the module. (Recommended)** After installing the module, visit 98 | `/MMM-Powerwall/auth` on your MagicMirror HTTP port, e.g. 99 | `http://192.168.0.52:8080/MMM-Powerwall/auth`. You can provide tokens for the 100 | Tesla API (links to apps which can help are on that page) and/or the password 101 | for your Powerwall's local API. The module will cache tokens for the Tesla 102 | API, but needs to retain the actual password for the local API. It is NOT 103 | RECOMMENDED that this be the same password used for your Tesla account. If 104 | signing in this way, you only need to include your username and Powerwall IP 105 | in the module configuration. 106 | 107 | - **Include your passwords in the module configuration. (Local only)** 108 | Note that the client downloads `config.js` during load, so anything in your 109 | config file passes unencrypted over the network (unless you've set up TLS). 110 | This method also does not work with the Tesla API. 111 | 112 | The module will generate `tokens.json` (for the Tesla API) and `localpw.json` 113 | (for the local Powerwall) after the first successful load with the password(s), 114 | so you can remove the password from your `config.js` file afterward if desired. 115 | 116 | Neither the password nor the tokens are sent anywhere except from the 117 | node_helper to the Tesla API. Feel free to verify this in the code. 118 | 119 | ### Teslamate Integration 120 | 121 | If you have installed [Teslamate](https://github.com/adriankumpf/teslamate), it 122 | exposes an MQTT server with information about monitored Tesla vehicles. To 123 | make the best use of this integration: 124 | 125 | - Your mosquitto instance should have the options `persistence true` and a 126 | `persistence_location` configured. 127 | - Either set `allow_anonymous true` or provide a username and password in options. 128 | 129 | The `teslamate` configuration option is an object with the following fields: 130 | 131 | | Option | Description 132 | |-------------- |----------- 133 | | `url` | *Required* URL to access the Mosquitto server 134 | | `namespace` | *Optional* If you have configured a custom namespace (with MQTT_NAMESPACE), supply it here 135 | | `options` | *Optional* If you need to pass any [options](https://github.com/mqttjs/MQTT.js/#client) to the MQTT client, supply them here. 136 | 137 | The Teslamate connection will be associated with the Tesla account supplied 138 | in the same config. 139 | 140 | ## Dependencies and Acknowledgements 141 | 142 | This module relies on the following APIs: 143 | 144 | - The Tesla Owner's API, picked apart at https://www.teslaapi.io/ 145 | - The Tesla Compositor API, picked apart at https://teslaownersonline.com/threads/teslas-image-compositor.7089/ 146 | - The Powerwall local API, picked apart at https://github.com/vloschiavo/powerwall2 147 | - The TWCManager local API, documented at https://github.com/ngardiner/TWCManager/blob/v1.2.0/docs/modules/Control_HTTP.md 148 | - The Sunrise Sunset API, documented at https://sunrise-sunset.org/api 149 | - The ArcGIS Reverse Geocode API, documented at https://developers.arcgis.com/rest/geocode/api-reference/geocoding-reverse-geocode.htm 150 | - Powerline icon made by [Freepik](https://www.flaticon.com/authors/freepik) from https://www.flaticon.com/ 151 | 152 | In addition to any commiters to the repo, the following have helped figure certain pieces out: 153 | 154 | - @ngardiner's work on TWCManager is amazing, and the car charging could not be tracked without it 155 | - @Kemmey provided initial code for interacting with the compositor 156 | - Access to Tesla's v3 authentication endpoint adapted from [enode-engineering/tesla-oauth2](https://github.com/enode-engineering/tesla-oauth2) 157 | -------------------------------------------------------------------------------- /node_helper.js: -------------------------------------------------------------------------------- 1 | /* Magic Mirror 2 | * Node Helper: MMM-Powerwall 3 | * 4 | * By Mike Bishop 5 | * MIT Licensed. 6 | */ 7 | 8 | const NodeHelper = require("node_helper"); 9 | const fs = require("fs").promises; 10 | const fetch = (...args) => import('node-fetch').then(({ default: fetch }) => fetch(...args)); 11 | const powerwall = require("./powerwall"); 12 | const path = require("path"); 13 | const nunjucks = require("./../../node_modules/nunjucks"); 14 | const { check, validationResult, matchedData } = require('express-validator'); 15 | const bodyParser = require('./../../node_modules/body-parser'); 16 | const spawn = require("await-spawn"); 17 | const mqtt = require("async-mqtt"); 18 | const mutex = require("async-mutex").Mutex; 19 | 20 | const MI_KM_FACTOR = 1.609344; 21 | 22 | module.exports = NodeHelper.create({ 23 | 24 | start: async function () { 25 | this.messageMutex = {}; 26 | this.twcStatus = {}; 27 | this.twcVINs = {}; 28 | this.chargeHistory = {}; 29 | this.teslaApiAccounts = {}; 30 | this.powerwallAccounts = {}; 31 | this.energy = {}; 32 | this.backup = {}; 33 | this.selfConsumption = {}; 34 | this.storm = {}; 35 | this.vehicles = {}; 36 | this.vehicleData = {}; 37 | this.mqttClients = {}; 38 | this.powerHistory = {}; 39 | this.filenames = []; 40 | this.lastUpdate = 0; 41 | this.debug = false; 42 | this.thisConfigs = []; 43 | this.tokenFile = path.resolve(__dirname + "/tokens.json"); 44 | this.localPwFile = path.resolve(__dirname + "/localpw.json"); 45 | this.template = null; 46 | 47 | await this.loadTranslation("en"); 48 | await this.combineConfig(); 49 | await this.configureAccounts(); 50 | await this.createAuthPage(); 51 | }, 52 | 53 | createAuthPage: async function () { 54 | this.expressApp.get("/MMM-Powerwall/auth", (req, res) => { 55 | res.send( 56 | nunjucks.render(__dirname + "/auth.njk", { 57 | translations: this.translation, 58 | errors: {}, 59 | data: {}, 60 | configUsers: Object.keys(this.teslaApiAccounts), 61 | configIPs: Object.keys(this.powerwallAccounts), 62 | }) 63 | ); 64 | }); 65 | 66 | this.expressApp.use(bodyParser.urlencoded({ extended: false })); 67 | this.expressApp.post("/MMM-Powerwall/auth", [ 68 | check("username") 69 | .isEmail() 70 | .withMessage(this.translation.needusername) 71 | .trim(), 72 | check("token") 73 | .notEmpty() 74 | .withMessage(this.translation.needtoken) 75 | .trim(), 76 | check("refresh_token") 77 | .optional({ 78 | checkFalsy: true 79 | }) 80 | .trim() 81 | ], async (req, res) => { 82 | var errors = validationResult(req).mapped(); 83 | 84 | if (Object.keys(errors).length == 0) { 85 | let username = req.body["username"]; 86 | if (req.body["refresh_token"]) { 87 | try { 88 | // If we have a refresh token, refresh immediately 89 | // so we know the validity period. 90 | this.teslaApiAccounts[username] = await this.doTeslaApiTokenRefresh(req.body["refresh_token"]); 91 | } 92 | catch { 93 | errors["refresh_token"] = { 94 | value: "", 95 | msg: this.translation.invalidtoken, 96 | param: "refresh_token", 97 | location: "refresh_token" 98 | }; 99 | } 100 | } 101 | else { 102 | // Current token only 103 | this.teslaApiAccounts[username] = { 104 | access_token: req.body["token"], 105 | token_type: "bearer", 106 | expires_in: 3888000, 107 | created_at: Date.now() / 1000 108 | }; 109 | url = "https://owner-api.teslamotors.com/api/1/products"; 110 | 111 | this.log("Checking token validity"); 112 | let response = await this.doTeslaApi(url, username); 113 | if (!Array.isArray(response)) { 114 | errors["token"] = { 115 | value: "", 116 | msg: this.translation.invalidtoken, 117 | param: "token", 118 | location: "token" 119 | }; 120 | 121 | } 122 | } 123 | } 124 | if (Object.keys(errors).length == 0) { 125 | // Successfully got token(s) 126 | await fs.writeFile(this.tokenFile, JSON.stringify(this.teslaApiAccounts)); 127 | return res.redirect("../.."); 128 | } 129 | else { 130 | return res.send( 131 | nunjucks.render(__dirname + "/auth.njk", { 132 | translations: this.translation, 133 | errors: errors, 134 | data: req.body, 135 | configUsers: Object.keys(this.teslaApiAccounts), 136 | configIPs: Object.keys(this.powerwallAccounts), 137 | })); 138 | } 139 | }); 140 | 141 | this.expressApp.post("/MMM-Powerwall/authLocal", [ 142 | check("password") 143 | .notEmpty() 144 | .withMessage(this.translation.needpassword) 145 | .trim() 146 | ], async (req, res) => { 147 | var errors = validationResult(req).mapped(); 148 | 149 | if (Object.keys(errors).length == 0) { 150 | let thisPowerwall = this.powerwallAccounts[req.body["ip"]]; 151 | thisPowerwall. 152 | once("error", message => { 153 | errors.password = { 154 | value: "", 155 | msg: message, 156 | param: "password", 157 | location: "body" 158 | }; 159 | }). 160 | once("login", async () => { 161 | let fileContents = {}; 162 | try { 163 | fileContents = JSON.parse( 164 | await fs.readFile(this.localPwFile) 165 | ); 166 | } 167 | catch { } 168 | fileContents[req.body["ip"]] = req.body["password"]; 169 | try { 170 | await fs.writeFile(this.localPwFile, JSON.stringify(fileContents)); 171 | } 172 | catch { } 173 | }); 174 | await thisPowerwall.login(req.body["password"]); 175 | } 176 | if (Object.keys(errors).length == 0) { 177 | return res.redirect("../.."); 178 | } 179 | return res.send( 180 | nunjucks.render(__dirname + "/auth.njk", { 181 | translations: this.translation, 182 | errors: errors, 183 | data: req.body, 184 | configUsers: Object.keys(this.teslaApiAccounts), 185 | configIPs: Object.keys(this.powerwallAccounts), 186 | }) 187 | ); 188 | }); 189 | 190 | }, 191 | 192 | combineConfig: async function () { 193 | // function copied from MichMich (MIT) 194 | var defaults = require(__dirname + "/../../js/defaults.js"); 195 | var configFilename = path.resolve(__dirname + "/../../config/config.js"); 196 | if (typeof (global.configuration_file) !== "undefined") { 197 | configFilename = global.configuration_file; 198 | } 199 | 200 | try { 201 | var c = require(configFilename); 202 | var config = Object.assign({}, defaults, c); 203 | this.configOnHd = config; 204 | // Get the configuration for this module. 205 | if ("modules" in this.configOnHd) { 206 | this.thisConfigs = this.configOnHd.modules. 207 | filter(m => "config" in m && "module" in m && m.module === 'MMM-Powerwall'). 208 | map(m => m.config); 209 | } 210 | } catch (e) { 211 | console.error("MMM-Powerwall WARNING! Could not load config file. Starting with default configuration. Error found: " + e); 212 | this.configOnHd = defaults; 213 | } 214 | 215 | this.debug = this.thisConfigs.some(config => config.debug); 216 | this.loadTranslation(this.configOnHd.language); 217 | }, 218 | 219 | loadTranslation: async function (language) { 220 | var self = this; 221 | 222 | try { 223 | self.translation = Object.assign({}, self.translation, JSON.parse( 224 | await fs.readFile( 225 | path.resolve(__dirname + "/translations/" + language + ".json") 226 | ) 227 | )); 228 | } 229 | catch { } 230 | }, 231 | 232 | configureAccounts: async function () { 233 | let fileContents = {}; 234 | try { 235 | fileContents = JSON.parse( 236 | await fs.readFile(this.tokenFile) 237 | ); 238 | } 239 | catch (e) { 240 | } 241 | 242 | if (Object.keys(fileContents).length >= 1) { 243 | this.log("Read Tesla API tokens from file"); 244 | 245 | this.teslaApiAccounts = { 246 | ...this.teslaApiAccounts, 247 | ...fileContents 248 | }; 249 | 250 | this.log(JSON.stringify(this.teslaApiAccounts)); 251 | } 252 | else { 253 | this.log("Token file is empty"); 254 | } 255 | 256 | let self = this; 257 | this.thisConfigs.forEach(async config => { 258 | let username = config.teslaAPIUsername; 259 | let password = config.teslaAPIPassword; 260 | 261 | if (!this.teslaApiAccounts[username]) { 262 | this.teslaApiAccounts[username] = null; 263 | this.log("Missing access tokens for " + username); 264 | } 265 | 266 | // Set up Teslamate MQTT connection associated with account 267 | if (username && !this.mqttClients[username] && config.teslamate) { 268 | let tm = config.teslamate; 269 | let namespace = "teslamate/" + 270 | (tm.namespace ? tm.namespace + "/" : "") + 271 | "cars/"; 272 | this.mqttClients[username] = tm; 273 | try { 274 | var client = await mqtt.connectAsync(tm.url, tm.options); 275 | this.mqttClients[username].client = client; 276 | 277 | let vehicleDetective = {}; 278 | let self = this; 279 | 280 | client.on("message", async (topic, message) => { 281 | if (topic.startsWith(namespace)) { 282 | message = message.toString(); 283 | [id, value] = topic.slice(namespace.length).split('/'); 284 | this.log("Teslamate MQTT: " + [id, value, message].join(", ")); 285 | if (!tm.vehicles) { 286 | tm.vehicles = {} 287 | } 288 | if (!(id in tm.vehicles)) { 289 | if (!vehicleDetective[id]) { 290 | vehicleDetective[id] = {}; 291 | } 292 | vehicleDetective[id][value] = message; 293 | 294 | if (self.vehicles[username] && 295 | vehicleDetective[id].display_name && 296 | vehicleDetective[id].odometer 297 | ) { 298 | // We have enough to try looking 299 | for (let vehicle of self.vehicles[username]) { 300 | if (vehicle.display_name == vehicleDetective[id].display_name && 301 | Math.abs(vehicle.odometer - vehicleDetective[id].odometer) < Math.min(100, .01 * vehicle.odometer) 302 | ) { 303 | for (const val in vehicleDetective[id]) { 304 | this.updateVehicleData(username, vehicle, val, vehicleDetective[id][val]) 305 | } 306 | delete vehicle.accumulator; 307 | tm.vehicles[id] = vehicle; 308 | break; 309 | } 310 | } 311 | } 312 | } 313 | 314 | if (id in tm.vehicles) { 315 | // Get the info needed to update cache 316 | let vehicle = tm.vehicles[id]; 317 | this.updateVehicleData(username, vehicle, value, message); 318 | } 319 | } 320 | }); 321 | 322 | await client.subscribe( 323 | [ 324 | "display_name", 325 | "state", 326 | "geofence", 327 | "latitude", 328 | "longitude", 329 | "shift_state", 330 | "speed", 331 | "odometer", 332 | "battery_level", 333 | "usable_battery_level", 334 | "plugged_in", 335 | "charge_limit_soc", 336 | "charger_voltage", 337 | "charger_actual_current", 338 | "time_to_full_charge", 339 | ].map(topic => namespace + "+/" + topic), 340 | { 341 | rh: 1 342 | } 343 | ); 344 | } 345 | catch (e) { 346 | this.log("Failed to subscribe to Teslamate events;" + e.toString()); 347 | } 348 | } 349 | }); 350 | 351 | await self.doTeslaApiTokenUpdate(); 352 | 353 | for (const username in this.teslaApiAccounts) { 354 | if (this.checkTeslaCredentials(username)) { 355 | if (!this.vehicles[username]) { 356 | // See if there are any cars on the account. 357 | this.vehicles[username] = await this.doTeslaApiGetVehicleList(username); 358 | } 359 | } 360 | } 361 | 362 | // Now do Powerwalls 363 | try { 364 | fileContents = JSON.parse( 365 | await fs.readFile(this.localPwFile) 366 | ); 367 | } 368 | catch (e) { 369 | fileContents = {}; 370 | } 371 | 372 | let changed = false; 373 | for (const config of this.thisConfigs) { 374 | let powerwallIP = config.powerwallIP; 375 | let powerwallPassword = config.powerwallPassword || fileContents[powerwallIP]; 376 | 377 | let thisPowerwall = this.powerwallAccounts[powerwallIP]; 378 | if (!thisPowerwall) { 379 | thisPowerwall = new powerwall.Powerwall(powerwallIP); 380 | thisPowerwall. 381 | on("error", error => { 382 | self.log(powerwallIP + " error: " + error); 383 | if (!thisPowerwall.authenticated) { 384 | self.sendSocketNotification("ReconfigurePowerwall", { 385 | ip: powerwallIP, 386 | }); 387 | } 388 | }). 389 | on("debug", msg => { 390 | self.log(powerwallIP + ": " + msg); 391 | }). 392 | on("login", () => { 393 | this.log("Successfully logged into " + powerwallIP); 394 | self.sendSocketNotification("PowerwallConfigured", { 395 | ip: powerwallIP, 396 | }); 397 | }). 398 | on("aggregates", aggregates => { 399 | self.sendSocketNotification("Aggregates", { 400 | ip: powerwallIP, 401 | aggregates: aggregates 402 | }); 403 | }). 404 | on("soe", soe => { 405 | self.sendSocketNotification("SOE", { 406 | ip: powerwallIP, 407 | soe: soe 408 | }); 409 | }). 410 | on("grid", grid => { 411 | self.sendSocketNotification("GridStatus", { 412 | ip: powerwallIP, 413 | gridStatus: grid 414 | }); 415 | }). 416 | on("operation", operation => { 417 | self.sendSocketNotification("Operation", { 418 | ip: powerwallIP, 419 | mode: operation.real_mode, 420 | reserve: Math.max( 421 | Math.round((operation.backup_reserve_percent - 5) / .95), 422 | 0 423 | ) 424 | }); 425 | }); 426 | this.powerwallAccounts[powerwallIP] = thisPowerwall; 427 | } 428 | 429 | if (!thisPowerwall.authenticated && powerwallPassword) { 430 | await thisPowerwall.login(powerwallPassword); 431 | if (thisPowerwall.authenticated && fileContents[powerwallIP] != powerwallPassword) { 432 | fileContents[powerwallIP] = powerwallPassword; 433 | changed = true; 434 | } 435 | } 436 | 437 | if (!thisPowerwall.authenticated) { 438 | self.sendSocketNotification("ReconfigurePowerwall", { 439 | ip: powerwallIP, 440 | }); 441 | } 442 | } 443 | 444 | if (changed) { 445 | try { 446 | await fs.writeFile(this.localPwFile, JSON.stringify(fileContents)); 447 | } 448 | catch (e) { } 449 | } 450 | }, 451 | 452 | updateVehicleData: function (username, vehicle, mqttTopic, mqttMessage) { 453 | 454 | let cached = this.vehicleData[username][vehicle.id].lastResult 455 | this.vehicleData[username][vehicle.id].lastUpdate = Date.now(); 456 | const transform = { 457 | "plugged_in": (plugged_in) => [ 458 | ["charging_state", plugged_in === "true" ? 459 | (cached.charge_state.time_to_full_charge && 460 | (cached.charge_state.time_to_full_charge > 0) 461 | ) ? 462 | "Charging" : "Not Charging" : 463 | "Disconnected" 464 | ], 465 | ["plugged_in", plugged_in === "true"] 466 | ], 467 | "speed": (speed_in_kph) => [ 468 | ["speed", speed_in_kph / MI_KM_FACTOR], 469 | [speed_in_kph > 0 ? "charging_state" : "skip_me", "Disconnected"] 470 | ], 471 | "state": (state) => [ 472 | ["state", state], 473 | state === "charging" ? 474 | ["charging_state", "Charging"] : 475 | ("plugged_in" in cached.charge_state ? 476 | cached.charge_state.plugged_in : 477 | cached.charge_state.charging_state != "Disconnected") ? 478 | ["charging_state", "Not Charging"] : 479 | ["charging_state", "Disconnected"] 480 | ], 481 | "time_to_full_charge": (time) => [ 482 | ["time_to_full_charge", time], 483 | ["charging_state", 484 | (time && (time > 0)) ? "Charging" : 485 | cached.charge_state.charging_state] 486 | ], 487 | }; 488 | const map = { 489 | "geofence": [], 490 | "state": [], 491 | "latitude": ["drive_state"], 492 | "longitude": ["drive_state"], 493 | "shift_state": ["drive_state"], 494 | "speed": ["drive_state"], 495 | "battery_level": ["charge_state"], 496 | "usable_battery_level": ["charge_state"], 497 | "charge_limit_soc": ["charge_state"], 498 | "charger_voltage": ["charge_state"], 499 | "charger_actual_current": ["charge_state"], 500 | "time_to_full_charge": ["charge_state"], 501 | "charging_state": ["charge_state"], 502 | "plugged_in": ["charge_state"] 503 | }; 504 | 505 | var updates; 506 | if (mqttTopic in transform) { 507 | updates = transform[mqttTopic](mqttMessage); 508 | } 509 | else { 510 | updates = [[mqttTopic, mqttMessage]]; 511 | } 512 | 513 | for (const [topic, message] of updates) { 514 | if (topic in map) { 515 | let path = map[topic]; 516 | let node = cached; 517 | while (path.length > 0 && node) { 518 | node = node[path.shift()]; 519 | } 520 | if (node[topic] != message) { 521 | node[topic] = message; 522 | let self = this; 523 | if (!vehicle.timeout) { 524 | vehicle.timeout = setTimeout(() => { 525 | vehicle.timeout = null; 526 | self.sendVehicleData( 527 | username, vehicle.id, "mqtt", 528 | self.vehicleData[username][vehicle.id].lastResult 529 | ); 530 | }, 1000); 531 | } 532 | } 533 | } 534 | } 535 | }, 536 | 537 | // Override socketNotificationReceived method. 538 | 539 | /* socketNotificationReceived(notification, payload) 540 | * This method is called when a socket notification arrives. 541 | * 542 | * argument notification string - The identifier of the noitication. 543 | * argument payload mixed - The payload of the notification. 544 | */ 545 | socketNotificationReceived: async function (notification, payload) { 546 | let self = this; 547 | if (!this.messageMutex[notification]) { 548 | this.messageMutex[notification] = new mutex(); 549 | } 550 | await this.messageMutex[notification].runExclusive( 551 | async () => await self.socketNotificationReceivedInner(notification, payload) 552 | ); 553 | }, 554 | 555 | socketNotificationReceivedInner: async function (notification, payload) { 556 | const self = this; 557 | 558 | this.log(notification + JSON.stringify(payload)); 559 | if (notification === "Configure-TeslaAPI") { 560 | let username = payload.teslaAPIUsername; 561 | let siteID = payload.siteID; 562 | await this.configureAccounts(); 563 | 564 | if (username && this.checkTeslaCredentials(username)) { 565 | if (!siteID) { 566 | this.log("Attempting to infer siteID"); 567 | siteID = await this.inferSiteID(username); 568 | this.log("Found siteID " + siteID); 569 | } 570 | 571 | let timezone = null; 572 | let siteInfo = await this.doTeslaApiGetSiteInfo(username, siteID); 573 | if (siteInfo) { 574 | timezone = siteInfo.installation_time_zone 575 | } 576 | 577 | this.sendSocketNotification("TeslaAPIConfigured", { 578 | username: username, 579 | siteID: siteID, 580 | timezone: timezone, 581 | vehicles: this.vehicles[username] 582 | }); 583 | 584 | } 585 | } 586 | else if (notification === "UpdateLocal") { 587 | let ip = payload.powerwallIP; 588 | if (ip in this.powerwallAccounts) { 589 | let pwPromise = this.powerwallAccounts[ip].update(payload.updateInterval); 590 | 591 | ip = payload.twcManagerIP; 592 | let port = payload.twcManagerPort; 593 | if (ip) { 594 | this.initializeCache(this.twcStatus, ip); 595 | this.initializeCache(this.twcVINs, ip); 596 | if (this.twcStatus[ip].lastUpdate + (payload.updateInterval || 0) < Date.now()) { 597 | await self.updateTWCManager(ip, port); 598 | } 599 | else { 600 | this.sendSocketNotification("ChargeStatus", { 601 | ip: ip, 602 | status: this.twcStatus[ip].lastResult, 603 | vins: this.twcVINs[ip].lastResult 604 | }); 605 | } 606 | } 607 | await pwPromise; 608 | } 609 | } 610 | else if (notification === "UpdateStormWatch") { 611 | let username = payload.username; 612 | let siteID = payload.siteID; 613 | 614 | if (username && !this.checkTeslaCredentials(username)) { 615 | return; 616 | } 617 | 618 | if (siteID) { 619 | this.initializeCache(this.storm, username, siteID); 620 | } 621 | else { 622 | return; 623 | } 624 | 625 | if (this.storm[username][siteID].lastUpdate + payload.updateInterval < Date.now()) { 626 | await self.doTeslaApiGetStormWatch(username, siteID); 627 | } 628 | else { 629 | this.sendSocketNotification("StormWatch", { 630 | username: username, 631 | siteID: siteID, 632 | storm: this.storm[username][siteID].lastResult.storm_mode_active 633 | }); 634 | } 635 | } 636 | else if (notification === "UpdateEnergy") { 637 | let username = payload.username; 638 | let siteID = payload.siteID; 639 | 640 | if (username && !this.checkTeslaCredentials(username)) { 641 | return; 642 | } 643 | 644 | if (siteID) { 645 | this.initializeCache(this.energy, username, siteID); 646 | } 647 | else { 648 | return; 649 | } 650 | 651 | if (this.energy[username][siteID].lastUpdate + payload.updateInterval < Date.now()) { 652 | await self.doTeslaApiGetEnergy(username, siteID); 653 | } 654 | else { 655 | this.sendSocketNotification("EnergyData", { 656 | username: username, 657 | siteID: siteID, 658 | energy: this.energy[username][siteID].lastResult 659 | }); 660 | } 661 | } 662 | else if (notification === "UpdateSelfConsumption") { 663 | let username = payload.username; 664 | let siteID = payload.siteID; 665 | 666 | if (username && !this.checkTeslaCredentials(username)) { 667 | return; 668 | } 669 | 670 | if (siteID) { 671 | this.initializeCache(this.selfConsumption, username, siteID); 672 | } 673 | else { 674 | return; 675 | } 676 | 677 | if (this.selfConsumption[username][siteID].lastUpdate + payload.updateInterval < Date.now()) { 678 | await self.doTeslaApiGetSelfConsumption(username, siteID); 679 | } 680 | else { 681 | this.sendSocketNotification("SelfConsumption", { 682 | username: username, 683 | siteID: siteID, 684 | selfConsumption: this.selfConsumption[username][siteID].lastResult 685 | }); 686 | } 687 | } 688 | else if (notification === "UpdatePowerHistory") { 689 | let username = payload.username; 690 | let siteID = payload.siteID; 691 | 692 | if (username && !this.checkTeslaCredentials(username)) { 693 | return; 694 | } 695 | 696 | if (siteID) { 697 | this.initializeCache(this.powerHistory, username, siteID); 698 | this.initializeCache(this.backup, username, siteID); 699 | } 700 | else { 701 | return; 702 | } 703 | 704 | if (this.powerHistory[username][siteID].lastUpdate + payload.updateInterval < Date.now()) { 705 | await self.doTeslaApiGetPowerHistory(username, siteID); 706 | } 707 | else { 708 | this.sendSocketNotification("PowerHistory", { 709 | username: username, 710 | siteID: siteID, 711 | powerHistory: this.powerHistory[username][siteID].lastResult 712 | }); 713 | } 714 | if (this.backup[username][siteID].lastUpdate + payload.updateInterval < Date.now()) { 715 | await self.doTeslaApiGetBackupHistory(username, siteID); 716 | } 717 | else { 718 | this.sendSocketNotification("Backup", { 719 | username: username, 720 | siteID: siteID, 721 | backup: this.backup[username][siteID].lastResult 722 | }); 723 | } 724 | 725 | } 726 | else if (notification === "UpdateChargeHistory") { 727 | let twcManagerIP = payload.twcManagerIP; 728 | let twcManagerPort = payload.twcManagerPort; 729 | 730 | this.initializeCache(this.chargeHistory, twcManagerIP); 731 | 732 | if (this.chargeHistory[twcManagerIP].lastUpdate + payload.updateInterval < Date.now()) { 733 | await self.updateTWCHistory(twcManagerIP, twcManagerPort); 734 | } 735 | else { 736 | this.sendSocketNotification("ChargeHistory", { 737 | twcManagerIP: twcManagerIP, 738 | chargeHistory: this.chargeHistory[twcManagerIP].lastResult 739 | }); 740 | } 741 | } 742 | else if (notification === "UpdateVehicleData") { 743 | let username = payload.username; 744 | let vehicleID = payload.vehicleID; 745 | 746 | if (username && !this.checkTeslaCredentials(username)) { 747 | return; 748 | } 749 | 750 | if (vehicleID) { 751 | this.initializeCache(this.vehicleData, username, vehicleID); 752 | } 753 | else { 754 | return; 755 | } 756 | 757 | let useCache = !(this.vehicleData[username][vehicleID].lastUpdate + payload.updateInterval 758 | <= Date.now()); 759 | this.doTeslaApiGetVehicleData(username, vehicleID, useCache); 760 | } 761 | }, 762 | 763 | checkTeslaCredentials: function (username) { 764 | if (!this.teslaApiAccounts[username] || this.teslaApiAccounts[username].refresh_failures > 3) { 765 | this.sendSocketNotification("ReconfigureTeslaAPI", { 766 | teslaAPIUsername: username 767 | }); 768 | return false; 769 | } 770 | else { 771 | return true; 772 | } 773 | }, 774 | 775 | initializeCache: function (node, ...rest) { 776 | let lastKey = rest.pop(); 777 | for (let key of rest) { 778 | if (!node[key]) { 779 | node[key] = {}; 780 | } 781 | node = node[key]; 782 | } 783 | if (!node[lastKey]) { 784 | node[lastKey] = { 785 | lastUpdate: 0, 786 | lastResult: null 787 | }; 788 | } 789 | }, 790 | 791 | updateCache: function (data, node, keys, time = null, target = "lastResult") { 792 | if (!time) { 793 | time = Date.now(); 794 | } 795 | if (keys && !Array.isArray(keys)) { 796 | keys = [keys]; 797 | } 798 | let lastKey = keys.pop(); 799 | for (let key of keys) { 800 | node = node[key]; 801 | } 802 | node[lastKey].lastUpdate = time; 803 | node[lastKey][target] = data; 804 | }, 805 | 806 | doTeslaApiGetStormWatch: async function (username, siteID) { 807 | if (username && siteID) { 808 | let url = "https://owner-api.teslamotors.com/api/1/energy_sites/" + siteID + "/live_status"; 809 | let cloudStatus = await this.doTeslaApi(url, username, null, siteID, this.storm); 810 | 811 | if (cloudStatus) { 812 | this.sendSocketNotification("StormWatch", { 813 | username: username, 814 | siteID: siteID, 815 | storm: cloudStatus.storm_mode_active 816 | }); 817 | } 818 | } 819 | }, 820 | 821 | doTeslaApiGetSiteInfo: function (username, siteID) { 822 | if (username && siteID) { 823 | let url = "https://owner-api.teslamotors.com/api/1/energy_sites/" + siteID + "/site_info"; 824 | return this.doTeslaApi(url, username); 825 | } 826 | }, 827 | 828 | updateTWCManager: async function (twcManagerIP, twcManagerPort) { 829 | let url = "http://" + twcManagerIP + ":" + twcManagerPort + "/api/getStatus"; 830 | let success = true; 831 | let now = Date.now(); 832 | 833 | try { 834 | var result = await fetch(url); 835 | } 836 | catch (e) { 837 | success = false; 838 | } 839 | 840 | if (success && result.ok) { 841 | var status = await result.json(); 842 | var vins = []; 843 | if (status.carsCharging > 0) { 844 | url = "http://" + twcManagerIP + ":" + twcManagerPort + "/api/getSlaveTWCs"; 845 | 846 | try { 847 | result = await fetch(url); 848 | } 849 | catch { } 850 | 851 | if (result.ok) { 852 | let slaves = await result.json(); 853 | for (let slaveID in slaves) { 854 | let slave = slaves[slaveID]; 855 | if (slave.currentVIN) { 856 | vins.push(slave.currentVIN); 857 | } 858 | } 859 | } 860 | } 861 | 862 | // Cache results 863 | this.updateCache(status, this.twcStatus, twcManagerIP, now); 864 | this.updateCache(vins, this.twcVINs, twcManagerIP, now); 865 | 866 | // Send notification 867 | this.sendSocketNotification("ChargeStatus", { 868 | ip: twcManagerIP, 869 | status: status, 870 | vins: vins 871 | }); 872 | } 873 | else { 874 | this.log("TWCManager fetch failed") 875 | } 876 | }, 877 | 878 | updateTWCHistory: async function (twcManagerIP, twcManagerPort) { 879 | let url = "http://" + twcManagerIP + ":" + twcManagerPort + "/api/getHistory"; 880 | let success = true; 881 | let now = Date.now(); 882 | 883 | try { 884 | var result = await fetch(url); 885 | } 886 | catch (e) { 887 | success = false; 888 | } 889 | 890 | if (success && result.ok) { 891 | var history = await result.json(); 892 | this.updateCache(history, this.chargeHistory, twcManagerIP, now); 893 | this.sendSocketNotification("ChargeHistory", { 894 | twcManagerIP: twcManagerIP, 895 | chargeHistory: history 896 | }); 897 | } 898 | }, 899 | 900 | inferSiteID: async function (username) { 901 | url = "https://owner-api.teslamotors.com/api/1/products"; 902 | 903 | this.log("Fetching products list"); 904 | let response = await this.doTeslaApi(url, username); 905 | if (!Array.isArray(response)) { 906 | return null; 907 | } 908 | 909 | let siteIDs = response.filter( 910 | product => (product.battery_type === "ac_powerwall") 911 | ).map(product => product.energy_site_id); 912 | 913 | if (siteIDs.length === 1) { 914 | this.log("Inferred site ID " + siteIDs[0]); 915 | return siteIDs[0]; 916 | } 917 | else if (siteIDs.length === 0) { 918 | console.log("Could not find a Powerwall in your Tesla account"); 919 | } 920 | else { 921 | console.log("Found multiple Powerwalls on your Tesla account:" + siteIDs); 922 | console.log("Add 'siteID' to your config.js to specify which to target"); 923 | } 924 | }, 925 | 926 | log: function (message) { 927 | if (this.debug) { 928 | console.log("MMM-Powerwall: " + message); 929 | } 930 | }, 931 | 932 | doTeslaApiTokenUpdate: async function (username = null) { 933 | let accountsToCheck = [username]; 934 | if (!username) { 935 | if (Date.now() < this.lastUpdate + 3600000) { 936 | // Only check for expired tokens hourly 937 | return; 938 | } 939 | else { 940 | this.lastUpdate = Date.now(); 941 | } 942 | accountsToCheck = Object.keys(this.teslaApiAccounts); 943 | } 944 | 945 | for (const username of accountsToCheck) { 946 | let tokens = this.teslaApiAccounts[username]; 947 | if (tokens && (Date.now() / 1000) > tokens.created_at + (tokens.expires_in / 3)) { 948 | try { 949 | this.teslaApiAccounts[username] = await this.doTeslaApiTokenRefresh(tokens.refresh_token); 950 | await fs.writeFile(this.tokenFile, JSON.stringify(this.teslaApiAccounts)); 951 | continue; 952 | } 953 | catch (e) { } 954 | 955 | // Refresh failed here 956 | if ((Date.now() / 1000) > (tokens.created_at + tokens.expires_in)) { 957 | // Token is expired; abandon it and try password authentication 958 | delete this.teslaApiAccounts[username] 959 | this.checkTeslaCredentials(username); 960 | await fs.writeFile(this.tokenFile, JSON.stringify(this.teslaApiAccounts)); 961 | } 962 | else { 963 | this.teslaApiAccounts[username].refresh_failures = 964 | 1 + (this.teslaApiAccounts[username].refresh_failures || 0); 965 | await fs.writeFile(this.tokenFile, JSON.stringify(this.teslaApiAccounts)); 966 | } 967 | } 968 | } 969 | }, 970 | 971 | doTeslaApiTokenRefresh: async function (token) { 972 | let args = [ 973 | path.resolve(__dirname + "/refresh.py"), 974 | token 975 | ]; 976 | let tokenBL = await spawn("python3", args); 977 | this.log("Refreshed Tesla API tokens") 978 | token = JSON.parse(tokenBL.toString()); 979 | token.created_at = Date.now() / 1000; 980 | return token; 981 | }, 982 | 983 | doTeslaApi: async function (url, username, id_key = null, 984 | deviceID = null, cache_node = null, event_name = null, 985 | response_key = null, event_key = null) { 986 | let result = {}; 987 | let now = Date.now(); 988 | 989 | if (!this.teslaApiAccounts[username]) { 990 | this.log("Called doTeslaApi() without credentials!") 991 | return {}; 992 | } 993 | else { 994 | await this.doTeslaApiTokenUpdate(); 995 | } 996 | 997 | try { 998 | result = await fetch(url, { 999 | headers: { 1000 | "Authorization": "Bearer " + this.teslaApiAccounts[username].access_token 1001 | } 1002 | }); 1003 | } 1004 | catch (e) { 1005 | this.log(e); 1006 | return null; 1007 | } 1008 | 1009 | if (result.ok) { 1010 | try { 1011 | var json = await result.json(); 1012 | this.log(url + " returned " + JSON.stringify(json).substring(0, 150)); 1013 | } 1014 | catch (e) { 1015 | this.log(e); 1016 | return null; 1017 | } 1018 | 1019 | let response = json.response; 1020 | if (response_key) { 1021 | response = response[response_key]; 1022 | } 1023 | 1024 | if (event_name && id_key && event_key) { 1025 | let event = { 1026 | username: username, 1027 | [id_key]: deviceID, 1028 | [event_key]: response 1029 | }; 1030 | this.sendSocketNotification(event_name, event); 1031 | } 1032 | 1033 | if (response && cache_node && deviceID) { 1034 | if (!cache_node[username]) { 1035 | cache_node[username] = {}; 1036 | } 1037 | if (!cache_node[username][deviceID]) { 1038 | cache_node[username][deviceID] = {}; 1039 | } 1040 | this.updateCache(response, cache_node, [username, deviceID], now); 1041 | } 1042 | 1043 | return response; 1044 | } 1045 | else { 1046 | this.log(url + " returned " + result.status); 1047 | this.log(await result.text()); 1048 | return null; 1049 | } 1050 | }, 1051 | 1052 | doTeslaApiGetEnergy: async function (username, siteID) { 1053 | url = "https://owner-api.teslamotors.com/api/1/energy_sites/" + siteID + "/history?period=day&kind=energy"; 1054 | await this.doTeslaApi(url, username, "siteID", siteID, this.energy, "EnergyData", "time_series", "energy"); 1055 | }, 1056 | 1057 | doTeslaApiGetPowerHistory: async function (username, siteID) { 1058 | url = "https://owner-api.teslamotors.com/api/1/energy_sites/" + siteID + "/history?period=day&kind=power"; 1059 | await this.doTeslaApi(url, username, "siteID", siteID, this.powerHistory, "PowerHistory", "time_series", "powerHistory"); 1060 | }, 1061 | 1062 | doTeslaApiGetBackupHistory: async function (username, siteID) { 1063 | url = "https://owner-api.teslamotors.com/api/1/energy_sites/" + siteID + "/history?kind=backup"; 1064 | await this.doTeslaApi(url, username, "siteID", siteID, this.backup, "Backup", "events", "backup"); 1065 | }, 1066 | 1067 | doTeslaApiGetSelfConsumption: async function (username, siteID) { 1068 | url = "https://owner-api.teslamotors.com/api/1/energy_sites/" + siteID + "/history?kind=self_consumption&period=day"; 1069 | await this.doTeslaApi(url, username, "siteID", siteID, this.selfConsumption, "SelfConsumption", "time_series", "selfConsumption"); 1070 | }, 1071 | 1072 | doTeslaApiGetVehicleList: async function (username) { 1073 | url = "https://owner-api.teslamotors.com/api/1/products"; 1074 | let response = await this.doTeslaApi(url, username); 1075 | 1076 | // response is an array of vehicle objects. Don't need all the properties. 1077 | if (Array.isArray(response)) { 1078 | return response.filter(x => x.vehicle_id != null).map( 1079 | function (vehicle) { 1080 | return { 1081 | id: vehicle.id_s, 1082 | vin: vehicle.vin, 1083 | display_name: vehicle.display_name 1084 | } 1085 | }); 1086 | } 1087 | else { 1088 | return []; 1089 | } 1090 | }, 1091 | 1092 | doTeslaApiCommand: async function (url, username, body) { 1093 | if (!this.teslaApiAccounts[username]) { 1094 | this.log("Called doTeslaApiCommand() without credentials!") 1095 | return {}; 1096 | } 1097 | 1098 | try { 1099 | result = await fetch(url, { 1100 | method: "POST", 1101 | body: JSON.stringify(body), 1102 | headers: { 1103 | "Authorization": "Bearer " + this.teslaApiAccounts[username].access_token 1104 | } 1105 | }); 1106 | } 1107 | catch (e) { 1108 | this.log(e); 1109 | return {}; 1110 | } 1111 | 1112 | if (result.ok) { 1113 | let json = await result.json(); 1114 | this.log(JSON.stringify(json)); 1115 | let response = json.response; 1116 | return response; 1117 | } 1118 | else { 1119 | this.log(url + " returned " + result.status); 1120 | this.log(await result.text()); 1121 | return {}; 1122 | } 1123 | }, 1124 | 1125 | delay: function wait(ms) { 1126 | return new Promise(resolve => { 1127 | setTimeout(resolve, ms); 1128 | }); 1129 | }, 1130 | 1131 | doTeslaApiWakeVehicle: async function (username, vehicleID) { 1132 | let timeout = 5000; 1133 | let url = "https://owner-api.teslamotors.com/api/1/vehicles/" + vehicleID + "/wake_up"; 1134 | let state = "initial"; 1135 | 1136 | do { 1137 | let response = await this.doTeslaApiCommand(url, username); 1138 | state = response.state; 1139 | if (response.state !== "online") { 1140 | if (timeout > 20000) { 1141 | break; 1142 | } 1143 | await this.delay(timeout); 1144 | timeout *= 2; 1145 | } 1146 | } while (state != "online"); 1147 | 1148 | return state === "online"; 1149 | }, 1150 | 1151 | doTeslaApiGetVehicleData: async function (username, vehicleID, useCached) { 1152 | // Slightly more complicated; involves calling multiple APIs 1153 | let state = "cached"; 1154 | const forceWake = !(this.vehicleData[username][vehicleID].lastResult); 1155 | if (!useCached || forceWake) { 1156 | let url = "https://owner-api.teslamotors.com/api/1/vehicles/" + vehicleID; 1157 | let response = await this.doTeslaApi(url, username); 1158 | if (response) { 1159 | state = response.state; 1160 | if (state !== "online" && forceWake && 1161 | await this.doTeslaApiWakeVehicle(username, vehicleID)) { 1162 | state = "online"; 1163 | } 1164 | } 1165 | else { 1166 | state = "error" 1167 | } 1168 | } 1169 | 1170 | const REQ_FIELDS = ["vehicle_state", "drive_state", "gui_settings", "charge_state", "vehicle_config"]; 1171 | let dataValid = data => data && 1172 | REQ_FIELDS. 1173 | every( 1174 | key => key in data 1175 | ); 1176 | 1177 | var data = null; 1178 | if (state === "online") { 1179 | // Get vehicle state 1180 | url = "https://owner-api.teslamotors.com/api/1/vehicles/" + vehicleID + "/vehicle_data"; 1181 | url += "?endpoints=" + [...REQ_FIELDS, "location_data"].join("%3B"); 1182 | data = await this.doTeslaApi(url, username, "ID", vehicleID, this.vehicleData); 1183 | } 1184 | 1185 | if (!dataValid(data)) { 1186 | // Car is asleep and either can't wake or we aren't asking 1187 | data = this.vehicleData[username][vehicleID].lastResult; 1188 | state = "cached"; 1189 | } 1190 | 1191 | if (dataValid(data)) { 1192 | let odometer = data.vehicle_state.odometer; 1193 | if (data.gui_settings.gui_distance_units === "mi/hr") { 1194 | odometer *= 1.609344; 1195 | } 1196 | (this.vehicles[username].find(vehicle => vehicle.id == vehicleID) || 1197 | {}).odometer = odometer; 1198 | if (!data.drive_state.longitude && data.drive_state.active_route_longitude) { 1199 | data.drive_state.longitude = data.drive_state.active_route_longitude; 1200 | data.drive_state.latitude = data.drive_state.active_route_latitude; 1201 | } 1202 | this.sendVehicleData(username, vehicleID, state, data); 1203 | } 1204 | else { 1205 | // Car fails to wake and we have no cached data. Send a sparse response. 1206 | this.sendSocketNotification("VehicleData", { 1207 | username: username, 1208 | ID: vehicleID, 1209 | state: state, 1210 | sentry: undefined, 1211 | drive: { 1212 | speed: undefined, 1213 | units: undefined, 1214 | gear: undefined, 1215 | location: undefined 1216 | }, 1217 | charge: { 1218 | state: undefined, 1219 | soc: undefined, 1220 | usable_soc: undefined, 1221 | limit: undefined, 1222 | power: undefined, 1223 | time: undefined 1224 | }, 1225 | config: { 1226 | car_type: undefined, 1227 | option_codes: undefined, 1228 | exterior_color: undefined, 1229 | wheel_type: undefined 1230 | } 1231 | }); 1232 | 1233 | } 1234 | }, 1235 | 1236 | sendVehicleData: function (username, vehicleID, state, data) { 1237 | this.sendSocketNotification("VehicleData", { 1238 | username: username, 1239 | ID: vehicleID, 1240 | state: state, 1241 | geofence: data.geofence, 1242 | sentry: data.vehicle_state.sentry_mode, 1243 | drive: { 1244 | speed: data.drive_state.speed, 1245 | units: data.gui_settings.gui_distance_units, 1246 | gear: data.drive_state.shift_state, 1247 | location: [data.drive_state.latitude, data.drive_state.longitude] 1248 | }, 1249 | charge: { 1250 | state: data.charge_state.charging_state, 1251 | soc: data.charge_state.battery_level, 1252 | usable_soc: data.charge_state.usable_battery_level, 1253 | limit: data.charge_state.charge_limit_soc, 1254 | power: data.charge_state.charger_actual_current * data.charge_state.charger_voltage, 1255 | time: data.charge_state.time_to_full_charge 1256 | }, 1257 | config: { 1258 | car_type: data.vehicle_config.car_type, 1259 | option_codes: data.option_codes, 1260 | exterior_color: data.vehicle_config.exterior_color, 1261 | wheel_type: data.vehicle_config.wheel_type 1262 | } 1263 | }); 1264 | 1265 | } 1266 | }); 1267 | -------------------------------------------------------------------------------- /MMM-Powerwall.js: -------------------------------------------------------------------------------- 1 | /* Magic Mirror 2 | * Module: MMM-Powerwall 3 | * 4 | * By Mike Bishop 5 | * MIT Licensed. 6 | */ 7 | 8 | const SOLAR = { key: "solar", color: "#ffba00" }; 9 | const POWERWALL = { key: "battery", color: "#0BC60B" }; 10 | const GRID = { key: "grid", color: "#8B979B" }; 11 | const HOUSE = { key: "house", color: "#09A9E6" }; 12 | const CAR = { key: "car", color: "#B91413" }; 13 | 14 | const MI_KM_FACTOR = 1.609344; 15 | 16 | const REQUIRED_CALLS = { 17 | CarCharging: ["local", "vehicle"], 18 | PowerwallSelfPowered: ["local", "energy", "selfConsumption"], 19 | SolarProduction: ["local", "energy", "vehicleIfNoTWC"], 20 | HouseConsumption: ["local", "energy", "vehicleIfNoTWC"], 21 | EnergyBar: ["local", "energy"], 22 | PowerLine: ["power"], 23 | Grid: ["local", "energy", "storm"] 24 | } 25 | 26 | const DISPLAY_SOURCES = [ 27 | SOLAR, 28 | POWERWALL, 29 | GRID 30 | ]; 31 | const DISPLAY_SINKS = [ 32 | POWERWALL, 33 | CAR, 34 | HOUSE, 35 | GRID 36 | ]; 37 | var DISPLAY_ALL = [ 38 | GRID, 39 | POWERWALL, 40 | HOUSE, 41 | CAR, 42 | SOLAR 43 | ]; 44 | 45 | Module.register("MMM-Powerwall", { 46 | defaults: { 47 | graphs: [ 48 | "CarCharging", 49 | "Grid", 50 | "PowerwallSelfPowered", 51 | "SolarProduction", 52 | "HouseConsumption", 53 | "EnergyBar", 54 | "PowerLine" 55 | ], 56 | localUpdateInterval: 10000, 57 | cloudUpdateInterval: 300000, 58 | powerwallIP: null, 59 | siteID: null, 60 | twcManagerIP: null, 61 | twcManagerPort: 8080, 62 | teslaAPIUsername: null, 63 | teslaAPIPassword: null, 64 | home: null, 65 | powerlineClip: null, 66 | debug: false 67 | }, 68 | requiresVersion: "2.32.0", // Required version of MagicMirror 69 | twcEnabled: null, 70 | twcConsumption: 0, 71 | teslaAPIEnabled: false, 72 | teslaAggregates: null, 73 | timezone: null, 74 | flows: null, 75 | historySeries: null, 76 | callsToEnable: {}, 77 | numCharging: 0, 78 | yesterdaySolar: null, 79 | yesterdayUsage: null, 80 | yesterdayImport: null, 81 | yesterdayExport: null, 82 | gridStatus: "SystemGridConnected", 83 | gridOutageStart: null, 84 | stormWatch: false, 85 | dayStart: null, 86 | dayMode: "day", 87 | energyData: null, 88 | charts: {}, 89 | powerHistoryChanged: false, 90 | selfConsumptionToday: [0, 0, 100], 91 | selfConsumptionYesterday: null, 92 | suspended: false, 93 | soe: 0, 94 | vehicles: null, 95 | displayVehicles: [], 96 | accountsNeedAuth: [], 97 | vehicleInFocus: null, 98 | cloudInterval: null, 99 | timeouts: {}, 100 | lastSpeedUpdate: 0, 101 | 102 | Log: function (string) { 103 | if (this.config.debug) { 104 | Log.log(string); 105 | } 106 | }, 107 | 108 | start: async function () { 109 | var self = this; 110 | 111 | //Flag for check if module is loaded 112 | this.loaded = false; 113 | 114 | if (self.config.twcManagerIP) { 115 | self.twcEnabled = true; 116 | } 117 | else { 118 | self.twcEnabled = false; 119 | } 120 | 121 | let carIndex = DISPLAY_ALL.indexOf(CAR) 122 | if (!self.twcEnabled && carIndex >= 0) { 123 | DISPLAY_ALL.splice(carIndex, 1); 124 | } 125 | 126 | // Handle singleton graph names 127 | if (!Array.isArray(this.config.graphs)) { 128 | this.config.graphs = [this.config.graphs]; 129 | } 130 | 131 | // Reverse graphs to accomodate wrap-reverse 132 | this.config.graphs.reverse(); 133 | 134 | let callsToEnable = new Set(); 135 | this.config.graphs.forEach( 136 | graph => REQUIRED_CALLS[graph].forEach( 137 | call => callsToEnable.add(call) 138 | ) 139 | ); 140 | callsToEnable.forEach(call => { 141 | self.callsToEnable[call] = true; 142 | }); 143 | if (this.callsToEnable.vehicleIfNoTWC && !this.twcEnabled) { 144 | this.callsToEnable.vehicle = true; 145 | } 146 | 147 | //Send settings to helper 148 | if (self.config.teslaAPIUsername) { 149 | this.configureTeslaApi(); 150 | } 151 | 152 | setInterval(function () { 153 | self.checkTimeouts(); 154 | self.advanceToNextVehicle(); 155 | }, 20000); 156 | this.updateLocal(); 157 | await this.advanceDayMode(); 158 | }, 159 | 160 | updateLocal: function () { 161 | if (this.callsToEnable.local) { 162 | this.Log("Requesting local data"); 163 | let self = this; 164 | let config = this.config; 165 | this.sendSocketNotification("UpdateLocal", { 166 | powerwallIP: config.powerwallIP, 167 | twcManagerIP: config.twcManagerIP, 168 | twcManagerPort: config.twcManagerPort, 169 | updateInterval: config.localUpdateInterval - 500 170 | }); 171 | this.doTimeout("local", () => self.updateLocal(), this.config.localUpdateInterval); 172 | } 173 | }, 174 | 175 | configureTeslaApi: function () { 176 | if (this.config.teslaAPIUsername) { 177 | this.Log("Configuring Tesla API"); 178 | this.sendSocketNotification("Configure-TeslaAPI", 179 | { 180 | siteID: this.config.siteID, 181 | teslaAPIUsername: this.config.teslaAPIUsername, 182 | }); 183 | this.Log("Enabled Tesla API"); 184 | } 185 | }, 186 | 187 | getTemplate: function () { 188 | return "MMM-Powerwall.njk"; 189 | }, 190 | 191 | getTemplateData: function () { 192 | let result = { 193 | id: this.identifier, 194 | graphs: this.config.graphs, 195 | translations: Object.assign( 196 | {}, 197 | Translator.translationsFallback[this.name], 198 | Translator.translations[this.name], 199 | ), 200 | accountsNeedAuth: this.accountsNeedAuth, 201 | }; 202 | 203 | this.Log("Returning " + JSON.stringify(result)); 204 | return result; 205 | }, 206 | 207 | getTranslations: function () { 208 | return { 209 | en: "translations/en.json", 210 | ps: "translations/ps.json", 211 | de: "translations/de.json", 212 | it: "translations/it.json", 213 | }; 214 | }, 215 | 216 | getScripts: function () { 217 | return [ 218 | this.file("node_modules/chart.js/dist/chart.js"), 219 | this.file("node_modules/chartjs-plugin-datalabels/dist/chartjs-plugin-datalabels.min.js"), 220 | this.file("node_modules/chartjs-plugin-annotation/dist/chartjs-plugin-annotation.min.js"), 221 | this.file("node_modules/luxon/build/global/luxon.min.js"), 222 | this.file("node_modules/chartjs-adapter-luxon/dist/chartjs-adapter-luxon.umd.min.js"), 223 | ]; 224 | }, 225 | 226 | getStyles: function () { 227 | return [ 228 | "MMM-Powerwall.css", 229 | "font-awesome.css", 230 | ]; 231 | }, 232 | 233 | updateEnergy: function () { 234 | // Energy gets updated with the local timeout, because it's not 235 | // requested on a recurring basis. Recency affects the accuracy. 236 | if (this.callsToEnable.energy && 237 | this.teslaAPIEnabled && this.config.siteID) { 238 | this.doTimeout( 239 | "energy", 240 | () => { 241 | this.Log("Requesting energy data"); 242 | this.sendDataRequestNotification("UpdateEnergy"); 243 | }, 244 | 0, 245 | true 246 | ) 247 | } 248 | }, 249 | 250 | sendDataRequestNotification: function (notification) { 251 | if (this.teslaAPIEnabled) { 252 | this.sendSocketNotification(notification, { 253 | username: this.config.teslaAPIUsername, 254 | siteID: this.config.siteID, 255 | updateInterval: this.config.cloudUpdateInterval - 500 256 | }); 257 | } 258 | }, 259 | 260 | updatePowerHistory: function () { 261 | if (this.callsToEnable.power) { 262 | this.Log("Requesting power history data"); 263 | this.sendDataRequestNotification("UpdatePowerHistory"); 264 | if (this.twcEnabled) { 265 | this.sendSocketNotification("UpdateChargeHistory", { 266 | twcManagerIP: this.config.twcManagerIP, 267 | twcManagerPort: this.config.twcManagerPort, 268 | updateInterval: this.config.localUpdateInterval - 500 269 | }); 270 | } 271 | } 272 | }, 273 | 274 | updateSelfConsumption: function () { 275 | if (this.callsToEnable.selfConsumption) { 276 | this.Log("Requesting self-consumption data"); 277 | this.sendDataRequestNotification("UpdateSelfConsumption"); 278 | } 279 | }, 280 | 281 | updateStormWatch: function () { 282 | if (this.callsToEnable.storm) { 283 | this.Log("Requesting Storm Watch state"); 284 | this.sendDataRequestNotification("UpdateStormWatch"); 285 | } 286 | }, 287 | 288 | updateVehicleData: function (timeout = null) { 289 | if (this.callsToEnable.vehicle) { 290 | let now = Date.now(); 291 | let willingToDefer = false; 292 | if (!timeout) { 293 | timeout = this.config.cloudUpdateInterval; 294 | willingToDefer = true; 295 | } 296 | if (Array.isArray(this.vehicles)) { 297 | for (let vehicle of this.vehicles) { 298 | if (willingToDefer && vehicle.deferUntil && now < vehicle.deferUntil) { 299 | let self = this; 300 | this.doTimeout("vehicle", () => self.updateVehicleData(), vehicle.deferUntil - now + 1000, true); 301 | continue; 302 | } 303 | 304 | this.Log("Requesting vehicle data"); 305 | this.sendSocketNotification("UpdateVehicleData", { 306 | username: this.config.teslaAPIUsername, 307 | vehicleID: vehicle.id, 308 | updateInterval: timeout - 500 309 | }); 310 | deferUntil = now + (timeout * .9); 311 | } 312 | } 313 | } 314 | }, 315 | 316 | showAccountAuth: function (account) { 317 | let needRefresh = false; 318 | if (this.accountsNeedAuth.indexOf(account) == -1) { 319 | this.accountsNeedAuth.push(account); 320 | this.updateDom(); 321 | } 322 | if (this.config.graphs.indexOf("AuthNeeded") == -1) { 323 | this.config.graphs.push("AuthNeeded"); 324 | this.updateDom(); 325 | } 326 | }, 327 | 328 | clearAccountAuth: function (account) { 329 | let toRemove = this.accountsNeedAuth.indexOf(account); 330 | if (toRemove >= 0) { 331 | this.accountsNeedAuth.splice(toRemove, 1); 332 | this.updateDom(); 333 | } 334 | 335 | if (this.accountsNeedAuth.length == 0) { 336 | toRemove = this.config.graphs.indexOf("AuthNeeded"); 337 | if (toRemove >= 0) { 338 | this.config.graphs.splice(toRemove, 1); 339 | this.updateDom(); 340 | } 341 | } 342 | }, 343 | 344 | // socketNotificationReceived from helper 345 | socketNotificationReceived: async function (notification, payload) { 346 | var self = this; 347 | this.Log("Received " + notification + ": " + JSON.stringify(payload)); 348 | switch (notification) { 349 | case "ReconfigureTeslaAPI": 350 | if (payload.teslaAPIUsername == self.config.teslaAPIUsername) { 351 | this.showAccountAuth(payload.teslaAPIUsername); 352 | } 353 | break; 354 | case "ReconfigurePowerwall": 355 | if (payload.ip == self.config.powerwallIP) { 356 | this.showAccountAuth(payload.ip); 357 | } 358 | break; 359 | case "TeslaAPIConfigured": 360 | if (payload.username === self.config.teslaAPIUsername) { 361 | this.clearAccountAuth(payload.username); 362 | this.teslaAPIEnabled = true; 363 | if (!self.config.siteID) { 364 | self.config.siteID = payload.siteID; 365 | } 366 | if (self.config.siteID === payload.siteID) { 367 | this.timezone = payload.timezone; 368 | this.updateEnergy(); 369 | this.updateAllCloudData(); 370 | this.scheduleCloudUpdate(); 371 | } 372 | this.updateLocal(); 373 | this.vehicles = payload.vehicles; 374 | this.updateVehicleData(); 375 | await this.focusOnVehicles(this.vehicles, 0); 376 | } 377 | break; 378 | case "PowerwallConfigured": 379 | if (payload.ip == self.config.powerwallIP) { 380 | this.clearAccountAuth(payload.ip); 381 | } 382 | case "Aggregates": 383 | if (payload.ip === this.config.powerwallIP && payload.aggregates) { 384 | this.doTimeout("local", () => self.updateLocal(), self.config.localUpdateInterval) 385 | 386 | let needUpdate = false; 387 | if (!this.flows) { 388 | needUpdate = true; 389 | } 390 | if (!this.twcEnabled && this.teslaAggregates && 391 | ( 392 | Math.abs(payload.aggregates.load.instant_power - this.teslaAggregates.load.instant_power) > 1250 || 393 | payload.aggregates.load.instant_power < this.twcConsumption 394 | ) 395 | ) { 396 | // If no TWC, probe for charging changes when we see large 397 | // swings in consumption. 1.25kW catches 12A @ 110+V or 6A @ 208+V. 398 | this.updateVehicleData(this.config.localUpdateInterval); 399 | } 400 | 401 | this.teslaAggregates = payload.aggregates; 402 | if (this.twcConsumption <= this.teslaAggregates.load.instant_power) { 403 | this.flows = this.attributeFlows(payload.aggregates, self.twcConsumption); 404 | } 405 | 406 | if (this.energyData) { 407 | this.generateDaystart(this.energyData); 408 | this.energyData = null; 409 | } 410 | 411 | if (needUpdate) { 412 | // If we didn't have data before, we need to redraw 413 | this.buildGraphs(); 414 | } 415 | 416 | // We're updating the data in-place. 417 | await this.updateData(); 418 | } 419 | break; 420 | case "SOE": 421 | if (payload.ip === this.config.powerwallIP) { 422 | this.soe = payload.soe; 423 | this.updateNode( 424 | this.identifier + "-PowerwallSOE", 425 | payload.soe, 426 | "%", 427 | "", 428 | false); 429 | let meterNode = document.getElementById(this.identifier + "-battery-meter"); 430 | if (meterNode) { 431 | meterNode.style = "height: " + payload.soe + "%;"; 432 | } 433 | } 434 | break; 435 | case "ChargeStatus": 436 | if (payload.ip === this.config.twcManagerIP) { 437 | let oldConsumption = this.twcConsumption; 438 | this.twcConsumption = Math.round(parseFloat(payload.status.chargerLoadWatts)); 439 | if (this.twcConsumption !== oldConsumption && 440 | this.teslaAggregates && this.flows && 441 | this.twcConsumption <= this.teslaAggregates.load.instant_power) { 442 | this.flows = this.attributeFlows(this.teslaAggregates, self.twcConsumption); 443 | await this.updateData(); 444 | } 445 | 446 | let charging = this.flows.sinks.car.total; 447 | if (this.flows && payload.status.carsCharging > 0 && this.vehicles && charging > 0) { 448 | // Charging at least one car 449 | this.updateNode(this.identifier + "-CarConsumption", charging, "W"); 450 | 451 | let vinsWeKnow = (payload.vins || []).filter( 452 | chargingVIN => this.vehicles.some( 453 | knownVehicle => knownVehicle.vin == chargingVIN 454 | ) 455 | ) || []; 456 | let vehicles; 457 | 458 | if (vinsWeKnow.length > 0) { 459 | // We recognize some charging VINs! 460 | vehicles = vinsWeKnow.map(vin => this.vehicles.find( 461 | vehicle => vehicle.vin == vin 462 | )); 463 | vehicles.sort((a, b) => ( 464 | a.charge && b.charge && 465 | a.charge.soc && b.charge.soc ? 466 | (a.charge.soc - b.charge.soc) : 467 | a.charge ? -1 : 1)); 468 | } 469 | else { 470 | // Charging cars are unknown; TWCs can't report VINs? 471 | // Show any cars the API indicates are currently 472 | // charging. This will have some false positives if 473 | // charging off-site, and will be slow to detect 474 | // vehicles. 475 | vehicles = this.vehicles.filter( 476 | knownVehicle => 477 | knownVehicle.charge && 478 | knownVehicle.charge.state === "Charging" 479 | ); 480 | } 481 | 482 | if (vehicles.map( 483 | vehicle => vehicle.charge ? 484 | (vehicle.charge.power != 0 ? 485 | this.flows.sinks.car.total / (vehicle.charge.power * payload.status.carsCharging) : 486 | 2) : 487 | 1 488 | ).some(ratio => ratio > 1.25 || ratio < 0.75)) { 489 | this.updateVehicleData(30000); 490 | } 491 | await this.focusOnVehicles(vehicles, payload.status.carsCharging) 492 | } 493 | else { 494 | // No cars are charging. 495 | await this.focusOnVehicles(this.vehicles, 0); 496 | } 497 | } 498 | break; 499 | 500 | case "EnergyData": 501 | if (payload.username === this.config.teslaAPIUsername && 502 | this.config.siteID == payload.siteID) { 503 | 504 | this.clearTimeout("energy"); 505 | 506 | if (this.teslaAggregates) { 507 | this.generateDaystart(payload); 508 | } 509 | else { 510 | this.energyData = payload 511 | } 512 | this.updateData(); 513 | } 514 | break; 515 | case "PowerHistory": 516 | if (payload.username === this.config.teslaAPIUsername && 517 | this.config.siteID == payload.siteID) { 518 | this.scheduleCloudUpdate(); 519 | this.powerHistory = payload.powerHistory; 520 | this.updatePowerLine(); 521 | } 522 | break; 523 | case "ChargeHistory": 524 | if (payload.twcManagerIP === this.config.twcManagerIP) { 525 | this.chargeHistory = payload.chargeHistory; 526 | this.cachedCarTotal = null; 527 | this.updatePowerLine(); 528 | } 529 | break; 530 | case "SelfConsumption": 531 | if (payload.username === this.config.teslaAPIUsername && 532 | this.config.siteID == payload.siteID) { 533 | 534 | this.scheduleCloudUpdate(); 535 | let yesterday = payload.selfConsumption[0]; 536 | let today = payload.selfConsumption[1]; 537 | this.selfConsumptionYesterday = [ 538 | yesterday.solar, 539 | yesterday.battery, 540 | 100 - yesterday.solar - yesterday.battery 541 | ]; 542 | this.selfConsumptionToday = [ 543 | today.solar, 544 | today.battery, 545 | 100 - today.solar - today.battery 546 | ]; 547 | this.updateNode( 548 | this.identifier + "-SelfPoweredTotal", 549 | Math.round(this.selfConsumptionToday[0]) + Math.round(this.selfConsumptionToday[1]), 550 | "%" 551 | ); 552 | this.updateNode( 553 | this.identifier + "-SelfPoweredYesterday", 554 | Math.round(this.selfConsumptionYesterday[0]) + Math.round(this.selfConsumptionYesterday[1]), 555 | "% " + this.translate("yesterday") 556 | ); 557 | let scChart = this.charts.selfConsumption 558 | if (scChart) { 559 | scChart.data.datasets[0].data = this.selfConsumptionToday; 560 | scChart.update(); 561 | } 562 | } 563 | break; 564 | case "VehicleData": 565 | // username: username, 566 | // ID: vehicleID, 567 | // state: state, 568 | // sentry: data.vehicle_state.sentry_mode, 569 | // drive: { 570 | // speed: data.drive_state.speed, 571 | // units: data.gui_settings.gui_distance_units, 572 | // gear: data.drive_state.shift_state, 573 | // location: [data.drive_state.latitude, data.drive_state.longitude] 574 | // }, 575 | // charge: { 576 | // state: data.charge_state.charging_state, 577 | // soc: data.charge_state.battery_level, 578 | // limit: data.charge_state.charge_limit_soc, 579 | // power: data.charge_state.charger_power, 580 | // time: data.charge_state.time_to_full_charge 581 | // } 582 | 583 | if (payload.username === this.config.teslaAPIUsername) { 584 | let intervalToUpdate = this.config.cloudUpdateInterval; 585 | if (payload.state === "online" && payload.drive.gear === "D") { 586 | intervalToUpdate = 2 * this.config.localUpdateInterval + this.config.cloudUpdateInterval; 587 | intervalToUpdate /= 3; 588 | } 589 | this.doTimeout("vehicle", () => self.updateVehicleData(), intervalToUpdate, true); 590 | 591 | let statusFor = (this.vehicles || []).find(vehicle => vehicle.id == payload.ID); 592 | if (!statusFor) { 593 | break; 594 | } 595 | 596 | if (payload.state === "online" && !payload.drive.gear && !payload.sentry && payload.charge.power == 0) { 597 | // If car is idle and not in Sentry mode, don't request data for half an hour; 598 | // let it try to sleep. 599 | statusFor.deferUntil = Date.now() + 30 * 60 * 1000; 600 | } 601 | else if 602 | ( 603 | ["D", "R"].includes(payload.drive.gear) || 604 | payload.sentry || 605 | payload.charge.power > 0 606 | ) { 607 | delete statusFor.deferUntil; 608 | } 609 | 610 | if (!statusFor.img && payload.config.car_type) { 611 | let image = new Image(); 612 | image.src = this.createCompositorUrl(payload.config); 613 | image.onload = async function (ev) { 614 | statusFor.img = image 615 | if (statusFor === self.vehicleInFocus) { 616 | await self.drawStatusForVehicle(statusFor, self.numCharging, false); 617 | } 618 | } 619 | } 620 | statusFor.drive = payload.drive; 621 | statusFor.charge = payload.charge; 622 | statusFor.geofence = payload.geofence 623 | await this.inferTwcFromVehicles(); 624 | 625 | if (!this.vehicleInFocus) { 626 | this.advanceToNextVehicle(); 627 | } 628 | else if (statusFor === this.vehicleInFocus) { 629 | await this.drawStatusForVehicle(statusFor, this.numCharging, false); 630 | } 631 | } 632 | break; 633 | case "GridStatus": 634 | if (payload.ip === this.config.powerwallIP) { 635 | this.gridStatus = payload.gridStatus; 636 | // Update will run soon enough 637 | //this.updateData(); 638 | } 639 | break; 640 | case "StormWatch": 641 | if (payload.username === this.config.teslaAPIUsername && 642 | this.config.siteID == payload.siteID) { 643 | this.scheduleCloudUpdate(); 644 | this.stormWatch = payload.storm; 645 | this.updateData(); 646 | } 647 | break; 648 | case "Backup": 649 | if (payload.username === this.config.teslaAPIUsername && 650 | this.config.siteID == payload.siteID) { 651 | let lastMidnight = luxon.DateTime.local().setZone(this.timezone).startOf('day'); 652 | this.backup = payload.backup.filter( 653 | outage => luxon.DateTime.fromISO(outage.timestamp).plus({ milliseconds: outage.duration }) > lastMidnight 654 | ); 655 | if (this.gridStatus === "SystemGridConnected") { 656 | // If the grid is up, this should include the most recent outage 657 | this.gridOutageStart = null; 658 | } 659 | if (this.powerHistory) { 660 | this.updatePowerLine(); 661 | } 662 | } 663 | case "Operation": 664 | if (payload.ip === this.config.powerwallIP) { 665 | let identifier = this.identifier + "-reserve"; 666 | if (payload.mode === "backup") { 667 | this.makeNodeInvisible(identifier); 668 | } 669 | else { 670 | let targetNode = document.getElementById(identifier); 671 | this.makeNodeVisible(identifier); 672 | if (targetNode) { 673 | targetNode.style.bottom = payload.reserve + "%"; 674 | } 675 | } 676 | } 677 | default: 678 | break; 679 | } 680 | }, 681 | 682 | inferTwcFromVehicles: async function () { 683 | if (this.teslaAggregates && !this.twcEnabled) { 684 | let oldConsumption = this.twcConsumption; 685 | let chargingAtHome = this.vehicles.filter(v => this.isHome(v.drive.location) && v.charge.state === "Charging"); 686 | this.numCharging = chargingAtHome.length; 687 | this.twcConsumption = chargingAtHome.reduce( 688 | (acc, v) => acc + v.charge.power, 689 | 0 690 | ); 691 | 692 | if (this.numCharging > 0) { 693 | // Charging at least one car 694 | this.updateNode(this.identifier + "-CarConsumption", this.twcConsumption, "W"); 695 | } 696 | 697 | if (this.twcConsumption !== oldConsumption && 698 | this.teslaAggregates && 699 | this.twcConsumption <= this.teslaAggregates.load.instant_power) { 700 | this.flows = this.attributeFlows(this.teslaAggregates, this.twcConsumption); 701 | await this.updateData(); 702 | } 703 | else if (this.twcConsumption > this.teslaAggregates.load.instant_power) { 704 | this.updateVehicleData(5000); 705 | } 706 | } 707 | }, 708 | 709 | updateAllCloudData: function () { 710 | this.updateSelfConsumption(); 711 | this.updatePowerHistory(); 712 | this.updateStormWatch(); 713 | }, 714 | 715 | scheduleCloudUpdate: function () { 716 | var self = this; 717 | this.doTimeout("cloud", 718 | () => self.updateAllCloudData(), 719 | this.config.cloudUpdateInterval 720 | ); 721 | }, 722 | 723 | doTimeout: function (name, func, timeout, exempt = false) { 724 | this.clearTimeout(name); 725 | let delay = timeout + (Math.random() * 3000) - 500; 726 | if (delay < 500) { 727 | delay = 500; 728 | } 729 | this.timeouts[name] = { 730 | func: func, 731 | target: Date.now() + delay, 732 | exempt: exempt 733 | }; 734 | if (!this.suspended || exempt) { 735 | this.timeouts[name].handle = setTimeout(() => func(), delay); 736 | } 737 | }, 738 | 739 | clearTimeout: function (name) { 740 | if (this.timeouts[name]) { 741 | clearTimeout(this.timeouts[name].handle); 742 | } 743 | delete this.timeouts[name] 744 | }, 745 | 746 | checkTimeouts: function () { 747 | for (let name in this.timeouts) { 748 | if ((!this.suspended || this.timeouts.exempt) && Date.now() - this.timeouts[name].target > 5000) { 749 | this.timeouts[name].func(); 750 | this.timeouts[name].target = Date.now(); 751 | } 752 | } 753 | }, 754 | 755 | suspend: function () { 756 | this.suspended = true; 757 | for (let name in this.timeouts) { 758 | if (!this.timeouts[name].exempt) { 759 | clearTimeout(this.timeouts[name].handle); 760 | } 761 | } 762 | }, 763 | 764 | resume: function () { 765 | this.suspended = false; 766 | this.checkTimeouts(); 767 | }, 768 | 769 | generateDaystart: function (payload) { 770 | this.yesterdaySolar = payload.energy[0].solar_energy_exported; 771 | this.yesterdayUsage = ( 772 | payload.energy[0].consumer_energy_imported_from_grid + 773 | payload.energy[0].consumer_energy_imported_from_solar + 774 | payload.energy[0].consumer_energy_imported_from_battery 775 | ); 776 | this.yesterdayImport = payload.energy[0].grid_energy_imported; 777 | this.yesterdayExport = ( 778 | payload.energy[0].grid_energy_exported_from_solar + 779 | payload.energy[0].grid_energy_exported_from_battery + 780 | payload.energy[0].grid_energy_exported_from_generator 781 | ); 782 | 783 | let todaySolar = payload.energy[1].solar_energy_exported; 784 | 785 | let todayGridIn = payload.energy[1].grid_energy_imported; 786 | let todayGridOut = ( 787 | payload.energy[1].grid_energy_exported_from_solar + 788 | payload.energy[1].grid_energy_exported_from_battery + 789 | payload.energy[1].grid_energy_exported_from_generator 790 | ); 791 | 792 | let todayBatteryIn = payload.energy[1].battery_energy_exported; 793 | let todayBatteryOut = ( 794 | payload.energy[1].battery_energy_imported_from_grid + 795 | payload.energy[1].battery_energy_imported_from_solar + 796 | payload.energy[1].battery_energy_imported_from_generator 797 | ); 798 | 799 | let todayUsage = ( 800 | payload.energy[1].consumer_energy_imported_from_grid + 801 | payload.energy[1].consumer_energy_imported_from_solar + 802 | payload.energy[1].consumer_energy_imported_from_battery 803 | ); 804 | 805 | this.dayStart = { 806 | solar: { 807 | export: ( 808 | this.teslaAggregates.solar.energy_exported - 809 | todaySolar 810 | ) 811 | }, 812 | grid: { 813 | export: ( 814 | this.teslaAggregates.site.energy_exported - 815 | todayGridOut 816 | ), 817 | import: ( 818 | this.teslaAggregates.site.energy_imported - 819 | todayGridIn 820 | ) 821 | }, 822 | house: { 823 | import: ( 824 | this.teslaAggregates.load.energy_imported - 825 | todayUsage 826 | ) 827 | }, 828 | battery: { 829 | export: ( 830 | this.teslaAggregates.battery.energy_exported - 831 | todayBatteryIn 832 | ), 833 | import: ( 834 | this.teslaAggregates.battery.energy_imported - 835 | todayBatteryOut 836 | ) 837 | } 838 | }; 839 | }, 840 | 841 | updatePowerLine: function () { 842 | let powerLine = this.charts.powerLine; 843 | if (powerLine) { 844 | let lastMidnight = luxon.DateTime.local().setZone(this.timezone).startOf('day'); 845 | let newData = this.processPowerHistory(); 846 | 847 | if (powerLine.options.scales.xAxis.min == lastMidnight.toString() 848 | && powerLine.data && powerLine.data.datasets.length == newData.datasets.length) { 849 | powerLine.data.labels = newData.labels; 850 | for (let i = 0; i < newData.datasets.length; i++) { 851 | powerLine.data.datasets[i].data = newData.datasets[i].data; 852 | } 853 | } 854 | else { 855 | powerLine.options.scales.xAxis.min = lastMidnight.toString(); 856 | powerLine.options.scales.xAxis.max = luxon.DateTime.local().setZone(this.timezone).endOf('day').toString(); 857 | powerLine.data = newData; 858 | } 859 | 860 | powerLine.options.scales.yAxis.max = newData.clip; 861 | powerLine.options.scales.yAxis.min = -1 * newData.clip; 862 | 863 | if (Array.isArray(this.backup)) { 864 | let outages = this.backup; 865 | if (this.gridOutageStart) { 866 | outages.push({ 867 | timestamp: new Date(this.gridOutageStart).toISOString(), 868 | duration: Date.now() - this.gridOutageStart 869 | }); 870 | } 871 | powerLine.options.plugins.annotation.annotations = [ 872 | ...outages.map( 873 | outage => { 874 | return { 875 | type: 'box', 876 | xScaleID: 'xAxis', 877 | xMin: outage.timestamp, 878 | xMax: luxon.DateTime.fromISO(outage.timestamp).plus({ milliseconds: outage.duration }).toISO(), 879 | backgroundColor: "rgba(255, 0, 0, 0.1)", 880 | borderColor: "rgba(255,0,0,0.1)" 881 | }; 882 | } 883 | ), 884 | { 885 | type: 'line', 886 | mode: 'vertical', 887 | scaleID: 'xAxis', 888 | value: 0, 889 | borderColor: 'black', 890 | borderWidth: 0.5, 891 | label: { 892 | enabled: false 893 | } 894 | } 895 | ]; 896 | } 897 | 898 | powerLine.update(); 899 | } 900 | }, 901 | 902 | drawStatusForVehicle: async function (statusFor, numCharging, hidden) { 903 | if (!statusFor || !statusFor.drive) { 904 | return false; 905 | } 906 | 907 | let animate = !hidden; 908 | let number = 0; 909 | let unit = "W"; 910 | let consumptionVisible; 911 | let consumptionId = this.identifier + "-CarConsumption"; 912 | let completionParaId = this.identifier + "-CarCompletion"; 913 | 914 | let vars = { 915 | NAME: statusFor.display_name, 916 | NUM: numCharging - 1, 917 | }; 918 | 919 | 920 | let picture = document.getElementById(this.identifier + "-Picture"); 921 | if (picture && statusFor.img) { 922 | let ctx = picture.getContext('2d'); 923 | ctx.clearRect(0, 0, picture.width, picture.height); 924 | ctx.drawImage(statusFor.img, 0, 0, 300, 300 / statusFor.img.width * statusFor.img.height); 925 | } 926 | 927 | // Determine location up-front, for later insertion 928 | if (statusFor.drive.location && statusFor.drive.location[0] && statusFor.drive.location[1]) { 929 | if (this.isHome(statusFor.drive.location)) { 930 | vars["LOCATION"] = this.translate("at_home"); 931 | } 932 | else if (statusFor.geofence) { 933 | vars["LOCATION"] = this.translate("at_geofence", { GEOFENCE: statusFor.geofence }); 934 | } 935 | else if (statusFor.namedLocation && statusFor.locationText && 936 | this.isSameLocation(statusFor.namedLocation, statusFor.drive.location)) { 937 | vars["LOCATION"] = this.translate("elsewhere", { TOWN: statusFor.locationText }); 938 | } 939 | else { 940 | let url = 941 | "https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer/reverseGeocode?" + 942 | "f=json&preferredLabelValues=localCity&featureTypes=Locality&location=" + 943 | statusFor.drive.location[1] + "%2C" + statusFor.drive.location[0]; 944 | try { 945 | let result = await fetch(url); 946 | if (result.ok) { 947 | let revGeo = await result.json(); 948 | if (revGeo.address.Match_addr) { 949 | vars["LOCATION"] = this.translate("elsewhere", { TOWN: revGeo.address.Match_addr }); 950 | statusFor.locationText = revGeo.address.Match_addr; 951 | statusFor.namedLocation = statusFor.drive.location; 952 | } 953 | } 954 | } 955 | catch { 956 | statusFor.locationText = null; 957 | } 958 | } 959 | } 960 | else { 961 | vars["LOCATION"] = "" 962 | } 963 | 964 | let isCharging = statusFor.charge.state === "Charging"; 965 | if (numCharging > 0) { 966 | // Cars are drawing power, including this one 967 | let verb = "consuming"; 968 | if (isCharging) { 969 | verb = "charging_at"; 970 | } 971 | 972 | statusText = this.translate( 973 | verb + (numCharging > 1 ? "_plural" : ""), 974 | vars 975 | ); 976 | 977 | consumptionVisible = true; 978 | } 979 | else if (!statusFor.charge.state) { 980 | // No data 981 | statusText = this.translate("unavailable", vars) 982 | consumptionVisible = false; 983 | let self = this; 984 | setTimeout(() => { 985 | self.updateVehicleData(30000); 986 | }, 30000); 987 | } 988 | else { 989 | // Cars not charging on TWCManager; show current instead 990 | switch (statusFor.drive.gear) { 991 | case "D": 992 | case "R": 993 | statusText = this.translate("driving", vars); 994 | consumptionVisible = true; 995 | 996 | if (Date.now() - this.lastSpeedUpdate >= 5000) { 997 | unit = statusFor.drive.units; 998 | if (unit === "mi/hr") { 999 | number = statusFor.drive.speed; 1000 | unit = "mph" 1001 | } 1002 | else { 1003 | // Convert to kph, since API reports mph 1004 | number = statusFor.drive.speed * MI_KM_FACTOR; 1005 | unit = "kph" 1006 | } 1007 | this.lastSpeedUpdate = Date.now() 1008 | 1009 | this.updateNode(consumptionId, number, unit, "", animate); 1010 | } 1011 | break; 1012 | 1013 | default: 1014 | switch (statusFor.charge.state) { 1015 | case "Disconnected": 1016 | statusText = this.translate("parked", vars); 1017 | break; 1018 | case "Charging": 1019 | // Car charging away from home, or no TWCManager 1020 | statusText = this.translate("charging", vars); 1021 | break; 1022 | default: 1023 | statusText = this.translate("not_charging", vars); 1024 | break; 1025 | } 1026 | 1027 | number = null; 1028 | unit = ""; 1029 | 1030 | consumptionVisible = false; 1031 | break; 1032 | } 1033 | 1034 | this.makeNodeInvisible(completionParaId); 1035 | } 1036 | 1037 | // Regardless of which path, set the car status text 1038 | this.updateText(this.identifier + "-CarStatus", statusText, animate); 1039 | 1040 | // If charging, display time to completion 1041 | if (statusFor.charge.time > 0 && isCharging) { 1042 | let days = Math.trunc(statusFor.charge.time / 24); 1043 | let hours = Math.trunc(statusFor.charge.time); 1044 | let minutes = Math.round( 1045 | (statusFor.charge.time - hours) 1046 | * 12) * 5; 1047 | if (days > 0) { 1048 | hours = Math.round(statusFor.charge.time - days * 24); 1049 | minutes = 0 1050 | } 1051 | 1052 | this.updateText(completionParaId, 1053 | this.translate("completion_time", 1054 | { 1055 | DAYS: 1056 | days > 0 ? 1057 | days > 1 ? 1058 | this.translate("multiday", { NUM: "" + days }) : 1059 | this.translate("1day") : 1060 | " ", 1061 | HOURS: 1062 | hours > 0 ? 1063 | hours >= 2 ? 1064 | this.translate("multihours", { NUM: "" + hours }) : 1065 | this.translate("1hour") : 1066 | " ", 1067 | MINUTES: 1068 | minutes > 0 ? 1069 | this.translate("minutes", { NUM: "" + minutes }) : 1070 | " ", 1071 | LISTSEP1: (hours > 0 && days > 0) ? this.translate("listsep") : " ", 1072 | LISTSEP2: (hours > 0 && minutes > 0) ? this.translate("listsep") : " " 1073 | } 1074 | ), 1075 | animate 1076 | ); 1077 | this.makeNodeVisible(completionParaId); 1078 | } 1079 | else { 1080 | this.makeNodeInvisible(completionParaId) 1081 | } 1082 | 1083 | // Update battery meter 1084 | let soc = statusFor.charge.soc; 1085 | let usableSoc = statusFor.charge.usable_soc; 1086 | if (!usableSoc) { 1087 | usableSoc = soc; 1088 | } 1089 | let lockedSoc = soc - usableSoc; 1090 | let meterNode = document.getElementById(this.identifier + "-car-meter"); 1091 | let lockedMeterNode = document.getElementById(this.identifier + "-car-meter-unavailable"); 1092 | if (meterNode && lockedMeterNode) { 1093 | meterNode.style.width = usableSoc + "%"; 1094 | lockedMeterNode.style.width = lockedSoc + "%"; 1095 | meterNode.classList.remove("battery", "battery-warn", "battery-critical") 1096 | if (soc > 99.5 || soc < 7.5) { 1097 | meterNode.classList.add("battery-critical"); 1098 | } 1099 | else if (soc > 90.5 || soc < 19.5) { 1100 | meterNode.classList.add("battery-warn"); 1101 | } 1102 | else { 1103 | meterNode.classList.add("battery"); 1104 | } 1105 | } 1106 | this.updateNode( 1107 | this.identifier + "-car-meter-text", 1108 | usableSoc || "??", 1109 | "%", 1110 | (lockedSoc >= 2) ? "❄ " : "", 1111 | animate 1112 | ); 1113 | if (consumptionVisible) { 1114 | this.makeNodeVisible(consumptionId); 1115 | } 1116 | else { 1117 | this.makeNodeInvisible(consumptionId); 1118 | } 1119 | return true; 1120 | }, 1121 | 1122 | isHome: function (location) { 1123 | return this.isSameLocation(this.config.home, location); 1124 | }, 1125 | 1126 | isSameLocation: function (l1, l2) { 1127 | if (Array.isArray(l1) && Array.isArray(l2)) { 1128 | return Math.abs(l1[0] - l2[0]) < 0.03 && 1129 | Math.abs(l1[1] - l2[1]) < 0.03; 1130 | } 1131 | return null; 1132 | }, 1133 | 1134 | formatAsK: function (number, unit) { 1135 | let separator = (unit[0] === "%") ? "" : " " 1136 | if (isNaN(number)) { 1137 | return number + separator + unit; 1138 | } 1139 | else if (number > 950) { 1140 | return Math.round(number / 100) / 10.0 + separator + "k" + unit; 1141 | } 1142 | else { 1143 | return Math.round(number) + separator + unit; 1144 | } 1145 | }, 1146 | 1147 | updateNode: function (id, value, unit, prefix = "", animate = true) { 1148 | this.updateText(id, prefix + this.formatAsK(value, unit), animate); 1149 | }, 1150 | 1151 | updateText: function (id, text, animate = true, classAdd = null, classRemove = null) { 1152 | let targetNode = document.getElementById(id); 1153 | let self = this; 1154 | 1155 | // Normalize the text before update/comparison 1156 | text = text.replace(/\s+/g, " ").trim(); 1157 | 1158 | if (targetNode && ( 1159 | targetNode.innerText !== text || 1160 | (classAdd && !this.classPresent(targetNode, classAdd)) || 1161 | (classRemove && this.classPresent(targetNode, classRemove)))) { 1162 | if (animate) { 1163 | targetNode.style.opacity = 0 1164 | setTimeout(function () { 1165 | self.updateClass(targetNode, classAdd, classRemove); 1166 | targetNode.innerText = text; 1167 | targetNode.style.opacity = 1; 1168 | }, 250); 1169 | } 1170 | else { 1171 | self.updateClass(targetNode, classAdd, classRemove); 1172 | targetNode.innerText = text; 1173 | } 1174 | } 1175 | }, 1176 | 1177 | updateClass: function (node, classAdd = null, classRemove = null) { 1178 | if (node) { 1179 | if (classAdd) { 1180 | if (Array.isArray(classAdd)) { 1181 | node.classList.add(...classAdd); 1182 | } 1183 | else { 1184 | node.classList.add(classAdd); 1185 | } 1186 | } 1187 | if (classRemove) { 1188 | if (Array.isArray(classRemove)) { 1189 | node.classList.remove(...classRemove); 1190 | } 1191 | else { 1192 | node.classList.remove(classRemove); 1193 | } 1194 | } 1195 | } 1196 | }, 1197 | 1198 | classPresent: function (node, classList) { 1199 | if (classList && node) { 1200 | if (Array.isArray(classList)) { 1201 | return classList.some(toCheck => node.classList.contains(toCheck)); 1202 | } 1203 | else { 1204 | return node.classList.contains(classList); 1205 | } 1206 | } 1207 | else { 1208 | return false; 1209 | } 1210 | }, 1211 | 1212 | updateChart: function (chart, display_array, distribution) { 1213 | if (chart) { 1214 | chart.data.datasets[0].data = display_array.map((entry) => distribution[entry.key]); 1215 | chart.update(); 1216 | } 1217 | }, 1218 | 1219 | makeNodeVisible: function (identifier) { 1220 | this.setNodeVisibility(identifier, "block"); 1221 | }, 1222 | 1223 | setNodeVisibility: function (identifier, value) { 1224 | let node = document.getElementById(identifier); 1225 | if (node) { 1226 | node.style.display = value; 1227 | } 1228 | }, 1229 | 1230 | makeNodeInvisible: function (identifier) { 1231 | this.setNodeVisibility(identifier, "none"); 1232 | }, 1233 | 1234 | updateData: async function () { 1235 | // Check if we need to advance dayMode 1236 | let now = new Date(); 1237 | if (this.dayNumber != now.getDay() || 1238 | !this.sunrise || !this.sunset || 1239 | (this.dayMode === "morning" && now.getTime() > this.sunrise) || 1240 | (this.dayMode === "day" && now.getTime() > this.sunset)) { 1241 | await this.advanceDayMode(); 1242 | } 1243 | 1244 | // Check for any overdue timeouts 1245 | this.checkTimeouts(); 1246 | let anyProductionToday = this.teslaAggregates && this.dayStart ? 1247 | this.teslaAggregates.solar.energy_exported > this.dayStart.solar.export : 1248 | null; 1249 | let isDay = this.dayMode === "day" && anyProductionToday !== false; 1250 | let showCurrent = isDay && this.flows && this.flows.sources.solar.total > 5; 1251 | 1252 | /******************* 1253 | * SolarProduction * 1254 | *******************/ 1255 | if (this.flows) { 1256 | this.updateNode( 1257 | this.identifier + "-SolarProduction", 1258 | this.flows.sources.solar.total, 1259 | "W", 1260 | "", 1261 | this.dayMode === "day" 1262 | ); 1263 | this.updateChart(this.charts.solarProduction, DISPLAY_SINKS, this.flows.sources.solar.distribution); 1264 | let dayContent = this.identifier + "-SolarDay"; 1265 | let nightContent = this.identifier + "-SolarNight"; 1266 | if (showCurrent) { 1267 | this.makeNodeVisible(dayContent); 1268 | this.makeNodeInvisible(nightContent); 1269 | } 1270 | else { 1271 | this.makeNodeInvisible(dayContent); 1272 | this.makeNodeVisible(nightContent); 1273 | this.updateText( 1274 | this.identifier + "-SolarHeader", 1275 | this.translate(isDay ? "solar_sameday" : "solar_prevday") 1276 | ) 1277 | this.updateText( 1278 | this.identifier + "-SolarTodayYesterday", 1279 | this.translate( 1280 | (this.dayMode === "morning" || !anyProductionToday) ? 1281 | "yesterday" : 1282 | (this.dayMode === "day" ? "today_during" : "today") 1283 | ) 1284 | ); 1285 | } 1286 | } 1287 | 1288 | if (this.teslaAggregates && this.dayStart) { 1289 | this.updateNode( 1290 | this.identifier + "-SolarTotalTextA", 1291 | this.teslaAggregates.solar.energy_exported - this.dayStart.solar.export, 1292 | "Wh " + this.translate("today"), "", showCurrent 1293 | ); 1294 | this.updateNode( 1295 | this.identifier + "-SolarYesterdayTotal", 1296 | this.yesterdaySolar, 1297 | "Wh " + this.translate("yesterday"), "", showCurrent 1298 | ); 1299 | this.updateNode( 1300 | this.identifier + "-SolarTotalB", 1301 | (this.dayMode === "morning" || !anyProductionToday) ? 1302 | this.yesterdaySolar : 1303 | (this.teslaAggregates.solar.energy_exported - this.dayStart.solar.export), 1304 | "Wh", "", !showCurrent 1305 | ); 1306 | this.makeNodeVisible(this.identifier + "-SolarTotalTextA"); 1307 | this.makeNodeVisible(this.identifier + "-SolarYesterdayTotal"); 1308 | 1309 | let scChart = this.charts.selfConsumption 1310 | if (scChart) { 1311 | let offset = [ 1312 | this.teslaAggregates.solar.energy_exported - this.dayStart.solar.export, 1313 | this.teslaAggregates.load.energy_imported - this.dayStart.house.import 1314 | ]; 1315 | offset[1] -= Math.min(offset[0], offset[1]); 1316 | 1317 | scChart.data.datasets[1].data = offset; 1318 | scChart.update(); 1319 | } 1320 | 1321 | } 1322 | 1323 | 1324 | /******************** 1325 | * HouseConsumption * 1326 | ********************/ 1327 | if (this.flows) { 1328 | this.updateNode(this.identifier + "-HouseConsumption", this.flows.sinks.house.total, "W"); 1329 | this.updateChart(this.charts.houseConsumption, DISPLAY_SOURCES, this.flows.sinks.house.sources); 1330 | if (this.dayStart) { 1331 | this.updateNode( 1332 | this.identifier + "-UsageTotal", 1333 | this.teslaAggregates.load.energy_imported - this.dayStart.house.import - this.carTotalToday(), 1334 | "Wh " + this.translate("today") 1335 | ); 1336 | this.updateNode( 1337 | this.identifier + "-UsageTotalYesterday", 1338 | this.yesterdayUsage - this.carTotalYesterday(), 1339 | "Wh " + this.translate("yesterday") 1340 | ) 1341 | this.makeNodeVisible(this.identifier + "-UsageTotal"); 1342 | this.makeNodeVisible(this.identifier + "-UsageTotalYesterday"); 1343 | } 1344 | } 1345 | 1346 | /******** 1347 | * Grid * 1348 | ********/ 1349 | if (this.flows) { 1350 | // Display/hide Storm Watch 1351 | let swNode = this.identifier + "-StormWatch"; 1352 | if (this.stormWatch) { 1353 | this.makeNodeVisible(swNode); 1354 | } 1355 | else { 1356 | this.makeNodeInvisible(swNode); 1357 | } 1358 | 1359 | // Various grid states 1360 | let directionNodeId = this.identifier + "-GridDirection"; 1361 | let inOutNodeId = this.identifier + "-GridInOut"; 1362 | let icon = document.getElementById(this.identifier + "-GridIcon"); 1363 | this.updateClass(icon, null, [ 1364 | "fa-long-arrow-alt-right", 1365 | "fa-long-arrow-alt-left", 1366 | "fa-times", 1367 | "bright", 1368 | "grid-error", 1369 | ]); 1370 | if (this.gridStatus != "SystemGridConnected") { 1371 | // Grid outage 1372 | this.updateText(directionNodeId, 1373 | this.translate( 1374 | this.gridStatus == "SystemTransitionToGrid" ? 1375 | "grid_transition" : 1376 | "grid_disconnected" 1377 | ), 1378 | true, "grid-error" 1379 | ); 1380 | this.updateClass(icon, ["fa-times", "grid-error"]); 1381 | this.makeNodeInvisible(inOutNodeId); 1382 | if (!this.gridOutageStart) { 1383 | this.gridOutageStart = Date.now(); 1384 | } 1385 | } 1386 | else if (this.flows.sources.grid.total >= 0.5) { 1387 | // Importing energy 1388 | this.updateText(directionNodeId, this.translate("grid_supply"), true, null, "grid-error") 1389 | this.updateNode(inOutNodeId, 1390 | this.flows.sources.grid.total, "W"); 1391 | this.updateClass(icon, ["fa-long-arrow-alt-right", "bright"]); 1392 | this.makeNodeVisible(inOutNodeId); 1393 | } 1394 | else if (this.flows.sinks.grid.total >= 0.5) { 1395 | this.updateText(directionNodeId, this.translate("grid_receive"), true, null, "grid-error") 1396 | this.updateNode(inOutNodeId, 1397 | this.flows.sinks.grid.total, "W"); 1398 | this.updateClass(icon, ["fa-long-arrow-alt-left", "bright"]); 1399 | this.makeNodeVisible(inOutNodeId); 1400 | } 1401 | else { 1402 | this.updateText(directionNodeId, this.translate("grid_idle"), true, null, "grid-error"); 1403 | this.makeNodeInvisible(inOutNodeId); 1404 | } 1405 | 1406 | if (this.dayStart) { 1407 | this.updateNode( 1408 | this.identifier + "-GridInToday", 1409 | this.teslaAggregates.site.energy_imported - this.dayStart.grid.import, 1410 | "Wh " + this.translate("import_today") 1411 | ); 1412 | this.updateNode( 1413 | this.identifier + "-GridInYesterday", 1414 | this.yesterdayImport, 1415 | "Wh " + this.translate("yesterday") 1416 | ); 1417 | this.updateNode( 1418 | this.identifier + "-GridOutToday", 1419 | this.teslaAggregates.site.energy_exported - this.dayStart.grid.export, 1420 | "Wh " + this.translate("export_today") 1421 | ); 1422 | this.updateNode( 1423 | this.identifier + "-GridOutYesterday", 1424 | this.yesterdayExport, 1425 | "Wh " + this.translate("yesterday") 1426 | ); 1427 | } 1428 | } 1429 | 1430 | /******************* 1431 | * Powerwall Meter * 1432 | *******************/ 1433 | if (this.teslaAggregates) { 1434 | let battery = this.teslaAggregates.battery.instant_power; 1435 | let targetId = this.identifier + "-PowerwallStatus"; 1436 | if (Math.abs(battery) > 20) { 1437 | this.updateNode( 1438 | targetId, 1439 | Math.abs(battery), 1440 | "W", 1441 | this.translate(battery > 0 ? "battery_supply" : "battery_charging") + " ", 1442 | false 1443 | ); 1444 | } 1445 | else { 1446 | this.updateText(targetId, this.translate("battery_standby")); 1447 | } 1448 | } 1449 | 1450 | /**************** 1451 | * Energy Flows * 1452 | ****************/ 1453 | let energyBar = this.charts.energyBar; 1454 | if (energyBar) { 1455 | energyBar.data.datasets[0].data = this.dataTotals(); 1456 | energyBar.update(); 1457 | } 1458 | }, 1459 | 1460 | dataTotals: function () { 1461 | let carTotal = this.carTotalToday(); 1462 | if (this.dayStart) { 1463 | let result = [ 1464 | // Grid out/in 1465 | [ 1466 | this.dayStart.grid.export - 1467 | this.teslaAggregates.site.energy_exported, 1468 | this.teslaAggregates.site.energy_imported - this.dayStart.grid.import 1469 | ], 1470 | // Battery out/in 1471 | [ 1472 | this.dayStart.battery.import - this.teslaAggregates.battery.energy_imported, 1473 | this.teslaAggregates.battery.energy_exported - 1474 | this.dayStart.battery.export 1475 | ], 1476 | // House out 1477 | [ 1478 | carTotal + this.dayStart.house.import - this.teslaAggregates.load.energy_imported, 1479 | 0 1480 | ], 1481 | // Car out - TODO 1482 | [ 1483 | -1 * carTotal, 1484 | 0 1485 | ], 1486 | // Solar in 1487 | [ 1488 | 0, 1489 | this.teslaAggregates.solar.energy_exported - 1490 | this.dayStart.solar.export 1491 | ] 1492 | ]; 1493 | if (!this.twcEnabled) { 1494 | result.splice(3, 1); 1495 | } 1496 | return result; 1497 | } 1498 | else { 1499 | return Array(5).fill(Array(2).fill(0)); 1500 | } 1501 | }, 1502 | 1503 | cachedCarTotal: null, 1504 | carTotalToday: function () { 1505 | if (Array.isArray(this.chargeHistory) && !this.cachedCarTotal) { 1506 | let midnight = luxon.DateTime.local().setZone(this.timezone).startOf('day'); 1507 | this.cachedCarTotal = this.chargeHistory.filter( 1508 | entry => luxon.DateTime.fromISO(entry.timestamp) >= midnight 1509 | ).reduce((total, next) => total + next.charger_power / 12, 0) 1510 | } 1511 | return this.cachedCarTotal || 0; 1512 | }, 1513 | cachedCarYesterday: null, 1514 | carTotalYesterday: function () { 1515 | if (Array.isArray(this.chargeHistory) && !this.cachedCarYesterday) { 1516 | let midnight = luxon.DateTime.local().setZone(this.timezone).startOf('day'); 1517 | let prevMidnight = midnight.minus({ "days": 1 }); 1518 | this.cachedCarYesterday = this.chargeHistory.filter( 1519 | entry => { 1520 | let date = luxon.DateTime.fromISO(entry.timestamp); 1521 | return date >= prevMidnight && date < midnight; 1522 | }).reduce((total, next) => total + next.charger_power / 12, 0); 1523 | } 1524 | return this.cachedCarYesterday || 0; 1525 | }, 1526 | 1527 | notificationReceived: function (notification, payload, sender) { 1528 | var self = this; 1529 | if (!sender) { 1530 | // Received from core system 1531 | if (notification === "MODULE_DOM_CREATED") { 1532 | // DOM has been created, so hook up the graph objects 1533 | self.buildGraphs() 1534 | } 1535 | } 1536 | if (notification === "USER_PRESENCE") { 1537 | if (payload) { 1538 | this.resume(); 1539 | } 1540 | else { 1541 | this.suspend(); 1542 | } 1543 | } 1544 | }, 1545 | 1546 | dayNumber: -1, 1547 | advanceDayMode: async function () { 1548 | let now = new Date(); 1549 | let self = this; 1550 | if (now.getDay() != this.dayNumber) { 1551 | this.dayNumber = now.getDay(); 1552 | this.sunrise = null; 1553 | this.sunset = null; 1554 | this.cachedCarYesterday = null; 1555 | this.cachedCarTotal = null; 1556 | 1557 | if (now.getHours() == 0 && now.getMinutes() == 0) { 1558 | // It's midnight 1559 | this.updateSelfConsumption(); 1560 | if (this.dayStart) { 1561 | this.yesterdaySolar = ( 1562 | this.teslaAggregates.solar.energy_exported - 1563 | this.dayStart.solar.export 1564 | ); 1565 | this.yesterdayUsage = ( 1566 | this.teslaAggregates.load.energy_imported - 1567 | this.dayStart.house.import 1568 | ); 1569 | this.yesterdayImport = ( 1570 | this.teslaAggregates.site.energy_imported - 1571 | this.dayStart.grid.import 1572 | ); 1573 | this.yesterdayExport = ( 1574 | this.teslaAggregates.site.energy_exported - 1575 | this.dayStart.grid.export 1576 | ); 1577 | } 1578 | this.dayStart = { 1579 | solar: { 1580 | export: this.teslaAggregates.solar.energy_exported 1581 | }, 1582 | grid: { 1583 | export: this.teslaAggregates.site.energy_exported, 1584 | import: this.teslaAggregates.site.energy_imported 1585 | }, 1586 | battery: { 1587 | export: this.teslaAggregates.battery.energy_exported, 1588 | import: this.teslaAggregates.battery.energy_imported 1589 | }, 1590 | house: { 1591 | import: this.teslaAggregates.load.energy_imported 1592 | } 1593 | }; 1594 | } 1595 | else { 1596 | this.dayStart = null; 1597 | } 1598 | } 1599 | 1600 | if (!this.dayStart) { 1601 | this.updateEnergy(); 1602 | } 1603 | 1604 | let riseset = {}; 1605 | if ((!this.sunrise || !this.sunset) && this.config.home) { 1606 | try { 1607 | let url = "https://api.sunrise-sunset.org/json?lat=" + 1608 | this.config.home[0] + "&lng=" + this.config.home[1] + 1609 | "&formatted=0&date=" + 1610 | [now.getFullYear(), now.getMonth() + 1, now.getDate()].join("-"); 1611 | let result = await fetch(url); 1612 | if (result.ok) { 1613 | let response = await result.json(); 1614 | for (const tag of ["sunrise", "sunset"]) { 1615 | if (response.results[tag]) { 1616 | riseset[tag] = Date.parse(response.results[tag]); 1617 | } 1618 | } 1619 | } 1620 | } 1621 | catch { } 1622 | } 1623 | this.sunrise = this.sunrise || riseset.sunrise || new Date().setHours(6, 0, 0, 0); 1624 | this.sunset = this.sunset || riseset.sunset || new Date().setHours(20, 30, 0, 0); 1625 | 1626 | now = now.getTime(); 1627 | if (now < this.sunrise) { 1628 | this.dayMode = "morning"; 1629 | } 1630 | else if (now > this.sunset) { 1631 | this.dayMode = "night"; 1632 | } 1633 | else { 1634 | this.dayMode = "day"; 1635 | } 1636 | 1637 | this.doTimeout("midnight", () => self.advanceDayMode(), new Date().setHours(24, 0, 0, 0) - now, true); 1638 | }, 1639 | 1640 | buildGraphs: function () { 1641 | this.Log("Rebuilding graphs"); 1642 | var self = this; 1643 | 1644 | Chart.register(ChartDataLabels); 1645 | Chart.helpers.merge(Chart.defaults, { 1646 | elements: { 1647 | arc: { 1648 | borderWidth: 0 1649 | } 1650 | }, 1651 | plugins: { 1652 | legend: { 1653 | display: false 1654 | }, 1655 | tooltip: { 1656 | enabled: false 1657 | }, 1658 | datalabels: { 1659 | color: "white", 1660 | textAlign: "center", 1661 | clip: false, 1662 | display: function (context) { 1663 | return context.dataset.data[context.dataIndex] >= 0.5 ? "auto" : false; 1664 | }, 1665 | labels: { 1666 | title: { 1667 | font: { 1668 | size: 16, 1669 | weight: "bold" 1670 | } 1671 | } 1672 | }, 1673 | formatter: function (value, context) { 1674 | return [ 1675 | context.dataset.labels[context.dataIndex], 1676 | self.formatAsK(value, "W") 1677 | ]; 1678 | } 1679 | } 1680 | } 1681 | }); 1682 | for (i of [Chart.overrides.doughnut, Chart.overrides.pie]) { 1683 | Chart.helpers.merge(i, { 1684 | responsive: true, 1685 | maintainAspectRatio: true, 1686 | aspectRatio: 1.12 1687 | }); 1688 | } 1689 | 1690 | for (const oldChart in this.charts) { 1691 | this.charts[oldChart].destroy(); 1692 | } 1693 | this.charts = {}; 1694 | 1695 | var myCanvas = document.getElementById(this.identifier + "-SolarDestinations"); 1696 | if (myCanvas) { 1697 | let distribution = this.flows ? this.flows.sources.solar.distribution : {}; 1698 | 1699 | // Build the chart on the canvas 1700 | var solarProductionPie = new Chart(myCanvas, { 1701 | type: "pie", 1702 | data: { 1703 | datasets: [ 1704 | { 1705 | data: DISPLAY_SINKS.map((entry) => distribution[entry.key]), 1706 | backgroundColor: DISPLAY_SINKS.map((entry) => entry.color), 1707 | weight: 2, 1708 | labels: DISPLAY_SINKS.map((entry) => this.translate(entry.key)) 1709 | }, 1710 | { 1711 | data: [1], 1712 | backgroundColor: SOLAR.color, 1713 | weight: 1, 1714 | showLine: false, 1715 | datalabels: { 1716 | labels: { 1717 | title: null, 1718 | value: null 1719 | } 1720 | } 1721 | } 1722 | ] 1723 | } 1724 | }); 1725 | this.charts.solarProduction = solarProductionPie; 1726 | } 1727 | 1728 | myCanvas = document.getElementById(this.identifier + "-HouseSources"); 1729 | if (myCanvas) { 1730 | let distribution = this.flows ? this.flows.sinks.house.sources : {}; 1731 | 1732 | // Build the chart on the canvas 1733 | var houseConsumptionPie = new Chart(myCanvas, { 1734 | type: "pie", 1735 | data: { 1736 | datasets: [ 1737 | { 1738 | data: DISPLAY_SOURCES.map((entry) => distribution[entry.key]), 1739 | backgroundColor: DISPLAY_SOURCES.map((entry) => entry.color), 1740 | weight: 2, 1741 | labels: DISPLAY_SOURCES.map((entry) => this.translate(entry.key)) 1742 | }, 1743 | { 1744 | data: [1], 1745 | backgroundColor: HOUSE.color, 1746 | weight: 1, 1747 | showLine: false, 1748 | datalabels: { 1749 | labels: { 1750 | title: null, 1751 | value: null 1752 | } 1753 | } 1754 | } 1755 | ] 1756 | } 1757 | }); 1758 | this.charts.houseConsumption = houseConsumptionPie; 1759 | } 1760 | 1761 | myCanvas = document.getElementById(this.identifier + "-SelfPoweredDetails"); 1762 | if (myCanvas) { 1763 | let offset = [0, 1]; 1764 | if (this.teslaAggregates && this.dayStart) { 1765 | offset = [ 1766 | this.teslaAggregates.solar.energy_exported - this.dayStart.solar.export, 1767 | this.teslaAggregates.load.energy_imported - this.dayStart.house.import 1768 | ]; 1769 | offset[1] -= Math.max(offset[0], 0); 1770 | } 1771 | let scSources = [SOLAR, POWERWALL, GRID]; 1772 | var selfConsumptionDoughnut = new Chart(myCanvas, { 1773 | type: "doughnut", 1774 | data: { 1775 | datasets: [ 1776 | { 1777 | data: this.selfConsumptionToday, 1778 | backgroundColor: scSources.map(entry => entry.color), 1779 | labels: scSources.map(entry => this.translate(entry.key)), 1780 | datalabels: { 1781 | formatter: function (value, context) { 1782 | return [ 1783 | context.dataset.labels[context.dataIndex], 1784 | Math.round(value) + "%" 1785 | ]; 1786 | } 1787 | }, 1788 | weight: 7 1789 | }, 1790 | { 1791 | data: offset, 1792 | backgroundColor: [SOLAR.color, "rgba(0,0,0,0)"], 1793 | datalabels: { 1794 | labels: { 1795 | title: null, 1796 | value: null 1797 | } 1798 | }, 1799 | weight: 1 1800 | }, 1801 | ] 1802 | }, 1803 | options: { 1804 | cutout: "60%" 1805 | } 1806 | }); 1807 | this.charts.selfConsumption = selfConsumptionDoughnut; 1808 | } 1809 | 1810 | myCanvas = document.getElementById(this.identifier + "-EnergyBar"); 1811 | if (myCanvas && this.teslaAggregates) { 1812 | let data = this.dataTotals(); 1813 | // Horizontal bar chart here 1814 | let energyBar = new Chart(myCanvas, { 1815 | type: 'bar', 1816 | data: { 1817 | labels: DISPLAY_ALL.map(entry => this.translate(entry.key)), 1818 | datasets: [{ 1819 | backgroundColor: DISPLAY_ALL.map(entry => entry.color), 1820 | borderColor: DISPLAY_ALL.map(entry => entry.color), 1821 | borderWidth: 1, 1822 | data: data 1823 | }] 1824 | }, 1825 | options: { 1826 | indexAxis: 'y', 1827 | // Elements options apply to all of the options unless overridden in a dataset 1828 | // In this case, we are setting the border of each horizontal bar to be 2px wide 1829 | elements: { 1830 | rectangle: { 1831 | borderWidth: 2, 1832 | } 1833 | }, 1834 | maintainAspectRatio: true, 1835 | aspectRatio: 1.7, 1836 | title: { 1837 | display: false 1838 | }, 1839 | plugins: { 1840 | datalabels: false, 1841 | annotation: { 1842 | drawTime: 'afterDatasetsDraw', 1843 | annotations: [{ 1844 | type: 'line', 1845 | mode: 'vertical', 1846 | scaleID: 'xAxis', 1847 | value: 0, 1848 | borderColor: 'black', 1849 | borderWidth: 0.5, 1850 | label: { 1851 | enabled: false 1852 | } 1853 | }] 1854 | } 1855 | }, 1856 | scales: { 1857 | xAxis: { 1858 | beginAtZero: true, 1859 | ticks: { 1860 | callback: function (value, index, values) { 1861 | if (value % 1000 == 0) { 1862 | return Math.abs(value) / 1000; 1863 | } 1864 | }, 1865 | color: "white", 1866 | precision: -3, 1867 | }, 1868 | suggestedMax: 1000, 1869 | suggestedMin: -1000, 1870 | title: { 1871 | display: true, 1872 | text: this.translate("energybar_label"), 1873 | color: "white" 1874 | } 1875 | }, 1876 | yAxis: { 1877 | ticks: { 1878 | color: "white" 1879 | } 1880 | } 1881 | } 1882 | } 1883 | }); 1884 | this.charts.energyBar = energyBar; 1885 | } 1886 | 1887 | myCanvas = document.getElementById(this.identifier + "-PowerLine"); 1888 | if (myCanvas) { 1889 | let data = this.processPowerHistory(); 1890 | let powerLine = new Chart(myCanvas, { 1891 | type: 'line', 1892 | data: data, 1893 | options: { 1894 | elements: { 1895 | point: { 1896 | radius: 0 1897 | } 1898 | }, 1899 | maintainAspectRatio: true, 1900 | aspectRatio: 1.7, 1901 | spanGaps: false, 1902 | title: { 1903 | display: false 1904 | }, 1905 | plugins: { 1906 | datalabels: false, 1907 | annotation: { 1908 | drawTime: 'afterDatasetsDraw', 1909 | annotations: [{ 1910 | type: 'line', 1911 | mode: 'horizontal', 1912 | scaleID: 'yAxis', 1913 | value: 0, 1914 | borderColor: 'black', 1915 | borderWidth: 0.5, 1916 | label: { 1917 | enabled: false 1918 | } 1919 | }] 1920 | } 1921 | }, 1922 | scales: { 1923 | xAxis: { 1924 | id: "xAxis", 1925 | type: "time", 1926 | min: luxon.DateTime.local().setZone(this.timezone).startOf('day').toString(), 1927 | max: luxon.DateTime.local().setZone(this.timezone).endOf('day').toString(), 1928 | ticks: { 1929 | color: "white", 1930 | autoSkipPadding: 10 1931 | } 1932 | }, 1933 | yAxis: { 1934 | id: "yAxis", 1935 | type: "linear", 1936 | ticks: { 1937 | callback: function (value, index, values) { 1938 | let clip = this._userMax; 1939 | value = Math.abs(value); 1940 | if (value % 1000 == 0 || value == clip) { 1941 | let result = value / 1000; 1942 | if (clip && value >= clip) { 1943 | result = ">" + result; 1944 | } 1945 | return result; 1946 | } 1947 | }, 1948 | color: "white", 1949 | precision: -3 1950 | }, 1951 | title: { 1952 | display: true, 1953 | text: this.translate("powerline_label"), 1954 | color: "white" 1955 | }, 1956 | stacked: true, 1957 | max: data.clip, 1958 | min: -1 * data.clip 1959 | } 1960 | } 1961 | } 1962 | }); 1963 | this.charts.powerLine = powerLine; 1964 | } 1965 | }, 1966 | 1967 | processPowerHistory: function () { 1968 | // { 1969 | // labels: DISPLAY_ALL.map(entry => entry.displayAs), 1970 | // datasets: [{ 1971 | // backgroundColor: DISPLAY_ALL.map(entry => entry.color), 1972 | // borderColor: DISPLAY_ALL.map(entry => entry.color), 1973 | // borderWidth: 1, 1974 | // data: data 1975 | // }] 1976 | // } 1977 | if (this.powerHistory) { 1978 | let self = this; 1979 | let lastMidnight = luxon.DateTime.local().setZone(this.timezone).startOf('day'); 1980 | let chargepoints = (this.chargeHistory || []).filter( 1981 | entry => luxon.DateTime.fromISO(entry.timestamp) >= lastMidnight 1982 | ) 1983 | let datapoints = this.powerHistory.filter( 1984 | entry => luxon.DateTime.fromISO(entry.timestamp) >= lastMidnight 1985 | ).map(function (entry, index) { 1986 | entry.charger_power = 0; 1987 | if (chargepoints[index]) { 1988 | if (chargepoints[index].timestamp !== entry.timestamp) { 1989 | self.Log("Date mismatch, " + chargepoints[index].timestamp + " vs. " + entry.timestamp); 1990 | } 1991 | entry.car_power = -1 * chargepoints[index].charger_power 1992 | } 1993 | else { 1994 | entry.car_power = 0; 1995 | } 1996 | return entry 1997 | }); 1998 | let entryVal = function (sample, entry, filter) { 1999 | if (sample) { 2000 | switch (entry.key) { 2001 | case "solar": 2002 | case "battery": 2003 | case "grid": 2004 | return filter(sample[entry.key + "_power"]); 2005 | case "car": 2006 | case "house": 2007 | // Positive 2008 | let housePlusCar = 2009 | sample.solar_power + 2010 | sample.battery_power + 2011 | sample.grid_power; 2012 | if (Math.abs(sample.car_power) > housePlusCar) { 2013 | // TWC claims to have delivered more power than 2014 | // Powerwall says house+car used in this period. 2015 | // 2016 | // Allocate everything to car charging, but this is still a bug. 2017 | return filter(entry.key == "car" ? -1 * housePlusCar : 0); 2018 | } 2019 | else { 2020 | return filter(entry.key == "car" ? sample.car_power : -1 * (housePlusCar + sample.car_power)); 2021 | } 2022 | default: 2023 | return 0; 2024 | } 2025 | } 2026 | else { 2027 | return 0; 2028 | } 2029 | } 2030 | 2031 | let process_dataset = (entry, filter) => { 2032 | return { 2033 | borderColor: entry.color, 2034 | borderWidth: 1, 2035 | order: { 2036 | house: 1, 2037 | car: 2, 2038 | solar: 3, 2039 | battery: 4, 2040 | grid: 5 2041 | }[entry.key], 2042 | fill: { 2043 | target: "origin", 2044 | above: entry.color, 2045 | below: entry.color 2046 | }, 2047 | data: datapoints. 2048 | map(sample => { 2049 | let val = entryVal(sample, entry, filter); 2050 | return val 2051 | }). 2052 | map((val, i, vals) => 2053 | (vals[i] || vals[i - 1] || vals[i + 1]) ? 2054 | Math.abs(val) >= 1 ? val : (filter(.0001) + filter(-.0001)) 2055 | : null) 2056 | }; 2057 | }; 2058 | let sources = DISPLAY_SOURCES.map(entry => process_dataset(entry, x => x > 0 ? x : 0)); 2059 | let sinks = DISPLAY_SINKS.map(entry => process_dataset(entry, x => x < 0 ? x : 0)); 2060 | let result = { 2061 | labels: datapoints.map(entry => entry.timestamp), 2062 | datasets: [ 2063 | ...sources, 2064 | ...sinks 2065 | ] 2066 | }; 2067 | let posTotal = sources.reduce( 2068 | (series, set) => series.map( 2069 | (element, index) => element + set.data[index] 2070 | ), 2071 | new Array(sources[0].data.length).fill(0) 2072 | ); 2073 | let max = Math.max(...posTotal); 2074 | if (max >= 5000 && this.config.powerlineClip !== false) { 2075 | let mean = this.average(posTotal); 2076 | let stddev = this.stddev(posTotal); 2077 | let exceptLastShown = series => { 2078 | let copySeries = [...series].sort((a, b) => a.order - b.order); 2079 | return posTotal.map( 2080 | (value, index) => { 2081 | let exceptLast = copySeries. 2082 | map(source => Math.abs(source.data[index])). 2083 | filter(e => e && e > 1). 2084 | slice(0, -1). 2085 | reduce((s, v) => s + v, 0) 2086 | return exceptLast < .9 * value ? exceptLast : 0; 2087 | } 2088 | ); 2089 | }; 2090 | if (max > (mean + 3 * stddev)) { 2091 | let clipLimit = Math.max( 2092 | ...posTotal.filter(value => value <= (mean + 2 * stddev)), 2093 | ...posTotal.slice(Math.max(posTotal.length - 5, 0)) 2094 | ); 2095 | if (this.config.powerlineClip === null) { 2096 | clipLimit = Math.max(clipLimit, 2097 | ...exceptLastShown(sources), 2098 | ...exceptLastShown(sinks), 2099 | ); 2100 | } 2101 | let scaleFactor; 2102 | if (clipLimit > 10000) { 2103 | scaleFactor = 5000; 2104 | } 2105 | else if (clipLimit > 5000) { 2106 | scaleFactor = 2000; 2107 | } 2108 | else { 2109 | scaleFactor = 1000; 2110 | } 2111 | clipLimit = Math.ceil(clipLimit / scaleFactor) * scaleFactor; 2112 | if (max > clipLimit) { 2113 | result.clip = clipLimit; 2114 | } 2115 | } 2116 | } 2117 | return result; 2118 | } 2119 | else { 2120 | return { 2121 | labels: [], 2122 | datasets: [], 2123 | clip: null 2124 | } 2125 | } 2126 | }, 2127 | 2128 | average: function (array) { 2129 | if (Array.isArray(array) && array.length > 0) { 2130 | let total = array.reduce((sum, value) => sum + value, 0); 2131 | return total / array.length; 2132 | } 2133 | else { 2134 | return NaN; 2135 | } 2136 | }, 2137 | 2138 | stddev: function (array) { 2139 | if (Array.isArray(array) && array.length > 0) { 2140 | let mean = this.average(array); 2141 | let diffsq = array.map(value => Math.pow(value - mean, 2)); 2142 | return Math.sqrt(this.average(diffsq)); 2143 | } 2144 | else { 2145 | return NaN; 2146 | } 2147 | }, 2148 | 2149 | attributeFlows: function (teslaAggregates, twcConsumption) { 2150 | if (teslaAggregates) { 2151 | let solar = Math.trunc(teslaAggregates.solar.instant_power); 2152 | if (solar < 5) { 2153 | solar = 0; 2154 | } 2155 | let battery = Math.trunc(teslaAggregates.battery.instant_power); 2156 | if (Math.abs(battery) <= 20) { 2157 | battery = 0; 2158 | } 2159 | let house = Math.trunc(teslaAggregates.load.instant_power); 2160 | let car = 0; 2161 | if (twcConsumption && twcConsumption <= house) { 2162 | car = twcConsumption; 2163 | house -= car; 2164 | } 2165 | let grid = teslaAggregates.site.instant_power; 2166 | 2167 | let flows = { 2168 | solar: { 2169 | unassigned: ((solar > 0) ? solar : 0), 2170 | battery: 0, 2171 | house: 0, 2172 | car: 0, 2173 | grid: 0 2174 | }, 2175 | battery: { 2176 | unassigned: ((battery > 0) ? battery : 0), 2177 | house: 0, 2178 | car: 0, 2179 | grid: 0, 2180 | battery: 0 2181 | }, 2182 | grid: { 2183 | unassigned: ((grid > 0) ? grid : 0), 2184 | battery: 0, 2185 | house: 0, 2186 | car: 0, 2187 | grid: 0 2188 | }, 2189 | unassigned: { 2190 | battery: ((battery < 0) ? Math.abs(battery) : 0), 2191 | house: house, 2192 | car: car, 2193 | grid: ((grid < 0) ? Math.abs(grid) : 0) 2194 | } 2195 | } 2196 | 2197 | for (const source of DISPLAY_SOURCES.map((value) => value.key)) { 2198 | for (const sink of DISPLAY_SINKS.map((value) => value.key)) { 2199 | let amount_to_claim = Math.min(flows[source].unassigned, flows.unassigned[sink]); 2200 | flows[source].unassigned -= amount_to_claim; 2201 | flows.unassigned[sink] -= amount_to_claim; 2202 | flows[source][sink] = amount_to_claim; 2203 | } 2204 | delete flows[source].unassigned; 2205 | } 2206 | 2207 | let result = { 2208 | sources: {}, 2209 | sinks: {} 2210 | } 2211 | 2212 | for (const source of DISPLAY_SOURCES) { 2213 | let target = {}; 2214 | let total = 0; 2215 | target.distribution = flows[source.key] 2216 | for (const sink of DISPLAY_SINKS) { 2217 | total += flows[source.key][sink.key]; 2218 | } 2219 | target.total = total; 2220 | result.sources[source.key] = target; 2221 | } 2222 | for (const sink of DISPLAY_SINKS) { 2223 | let target = {}; 2224 | let total = 0; 2225 | target.sources = {}; 2226 | for (const source of DISPLAY_SOURCES) { 2227 | total += flows[source.key][sink.key]; 2228 | target.sources[source.key] = flows[source.key][sink.key]; 2229 | } 2230 | target.total = total; 2231 | result.sinks[sink.key] = target; 2232 | } 2233 | return result; 2234 | } 2235 | else { 2236 | return null; 2237 | } 2238 | }, 2239 | 2240 | focusOnVehicles: async function (vehicles, numCharging) { 2241 | // Makes the "car status" tile focus on particular vehicles 2242 | // "vehicles" is a set 2243 | // "numCharging" indicates how many cars are charging 2244 | 2245 | if (!vehicles) { 2246 | return; 2247 | } 2248 | 2249 | // For the purposes of this function, it's sufficient to check length and equality of values 2250 | let areVehiclesDifferent = 2251 | vehicles.length !== this.displayVehicles.length || 2252 | vehicles.some((newVehicle, index) => newVehicle !== this.displayVehicles[index]) || 2253 | this.numCharging !== numCharging; 2254 | 2255 | if (this.numCharging !== numCharging || 2256 | this.vehicles.filter( 2257 | vehicle => vehicle.charge && 2258 | vehicle.charge.state === "Charging" && 2259 | this.isHome(vehicle.drive.location) 2260 | ).length !== numCharging) { 2261 | // If numCharging has changed, or if it disagrees with the Tesla API, refresh 2262 | this.numCharging = numCharging; 2263 | this.updateVehicleData(30000); 2264 | } 2265 | 2266 | if (areVehiclesDifferent) { 2267 | this.displayVehicles = vehicles; 2268 | await this.advanceToNextVehicle(); 2269 | } 2270 | }, 2271 | 2272 | advanceToNextVehicle: async function () { 2273 | let indexToFocus = (this.displayVehicles.indexOf(this.vehicleInFocus) + 1) % this.displayVehicles.length; 2274 | let focusSameVehicle = false; 2275 | if (this.displayVehicles.length === 0) { 2276 | indexToFocus = -1; 2277 | } 2278 | else { 2279 | focusSameVehicle = this.displayVehicles[indexToFocus] === this.vehicleInFocus; 2280 | } 2281 | 2282 | if (indexToFocus >= 0) { 2283 | let carTile = document.getElementById(this.identifier + "-CarTile"); 2284 | if (carTile) { 2285 | if (!focusSameVehicle) { 2286 | carTile.style.opacity = 0; 2287 | await this.delay(500); 2288 | } 2289 | this.vehicleInFocus = this.displayVehicles[indexToFocus]; 2290 | await this.drawStatusForVehicle(this.vehicleInFocus, this.numCharging, !focusSameVehicle); 2291 | if (!focusSameVehicle) { 2292 | carTile.style.opacity = 1; 2293 | } 2294 | } 2295 | } 2296 | else { 2297 | // Should only happen if TWCManager reports cars charging, but no cars are identified as charging 2298 | // Hopefully can resolve by polling for vehicle data more often, so we find the charging car. 2299 | // If it's a friend's car, this won't work. 2300 | this.updateVehicleData(30000); 2301 | } 2302 | }, 2303 | 2304 | delay: function (ms) { 2305 | return new Promise(resolve => setTimeout(resolve, ms)); 2306 | }, 2307 | 2308 | createCompositorUrl: function (config) { 2309 | let url = "https://static-assets.tesla.com/v1/compositor/?"; 2310 | let params = [ 2311 | "view=STUD_3QTR", 2312 | "size=400", 2313 | "bkba_opt=1" 2314 | ]; 2315 | let model_map = { 2316 | "models": "ms", 2317 | "modelx": "mx", 2318 | "model3": "m3", 2319 | "modely": "my" 2320 | }; 2321 | params.push("model=" + model_map[config.car_type]); 2322 | let options = []; 2323 | if (config.option_codes) { 2324 | options = config.option_codes.split(","); 2325 | } 2326 | 2327 | this.substituteOptions({ 2328 | "Pinwheel18": "W38B", 2329 | "AeroTurbine20": "WT20", 2330 | "Sportwheel19": "W39B", 2331 | "Stiletto19": "W39B", 2332 | "AeroTurbine19": "WTAS", 2333 | "Turbine19": "WTTB", 2334 | "Arachnid21Grey": "WTAB", 2335 | "Performancewheel20": "W32P", 2336 | "Stiletto20": "W32P", 2337 | "AeroTurbine22": "WT22", 2338 | "Super21Gray": "WTSG", 2339 | "Induction20Black": "WY20P", 2340 | "Gemini19": "WY19B", 2341 | }, config.wheel_type, options); 2342 | 2343 | this.substituteOptions({ 2344 | "ObsidianBlack": "PMBL", 2345 | "SolidBlack": "PMBL", 2346 | "MetallicBlack": "PMBL", 2347 | "DeepBlueMetallic": "PPSB", 2348 | "DeepBlue": "PPSB", 2349 | "RedMulticoat": "PPMR", 2350 | "Red": "PPMR", 2351 | "MidnightSilverMetallic": "PMNG", 2352 | "MidnightSilver": "PMNG", 2353 | "SteelGrey": "PMNG", 2354 | "SilverMetallic": "PMNG", 2355 | "MetallicBrown": "PMAB", 2356 | "Brown": "PMAB", 2357 | "Silver": "PMSS", 2358 | "TitaniumCopper": "PPTI", 2359 | "DolphinGrey": "PMTG", 2360 | "Green": "PMSG", 2361 | "MetallicGreen": "PMSG", 2362 | "PearlWhiteMulticoat": "PPSW", 2363 | "PearlWhite": "PPSW", 2364 | "Pearl": "PPSW", 2365 | "SolidWhite": "PBCW", 2366 | "White": "PBCW", 2367 | "SignatureBlue": "PMMB", 2368 | "MetallicBlue": "PMMB", 2369 | "SignatureRed": "PPSR", 2370 | "Quicksilver": "PN00", 2371 | "MidnightCherryRed": "PR00", 2372 | }, config.exterior_color, options); 2373 | 2374 | params.push("options=" + options.join(",")); 2375 | url += params.join("&"); 2376 | return url; 2377 | }, 2378 | 2379 | substituteOptions: function (map, value, options) { 2380 | if (map[value]) { 2381 | for (const option of Object.values(map)) { 2382 | let toRemove = options.indexOf(option); 2383 | if (toRemove >= 0) { 2384 | options.splice(toRemove, 1); 2385 | } 2386 | } 2387 | options.push(map[value]); 2388 | } 2389 | else { 2390 | this.Log("Unknown vehicle trait encountered: " + value); 2391 | } 2392 | } 2393 | }); 2394 | --------------------------------------------------------------------------------