├── .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 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
47 |
48 |
56 |
57 |
58 |
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 |
{{translations.stormwatch}}
49 |
50 |
54 |
58 |
59 | {%- elif graph == "PowerwallSelfPowered" -%}
60 |
61 |
72 |
73 |
74 |
75 |
76 | {{translations.selfpowered}}
77 |
78 |
79 |
80 |
81 | {%- elif graph == "CarCharging" -%}
82 |
101 | {%- elif graph == "EnergyBar" -%}
102 |
103 |
104 |
105 | {%- elif graph == "PowerLine" -%}
106 |
107 |
108 |
109 | {%- elif graph == "AuthNeeded" -%}
110 |
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 | 
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

64 | - Grid

65 | - PowerwallSelfPowered

66 | - SolarProduction

67 | - HouseConsumption

68 | - EnergyBar

69 | - PowerLine

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 |
--------------------------------------------------------------------------------