diff --git a/CHANGELOG.md b/CHANGELOG.md index dd0eb0c7..853a2845 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ This document logs the changes per release of TWCManager. * Expose all time properties to the policy module for evaluation (thanks @MikeBishop) * Impovements to policy page in Web UI to show the value of policy parameters (thanks @MikeBishop) * Move grace period functionality for vehicles connected prior to policy evaluation to the master module, which opens the door to policy evaluation based on vehicle arrival/VIN (thanks @MikeBishop) + * Split and show the values of Charger Load and Other Load in console output when the Subtract Charger Load setting is enabled (thanks @mikey4321) + * Added EMS module support for SmartMe API * Bugfixes * Add a sleep of 5 seconds when waking car up to avoid an infinite loop (thanks @dschuesae) * Fix a bug with the legacy web interface which causes the Resume Track Green Energy setting of None to fail. Also added a deprecation notice to the web interface to ensure people don't inadvertently use it over the modular interface. diff --git a/README.md b/README.md index 0dd2bc3d..daf40451 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ ## Screenshots ![Screenshot](docs/screenshot.png) -![Screenshot](docs/screenshot2.png) +![Screenshot](docs/screenshot3.png) ## How it works diff --git a/TWCManager.py b/TWCManager.py index 38389f1d..bab41ac8 100755 --- a/TWCManager.py +++ b/TWCManager.py @@ -74,6 +74,7 @@ "Status.MQTTStatus", "Pricing.aWATTarPricing", "Pricing.StaticPricing", + "Pricing.PVPCesPricing", ] # Enable support for Python Visual Studio Debugger @@ -99,6 +100,10 @@ debugLog(1, "Unable to find a configuration file.") sys.exit() + + + +######################################################################## # All TWCs ship with a random two-byte TWCID. We default to using 0x7777 as our # fake TWC ID. There is a 1 in 64535 chance that this ID will match each real # TWC on the network, in which case you should pick a different random id below. @@ -242,6 +247,7 @@ def background_tasks_thread(master): elif task["cmd"] == "saveSettings": master.saveSettings() + except: master.debugLog( 1, @@ -288,7 +294,6 @@ def check_green_energy(): master.setGeneration(module["name"], module["ref"].getGeneration()) master.setMaxAmpsToDivideAmongSlaves(master.getMaxAmpsToDivideGreenEnergy()) - def update_statuses(): # Print a status update if we are on track green energy showing the diff --git a/docs/Settings.md b/docs/Settings.md index 731f1335..f5f1f6d0 100644 --- a/docs/Settings.md +++ b/docs/Settings.md @@ -37,3 +37,15 @@ The Do not Charge action states that outside of scheduled or Track Green Energy * Track Green Energy This is an option that currently does not operate (and will only set Non-Scheduled Charging rate to 0). In future, this will allow continuing of Track Green Energy behaviour outside of the hard-coded daylight hours (6am - 8pm). + +### Manual Tesla API key override + +In some instances, you may prefer to obtain the Tesla API keys yourself. The main benefit of this approach is that you do not need to provide your Tesla username or password to TWCManager. + +Another reason to use this feature might be as a temporary workaround if the Tesla authentication flow is changed or the TWCManager authentication function is faulty. + +Note: Providing your Tesla username and password to TWCManager to automatically fetch your Tesla API access and refresh tokens does not put your credentials at significant risk as they are only used once to fetch the token before being destroyed, however there may nonetheless be a preference not to provide these credentials at all. + +To obtain the key, you will need some knowledge of the Tesla API authentication flow. To assist with this, a link to a service which can assist you with this process is provided, however this does therefore require you to provide your credentials to that service. Otherwise, you may want to research the Tesla authentication flow and obtain the tokens yourself, or to obtain them from another application that you have previously authenticated to. + +Providing any value for the Access or Refresh tokens will result in the current stored tokens being overridden with the value you supply. We don't perform any validation of the tokens and the previous values are lost. Back up your settings.json file prior to entering your token manually if you need to revert your settings. diff --git a/docs/screenshot3.png b/docs/screenshot3.png new file mode 100644 index 00000000..a373884b Binary files /dev/null and b/docs/screenshot3.png differ diff --git a/etc/twcmanager/config.json b/etc/twcmanager/config.json index 6f659596..3aaf807d 100644 --- a/etc/twcmanager/config.json +++ b/etc/twcmanager/config.json @@ -32,6 +32,32 @@ # wiringMaxAmpsPerTWC = 50 * 0.8 = 40 and wiringMaxAmpsAllTWCs = 40 + 40 = 80. "wiringMaxAmpsPerTWC": 6, + + # If you what to limit the power drawn from the Grid you need to set this + # maxAmpsAllowedFromGrid and extend the policy you what it to apply, i.e.: + # { "name": "Charge Now with Grid power limit", + # "match": [ + # "settings.chargeNowAmps", + # "settings.chargeNowTimeEnd", + # "settings.chargeNowTimeEnd", + # ], + # "condition": ["gt", "gt", "gt"], + # "value": [0, 0, "now"], + # "background_task": "checkMaxPowerFromGrid", + # "charge_amps": "settings.chargeNowAmps", + # "charge_limit": "config.chargeNowLimit"}, + + # { "name": "Scheduled Charging with Grid power limit", + # "match": [ "checkScheduledCharging()" ], + # "condition": [ "eq" ], + # "value": [ 1 ], + # "background_task": "checkMaxPowerFromGrid", + # "charge_amps": "settings.scheduledAmpsMax", + # "charge_limit": "config.scheduledLimit"}, + + "maxAmpsAllowedFromGrid": 15, + + # https://teslamotorsclub.com/tmc/threads/model-s-gen2-charger-efficiency-testing.78740/#post-1844789 # says you're using 10.85% more power (91.75/82.77=1.1085) charging at 5A vs 40A, # 2.48% more power at 10A vs 40A, and 1.9% more power at 20A vs 40A. This is @@ -258,6 +284,25 @@ # # They should primarily be used to abort charging when necessary. "emergency":[ + { "name": "Charge Now with Grid power limit", + "match": [ + "settings.chargeNowAmps", + "settings.chargeNowTimeEnd", + "settings.chargeNowTimeEnd", + ], + "condition": ["gt", "gt", "gt"], + "value": [0, 0, "now"], + "background_task": "checkMaxPowerFromGrid", + "charge_amps": "settings.chargeNowAmps", + "charge_limit": "config.chargeNowLimit"}, + + { "name": "Scheduled Charging with Grid power limit", + "match": [ "checkScheduledCharging()" ], + "condition": [ "eq" ], + "value": [ 1 ], + "background_task": "checkMaxPowerFromGrid", + "charge_amps": "settings.scheduledAmpsMax", + "charge_limit": "config.scheduledLimit"}, ], # Rules in the before section here are evaluated after the Charge Now rule "before":[ @@ -423,7 +468,14 @@ "import": 0.20, "export": 0.09 } + }, + "PVPCes": { + # Enable this module if you are a customer under PVPC in Spain + # You need to get a personal token from https://api.esios.ree.es/ + "enabled": false, + "token": "xxx" } + }, "sources":{ # This section is where we configure the various sources that we retrieve our generation and consumption diff --git a/html/index.php b/html/index.php index 7e07326c..82bc8cb6 100755 --- a/html/index.php +++ b/html/index.php @@ -7,10 +7,6 @@ // 1 is just the most useful info. // 10 is all info. $debugLevel = 0; - - // Point $twcScriptDir to the directory containing the TWCManager.py script. - // Interprocess Communication with TWCManager.py will not work if this - // parameter is incorrect. $twcScriptDir = "/etc/twcmanager"; // End configuration parameters diff --git a/lib/TWCManager/Control/HTTPControl.py b/lib/TWCManager/Control/HTTPControl.py index 416703b6..84b62024 100644 --- a/lib/TWCManager/Control/HTTPControl.py +++ b/lib/TWCManager/Control/HTTPControl.py @@ -54,6 +54,7 @@ def __init__(self, master): def CreateHTTPHandlerClass(master): class HTTPControlHandler(BaseHTTPRequestHandler): ampsList = [] + kwhList = [] fields = {} hoursDurationList = [] master = None @@ -73,6 +74,12 @@ def __init__(self, *args, **kwargs): for amp in range(5, (master.config["config"].get("wiringMaxAmpsPerTWC", 5)) + 1): self.ampsList.append([amp, str(amp) + "A"]) + # Populate kwhList so that any function which requires a list of supported + # TWC kwh can easily access it + if not len(self.kwhList): + for kwh in range(40, 140): + self.kwhList.append([kwh, str(kwh) + "Kwh"]) + # Populate list of hours if not len(self.hoursDurationList): for hour in range(1, 25): @@ -104,6 +111,7 @@ def __init__(self, *args, **kwargs): # render HTML, we can keep using those even inside jinja2 self.templateEnv.globals.update(addButton=self.addButton) self.templateEnv.globals.update(ampsList=self.ampsList) + self.templateEnv.globals.update(kwhList=self.kwhList) self.templateEnv.globals.update(chargeScheduleDay=self.chargeScheduleDay) self.templateEnv.globals.update(doChargeSchedule=self.do_chargeSchedule) self.templateEnv.globals.update(hoursDurationList=self.hoursDurationList) @@ -126,8 +134,18 @@ def checkBox(self, name, value): return cb def do_chargeSchedule(self): + # For future days we use the name of the day with the suffix "next" schedule = [ "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" ] settings = master.settings.get("Schedule", {}) + pricesI = master.getWeekImportPrice() + if not pricesI: + pricesI={} + ltNow = time.localtime() + hourNow = ltNow.tm_hour + if ltNow.tm_wday<6: + wdayNow = ltNow.tm_wday+1 + else: + wdayNow = 0 page = """ @@ -139,20 +157,55 @@ def do_chargeSchedule(self): page += """ """ - for i in (x for y in (range(6, 24), range(0, 6)) for x in y): + for i in (x for y in (range(0, 8), range(8, 24)) for x in y): page += "" % (i) - for day in schedule: + for dayTn in range(0,7): + day=schedule[dayTn] + + energyOffset = int(master.queryGreenEnergyWhDay(day,i)) + ampsOffset = round(master.convertWattsToAmps(energyOffset),2) + futureColor="" + if pricesI.get("next"+day,None) != None: + day = "next"+day + futureColor = ";color:blue" + if dayTn == wdayNow and i >= hourNow: + futureColor = ";color:blue" + + if dayTn>0: + dayYn=schedule[dayTn-1] + else: + dayYn=schedule[6] today = settings.get(day, {}) + yesterday = settings.get(dayYn, {}) curday = settings.get("Common", {}) if (settings.get("schedulePerDay", 0)): curday = settings.get(day, {}) - if (today.get("enabled", None) == "on" and - (int(curday.get("start", 0)[:2]) <= int(i)) and - (int(curday.get("end", 0)[:2]) >= int(i))): - page += "" - else: + start = int(curday.get("start", "24:00")[:2]) + end = int(curday.get("end", "24:00")[:2]) + + price=0 + if pricesI.get(day,None) != None: + price = pricesI[day][str(i)] + + if ((today.get("enabled", None) == "on" and ( + (start < end and start <= i and end > i) or (start > end and start <= i))) + or (yesterday.get("enabled", None) == "on" and start > end and i < end)): + if price <= 0 and ampsOffset == 0 : + page += "" + elif ampsOffset == 0 : + page += "" + else: + page += "" + + else : #Todo - need to mark track green + non scheduled chg - page += "" + if price <= 0 and ampsOffset == 0 : + page += "" + elif ampsOffset == 0 : + page += "" + else: + page += "" + page += "" page += "" page += "
%02dSC @ " + str(settings.get("Settings", {}).get("scheduledAmpsMax", 0)) + "ASC@" + str(settings.get("Settings", {}).get("scheduledAmpsMax", 0)) + "ASC@" + str(settings.get("Settings", {}).get("scheduledAmpsMax", 0)) + "A "+str(price)+"€ SC@" + str(settings.get("Settings", {}).get("scheduledAmpsMax", 0)) + "A "+str(price)+"€ "+str(ampsOffset)+"A  "+str(price)+"€ "+str(price)+"€ "+str(ampsOffset)+"A
" @@ -504,10 +557,17 @@ def do_GET(self): self.do_API_GET() return + if self.url.path == "/teslaAccount/login": + # For security, these details should be submitted via a POST request + # Send a 405 Method Not Allowed in response. + self.send_response(405) + page = "This function may only be requested via the POST HTTP method." + self.wfile.write(page.encode("utf-8")) + return + if ( self.url.path == "/" - or self.url.path == "/apiacct/True" - or self.url.path == "/apiacct/False" + or self.url.path.startswith("/teslaAccount") ): self.send_response(200) self.send_header("Content-type", "text/html") @@ -578,14 +638,38 @@ def do_GET(self): self.wfile.write(page.encode("utf-8")) return - if self.url.path == "/tesla-login": - # For security, these details should be submitted via a POST request - # Send a 405 Method Not Allowed in response. - self.send_response(405) - page = "This function may only be requested via the POST HTTP method." + if self.url.path == "/graphs" or self.url.path == "/graphsP": + # We query the last 24h by default + now = datetime.now().replace(second=0, microsecond=0) + initial=now - timedelta(hours=24) + end= now + # It we came from a POST the dates should be already stored in settings + if self.url.path == "/graphs": + self.process_save_graphs(initial,end) + + self.send_response(200) + self.send_header("Content-type", "text/html") + self.end_headers() + # Load debug template and render + self.template = self.templateEnv.get_template("nographs.html.j2") + for module in master.getModulesByType("Logging"): + if module["ref"].greenEnergyQueryAvailable(): + self.template = self.templateEnv.get_template("graphs.html.j2") + break + + page = self.template.render(self.__dict__) self.wfile.write(page.encode("utf-8")) return + if self.url.path == "/graphs/date": + inicio=master.settings["Graphs"]["Initial"] + fin=master.settings["Graphs"]["End"] + + self.process_graphs(inicio,fin) + return + + + # All other routes missed, return 404 self.send_response(404) @@ -616,12 +700,25 @@ def do_POST(self): self.process_save_settings() return - if self.url.path == "/tesla-login": + if self.url.path == "/teslaAccount/login": # User has submitted Tesla login. # Pass it to the dedicated process_teslalogin function self.process_teslalogin() return + if self.url.path == "/graphs/dates": + # User has submitted dates to graph this period. + objIni = datetime.strptime(self.getFieldValue("dateIni"), "%Y-%m-%dT%H:%M") + objEnd = datetime.strptime(self.getFieldValue("dateEnd"), "%Y-%m-%dT%H:%M") + self.process_save_graphs(objIni,objEnd) + self.send_response(302) + self.send_header("Location", "/graphsP") + self.end_headers() + + self.wfile.write("".encode("utf-8")) + return + + # All other routes missed, return 404 self.send_response(404) self.end_headers() @@ -651,16 +748,29 @@ def chargeScheduleDay(self, day): page += "" + self.checkBox("enabled"+suffix, today.get("enabled", 0)) + "" page += "" + str(day) + "" - page += "" + self.optionList(self.timeList, - {"name": "start"+suffix, - "value": today.get("start", "00:00")}) + "" - page += " to " - page += "" + self.optionList(self.timeList, - {"name": "end"+suffix, - "value": today.get("end", "00:00")}) + "" + + if sched.get("schedulePerDay", 0): + page += "" + self.optionList(self.timeList, + {"name": "start"+suffix, + "value": today.get("start", "00:00")}) + "" + page += " to " + page += "" + self.optionList(self.timeList, + {"name": "end"+suffix, + "value": today.get("end", "00:00")}) + "" + page += "" + self.checkBox("flex"+suffix, today.get("flex", 0)) + "" page += "Flex Charge" + if master.getPricingInAdvanceAvailable(): + page += "" + self.checkBox("cheaper"+suffix, + today.get("cheaper", 0)) + "" + page += "Flex Cheaper" + if not today.get("flex", 0): + page += "" + self.optionList(self.hoursDurationList, + {"name": "actualH"+suffix, + "value": today.get("actualH", 1)}) + "" + page += "hours" + page += "" return page @@ -680,7 +790,7 @@ def log_message(self, format, *args): def optionList(self, list, opts={}): page = "
" - page += "" % ( opts.get("name", ""), opts.get("name", ""), ) @@ -688,7 +798,7 @@ def optionList(self, list, opts={}): sel = "" if str(opts.get("value", "-1")) == str(option[0]): sel = "selected" - page += "" % (option[0], sel, option[1]) + page += "" % (option[0], sel, option[1]) page += "" page += "
" return page @@ -709,11 +819,13 @@ def process_save_schedule(self): master.settings["Schedule"][day] = {} master.settings["Schedule"][day]["enabled"] = "" master.settings["Schedule"][day]["flex"] = "" + master.settings["Schedule"][day]["cheaper"] = "" + master.settings["Schedule"][day]["actualH"] = "" # Detect schedule keys. Rather than saving them in a flat # structure, we'll store them multi-dimensionally fieldsout = self.fields.copy() - ct = re.compile(r'(?Penabled|end|flex|start)(?P.*?)ChargeTime') + ct = re.compile(r'(?Penabled|end|flex|cheaper|actualH|start)(?P.*?)ChargeTime') for key in self.fields: match = ct.match(key) if match: @@ -745,6 +857,9 @@ def process_save_schedule(self): master.settings["scheduledAmpsStartHour"] = int(master.settings["Schedule"]["Common"]["start"][:2]) master.settings["scheduledAmpsEndHour"] = int(master.settings["Schedule"]["Common"]["end"][:2]) master.settings["scheduledAmpsMax"] = float(master.settings["Schedule"]["Settings"]["scheduledAmpsMax"]) + master.settings["flexBatterySize"] = float(master.settings["Schedule"]["Settings"]["flexBatterySize"]) + master.debugLog(10,"HTTP","scheduledAmpsMax: "+str(master.settings["scheduledAmpsMax"])) + master.debugLog(10,"HTTP","flexBatterySize: "+str(master.settings["flexBatterySize"])) # Scheduled Days bitmap backward compatibility master.settings["scheduledAmpsDaysBitmap"] = ( @@ -768,8 +883,22 @@ def process_save_schedule(self): def process_save_settings(self): - # Write settings + # This function will write the settings submitted from the settings + # page to the settings dict, before triggering a write of the settings + # to file for key in self.fields: + + # If the key relates to the car API tokens, we need to pass these + # to the appropriate module, rather than directly updating the + # configuration file (as it would just be overwritten) + if (key == "carApiBearerToken" or key == "carApiRefreshToken") and self.getFieldValue(key) != "": + carapi = master.getModuleByName("TeslaAPI") + if key == "carApiBearerToken": + carapi.setCarApiBearerToken(self.getFieldValue(key)) + elif key == "carApiRefreshToken": + carapi.setCarApiRefreshToken(self.getFieldValue(key)) + + # Write setting to dictionary master.settings[key] = self.getFieldValue(key) # If Non-Scheduled power action is either Do not Charge or @@ -810,7 +939,7 @@ def process_teslalogin(self): # Redirect to an index page with output based on the return state of # the function self.send_response(302) - self.send_header("Location", "/apiacct/" + str(ret)) + self.send_header("Location", "/teslaAccount/" + str(ret)) self.end_headers() self.wfile.write("".encode("utf-8")) return @@ -880,6 +1009,71 @@ def show_twcs(self): page += "" return page + def process_save_graphs(self,initial,end): + # Check that Graphs dict exists within settings. + # If not, this would indicate that this is the first time + # we have saved it + if (master.settings.get("Graphs", None) == None): + master.settings["Graphs"] = {} + master.settings["Graphs"]["Initial"]=initial + master.settings["Graphs"]["End"]=end + + return + + def process_graphs(self,init,end): + # This function will query the green_energy SQL table + result={} + try: + available=False + for module in master.getModulesByType("Logging"): + if module["ref"].greenEnergyQueryAvailable(): + result= module["ref"].queryGreenEnergy( + { + "dateBegin": init, + "dateEnd": end + } + ) + available = True + break + if not available: + return + + except Exception as e: + master.debugLog(1, + "HTTPCtrl", + "Excepcion queryGreenEnergy: " + + e, + ) + + data = {} + data[0] = { + "initial":init.strftime("%Y-%m-%dT%H:%M"), + "end":end.strftime("%Y-%m-%dT%H:%M"), + } + i=1 + while i + + + + diff --git a/lib/TWCManager/Control/themes/Default/graphs.html.j2 b/lib/TWCManager/Control/themes/Default/graphs.html.j2 new file mode 100644 index 00000000..8da8d4cc --- /dev/null +++ b/lib/TWCManager/Control/themes/Default/graphs.html.j2 @@ -0,0 +1,20 @@ + + + + TWCManager + {% include 'bootstrap.html.j2' %} + {% include 'drawChart.html.j2' %} + + + {% include 'navbar.html.j2' %} +
+

Initial Date: End Date: + + + +

+ +
+ + + diff --git a/lib/TWCManager/Control/themes/Default/main.html.j2 b/lib/TWCManager/Control/themes/Default/main.html.j2 index c493a47b..208a08c2 100644 --- a/lib/TWCManager/Control/themes/Default/main.html.j2 +++ b/lib/TWCManager/Control/themes/Default/main.html.j2 @@ -10,21 +10,37 @@ + + diff --git a/lib/TWCManager/Control/themes/Default/settings.html.j2 b/lib/TWCManager/Control/themes/Default/settings.html.j2 index 4d0421e0..15e28464 100644 --- a/lib/TWCManager/Control/themes/Default/settings.html.j2 +++ b/lib/TWCManager/Control/themes/Default/settings.html.j2 @@ -66,6 +66,21 @@ })|safe }} + + + + diff --git a/lib/TWCManager/Logging/CSVLogging.py b/lib/TWCManager/Logging/CSVLogging.py index a5896ae3..4a3483fa 100644 --- a/lib/TWCManager/Logging/CSVLogging.py +++ b/lib/TWCManager/Logging/CSVLogging.py @@ -165,3 +165,8 @@ def updateChargeSession(self, data): # Update the open charging session in memory. if data.get("vehicleVIN", None): self.openSessions[data["TWCID"]]["vehicleVIN"] = data.get("vehicleVIN", "") + + def greenEnergyQueryAvailable(self): + # ToDo + return None + diff --git a/lib/TWCManager/Logging/ConsoleLogging.py b/lib/TWCManager/Logging/ConsoleLogging.py index 3058fe8e..c80ebb4c 100644 --- a/lib/TWCManager/Logging/ConsoleLogging.py +++ b/lib/TWCManager/Logging/ConsoleLogging.py @@ -130,3 +130,8 @@ def updateChargeSession(self, data): # Called when additional information needs to be updated for a # charge session. For console output, we ignore this. return None + + def greenEnergyQueryAvailable(self): + # ToDo + return None + diff --git a/lib/TWCManager/Logging/FileLogging.py b/lib/TWCManager/Logging/FileLogging.py index 63f2eff0..7513ca01 100644 --- a/lib/TWCManager/Logging/FileLogging.py +++ b/lib/TWCManager/Logging/FileLogging.py @@ -136,3 +136,8 @@ def updateChargeSession(self, data): # Called when additional information needs to be updated for a # charge session. For console output, we ignore this. return None + + def greenEnergyQueryAvailable(self): + # ToDo + return None + diff --git a/lib/TWCManager/Logging/MySQLLogging.py b/lib/TWCManager/Logging/MySQLLogging.py index 29772920..ba143ed8 100644 --- a/lib/TWCManager/Logging/MySQLLogging.py +++ b/lib/TWCManager/Logging/MySQLLogging.py @@ -1,6 +1,7 @@ # MySQLLogging module. Provides output to a MySQL Server for regular statistics # recording. - +from datetime import datetime, timedelta +import time class MySQLLogging: @@ -89,7 +90,310 @@ def greenEnergy(self, data): 1, "MySQLLog", "Error updating MySQL database. Rows = %d" % rows ) self.db.rollback() + cur.close() + + whenToAcumulate = self.configLogging.get("whenToAcumulate", "false") + numberToAcumulate = self.configLogging.get("numberToAcumulate", 0) + if whenToAcumulate == "days": + dateToAcumulate = datetime.now() - timedelta(days=numberToAcumulate) + self.acumulateGreenEnergyDays(dateToAcumulate) + elif whenToAcumulate == "hours": + dateToAcumulate = datetime.now() - timedelta(hours=numberToAcumulate) + self.acumulateGreenEnergyHours(dateToAcumulate) + + return + + def acumulateGreenEnergyDays(self,date): + + inic = date.strftime("%Y-%m-%dT00:00:00") + endc = date.strftime("%Y-%m-%dT23:59:59") + + cur = self.db.cursor() + queryWh = """ + SELECT * from green_energy_wh where time>=%s and time<=%s order by time + """ + query = """ + SELECT * from green_energy where time>=%s and time<=%s order by time + """ + delete = """ + DELETE from green_energy where time>=%s and time<=%s + """ + insert = """ + INSERT into green_energy_wh values(%s,%s,%s,%s) + """ + rows = 0 + try: + rows = cur.execute(queryWh,(inic,endc,),) + if rows > 23: + return + + rows = cur.execute(query,(inic,endc,),) + + i = 1 + result = "" + if rows: + self.master.debugLog(10, "MySQLLog", "Date: "+date.strftime("%Y-%m-%dT00:00:00")+" #Registers to acumulate: "+str(rows)) + result = cur.fetchall() + + while i=%s and time<=%s order by time + """ + query = """ + SELECT * from green_energy where time>=%s and time<=%s order by time + """ + delete = """ + DELETE from green_energy where time>=%s and time<=%s + """ + insert = """ + INSERT into green_energy_wh values(%s,%s,%s,%s) + """ + rows = 0 + try: + rows = cur.execute(queryWh,(inic,endc,),) + if rows: + return + + rows = cur.execute(query,(inic,endc,),) + + i = 1 + result = "" + if rows: + self.master.debugLog(10, "MySQLLog", "Hour: "+date.strftime("%Y-%m-%dT%H:00:00")+" #Registers to acumulate: "+str(rows)) + result = cur.fetchall() + + genWA = 0 + conWA = 0 + chgWA = 0 + while i%s and time<%s + """ + cur = self.db.cursor() + rows = 0 + try: + rows = cur.execute( + query, + ( + data.get("dateBegin", 0), + data.get("dateEnd", 0), + ), + ) + except Exception as e: + self.master.debugLog(1, "MySQLLog", str(e)) + + result={} + if rows: + # Query was successful. Commit + result = cur.fetchall() + else: + # Issue, log message + self.master.debugLog( + 1, "MySQLLog", "Error query MySQL database. Rows = %d" % rows + ) + cur.close() + return list(result) + + def queryGreenEnergyWh(self, data): + # Check if this status is muted or acumulation is not active + if self.configLogging["mute"].get("GreenEnergy", 0) or self.configLogging.get("whenToAcumulate", 0)==0: + return None + # Ensure database connection is alive, or reconnect if not + self.db.ping(reconnect=True) + + query = """ + SELECT * from green_energy_wh where time>%s and time<%s + """ + cur = self.db.cursor() + rows = 0 + try: + rows = cur.execute( + query, + ( + data.get("dateBegin", 0), + data.get("dateEnd", 0), + ), + ) + except Exception as e: + self.master.debugLog(1, "MySQLLog", str(e)) + + result={} + if rows: + # Query was successful. Commit + result = cur.fetchall() + else: + # Issue, log message + self.master.debugLog( + 1, "MySQLLog", "Error query MySQL database. Rows = %d" % rows + ) + cur.close() + return list(result) + + def queryGreenEnergyWhDay(self, day,hour): + # Check if this status is muted or acumulation is not active + + if self.configLogging["mute"].get("GreenEnergy", 0) or self.configLogging.get("whenToAcumulate", 0)==0: + return 0 + daysNames = [ "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday" ] + # Ensure database connection is alive, or reconnect if not + self.db.ping(reconnect=True) + + query = """ + SELECT * from green_energy_wh where time=%s + """ + cur = self.db.cursor() + wDay=0 + for i in range(0,6): + dayN=daysNames[i] + if dayN == day: + wDay = i + ltNow = time.localtime() + if ltNow.tm_wday >= wDay: + delta = ltNow.tm_wday-wDay + else: + delta = 7-ltNow.tm_wday-wDay + + init = datetime.now() - timedelta(days=delta) + + inic = init.strftime("%Y-%m-%dT"+str(hour)+":00:00") + + rows = 0 + try: + rows = cur.execute( + query, + ( + inic, + ), + ) + except Exception as e: + self.master.debugLog(1, "MySQLLog", str(e)) + + result=0 + if rows: + # Query was successful. Commit + result = cur.fetchall() + genWh = result[0][1] + conWh = result[0][2] + chgWh = result[0][3] + result = conWh - genWh - chgWh + + cur.close() + return result + + + def queryEnergyNotAvailable(self, startHour,endHour): + # Check if this status is muted + if self.configLogging["mute"].get("GreenEnergy", 0) or self.configLogging.get("whenToAcumulate", 0)==0: + return None + + end = datetime.now() + # Use the last 7 days average + init = end - timedelta(days=7) + + inic = init.strftime("%Y-%m-%dT%H:00:00") + ende = end.strftime("%Y-%m-%dT%H:00:00") + + result= self.queryGreenEnergyWh( + { + "dateBegin": inic, + "dateEnd": ende + } + ) + + energy = 0 + numReg = 0 + i = 0 + while i 0: + energy = energy / numReg + else: + self.master.debugLog(10, "MySQLLog", "Average Energy not available: "+str(int(energy))) + + return int(energy) def slavePower(self, data): # Check if this status is muted @@ -244,3 +548,7 @@ def updateChargeSession(self, data): self.db.rollback() cur.close() return None + + def greenEnergyQueryAvailable(self): + return True + diff --git a/lib/TWCManager/Logging/SQLiteLogging.py b/lib/TWCManager/Logging/SQLiteLogging.py index a77f104f..0638c090 100644 --- a/lib/TWCManager/Logging/SQLiteLogging.py +++ b/lib/TWCManager/Logging/SQLiteLogging.py @@ -102,3 +102,8 @@ def updateChargeSession(self, data): cur.execute(query, (data.get("vehicleVIN", ""))) cur.close() return None + + def greenEnergyQueryAvailable(self): + # ToDo + return None + diff --git a/lib/TWCManager/Policy/Policy.py b/lib/TWCManager/Policy/Policy.py index 5a6e16b8..9c622a22 100644 --- a/lib/TWCManager/Policy/Policy.py +++ b/lib/TWCManager/Policy/Policy.py @@ -295,7 +295,9 @@ def policyIsGreen(self): if self.getPolicyByName(self.active_policy): return ( self.getPolicyByName(self.active_policy).get("background_task", "") - == "checkGreenEnergy" + == "checkGreenEnergy" or + self.getPolicyByName(self.active_policy).get("background_task", "") + == "checkMaxPowerFromGrid" ) return 0 diff --git a/lib/TWCManager/Pricing/PVPCesPricing.py b/lib/TWCManager/Pricing/PVPCesPricing.py new file mode 100644 index 00000000..35ce7287 --- /dev/null +++ b/lib/TWCManager/Pricing/PVPCesPricing.py @@ -0,0 +1,253 @@ +from datetime import datetime +from datetime import timedelta +import time + +class PVPCesPricing: + + import requests + import time + + # https://www.esios.ree.es/es/pvpc publishes at 20:30CET eveyday the prices for next day + # There is no limitation to fetch prices as it's updated onces a day + cacheTime = 1 + config = None + configConfig = None + configPvpc = None + exportPrice = 0 + fetchFailed = False + importPrice = 0 + lastFetch = 0 + status = False + timeout = 10 + headers = {} + weekImportPrice = {} + + def __init__(self, master): + + self.master = master + self.config = master.config + try: + self.configConfig = master.config["config"] + except KeyError: + self.configConfig = {} + + try: + self.configPvpc = master.config["pricing"]["PVPCes"] + except KeyError: + self.configPvpc = {} + + self.status = self.configPvpc.get("enabled", self.status) + self.debugLevel = self.configConfig.get("debugLevel", 0) + + token=self.configPvpc.get("token") + if self.status: + self.headers = { + 'Accept': 'application/json; application/vnd.esios-api-v1+json', + 'Content-Type': 'application/json', + 'Host': 'api.esios.ree.es', + 'Cookie': '', + } + self.headers['Authorization']="Token token="+token + + # Unload if this module is disabled or misconfigured + if not self.status: + self.master.releaseModule("lib.TWCManager.Pricing", self.__class__.__name__) + return None + + def getExportPrice(self): + + if not self.status: + self.master.debugLog( + 10, + "$PVPCes", + "PVPCes Pricing Module Disabled. Skipping getExportPrice", + ) + return 0 + + # Perform updates if necessary + self.update() + + # Return current export price + return float(self.exportPrice) + + def getImportPrice(self): + + if not self.status: + self.master.debugLog( + 10, + "$PVPCes", + "PVPCes Pricing Module Disabled. Skipping getImportPrice", + ) + return 0 + + # Perform updates if necessary + self.update() + + # Return current import price + return float(self.importPrice) + + def getWeekImportPrice(self): + + if not self.status: + self.master.debugLog( + 10, + "$PVPCes", + "PVPCes Pricing Module Disabled. Skipping getWeekImportPrice", + ) + return 0 + + # Perform updates if necessary + self.update() + + # Return current import price + return self.weekImportPrice + + + def update(self): + # Fetch the current pricing data from the https://www.esios.ree.es/es/pvpc API + self.fetchFailed = False + days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] + now=datetime.now() + lastweek=datetime.now() - timedelta(days=6) + tomorrow=datetime.now() + timedelta(days=1) + if self.lastFetch == 0 or (now.hour != self.lastFetch.hour): + # Cache not feched or was feched last hour, fetch values from API. + # we are going to fetch a week + tomorrow + ini=str(lastweek.year)+"-"+str(lastweek.month)+"-"+str(lastweek.day)+"T"+"00:00:00" + end=str(tomorrow.year)+"-"+str(tomorrow.month)+"-"+str(tomorrow.day)+"T"+"23:00:00" + + url = "https://api.esios.ree.es/indicators/1014?start_date="+ini+"&end_date="+end + + try: + r = self.requests.get(url,headers=self.headers, timeout=self.timeout) + except self.requests.exceptions.ConnectionError as e: + self.master.debugLog( + 4, + "$PVPCes", + "Error connecting to PVPCes API to fetch market pricing", + ) + self.fetchFailed = True + return False + + self.lastFetch= now + + try: + r.raise_for_status() + except self.requests.exceptions.HTTPError as e: + self.master.debugLog( + 4, + "$PVPCes", + "HTTP status " + + str(e.response.status_code) + + " connecting to PVPCes API to fetch market pricing", + ) + return False + + if r.json() and len(r.json()['indicator']['values']) >= 24*7: + #Update settings with the new prices info for week + self.weekImportPrice = {} + ltNow = time.localtime() + if ltNow.tm_wday < 5: + i=ltNow.tm_wday+2 + elif ltNow.tm_wday == 5: + i=1 + else: + i=0 + + try: + for day in range(0,8): + sufix = "" + if day > 6 and len(r.json()['indicator']['values'])>7*24: + #This is tomorrow we add the "next" sufix to the day name + sufix = "next" + elif day > 6: + break + if (self.weekImportPrice.get(sufix+days[i], None) == None): + self.weekImportPrice[sufix+days[i]] = {} + + for hour in range(0,24): + self.weekImportPrice[sufix+days[i]][str(hour)]= round(r.json()['indicator']['values'][day*24+hour]['value']/1000,5) + if i < 6: + i=i+1 + else: + i=0 + + self.importPrice = float( + r.json()['indicator']['values'][6*24+now.hour]['value'] + ) + # Convert MWh price to KWh + self.importPrice = round(self.importPrice / 1000,5) + + except Exception as e: + self.master.debugLog(4,"$PVPCes","Exception updating todays prices: "+str(e)) + else: + self.master.debugLog(4,"$PVPCes","Not enought info fetched") + + + def getCheapestStartHour(self,numHours,ini,end): + # Perform updates if necessary + self.master.debugLog(10,"PVPC","getCheapestStartHour: "+str(numHours)+" "+str(ini)+" "+str(end)) + self.update() + days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] + now=datetime.now() + ltNow = time.localtime() + if ltNow.tm_wday < 6: + today=days[ltNow.tm_wday+1] + if ltNow.tm_wday < 5: + tomorrow=days[ltNow.tm_wday+2] + else: + tomorrow=days[2] + else: + today=days[0] + tomorrow=days[1] + + + minPriceHstart=ini + ini = int(ini) + end = int(end) + + if len(self.weekImportPrice[today])>23: + try: + if end < ini: + # If the scheduled hours are bettween days we consider hours going from 0 to 47 + # tomorrow 1am will be 25 + end = 24 + end + i=ini + minPrice=999999999 + while i<=(end-numHours): + j=0 + priceH=0 + while j23: + price = float(self.weekImportPrice[tomorrow][str(indice-24)]) + else: + self.master.debugLog(10,"$PVPCes", "There is not enough price info") + return ini + priceH = priceH + price + + j=j+1 + if priceH 23: + minPriceHstart = minPriceHstart - 24 + + return minPriceHstart + + def getPricingInAdvanceAvailable(self): + return True + diff --git a/lib/TWCManager/Pricing/StaticPricing.py b/lib/TWCManager/Pricing/StaticPricing.py index 44ecaed9..d3dfef54 100644 --- a/lib/TWCManager/Pricing/StaticPricing.py +++ b/lib/TWCManager/Pricing/StaticPricing.py @@ -73,3 +73,12 @@ def getImportPrice(self): # Return current import price return float(self.importPrice) + def getWeekImportPrice(self): + # For future implementation + return 0 + + def getPricingInAdvanceAvailable(self): + # For future implementation + return 0 + return 0 + diff --git a/lib/TWCManager/Pricing/aWATTarPricing.py b/lib/TWCManager/Pricing/aWATTarPricing.py index 64f90f4a..8322298f 100644 --- a/lib/TWCManager/Pricing/aWATTarPricing.py +++ b/lib/TWCManager/Pricing/aWATTarPricing.py @@ -118,4 +118,13 @@ def update(self): "Exception during parsing aWATTar pricing", ) + def getWeekImportPrice(self): + # ToDo + return 0 + + + def getPricingInAdvanceAvailable(self): + # ToDo + return 0 + return 0 diff --git a/lib/TWCManager/TWCMaster.py b/lib/TWCManager/TWCMaster.py index deb7a8e2..7187e297 100644 --- a/lib/TWCManager/TWCMaster.py +++ b/lib/TWCManager/TWCMaster.py @@ -1,4 +1,4 @@ -#! /usr/bin/python3 +#! /usr/b from lib.TWCManager.TWCSlave import TWCSlave from datetime import datetime, timedelta @@ -52,6 +52,7 @@ class TWCMaster: "scheduledAmpsDaysBitmap": 0x7F, "scheduledAmpsEndHour": -1, "scheduledAmpsMax": 0, + "flexBatterySize": 100, "scheduledAmpsStartHour": -1, } slaveHeartbeatData = bytearray( @@ -103,11 +104,10 @@ def advanceHistorySnap(self): self.debugLog(10, "TWCMaster", "Exception in advanceHistorySnap: " + str(e)) def checkScheduledCharging(self): - # Check if we're within the hours we must use scheduledAmpsMax instead # of nonScheduledAmpsMax - blnUseScheduledAmps = 0 ltNow = time.localtime() + blnUseScheduledAmps = 0 hourNow = ltNow.tm_hour + (ltNow.tm_min / 60) timeSettings = self.getScheduledAmpsTimeFlex() startHour = timeSettings[0] @@ -120,6 +120,7 @@ def checkScheduledCharging(self): and endHour > -1 and daysBitmap > 0 ): + self.debugLog(10, "TWCMaster", "Schedule Charging Start: "+str(startHour)+" End: "+str(endHour)) if startHour > endHour: # We have a time like 8am to 7am which we must interpret as the # 23-hour period after 8am or before 7am. Since this case always @@ -145,6 +146,7 @@ def checkScheduledCharging(self): and (daysBitmap & (1 << ltNow.tm_wday)) ): blnUseScheduledAmps = 1 + return blnUseScheduledAmps def convertAmpsToWatts(self, amps): @@ -269,6 +271,69 @@ def getPricing(self): self.exportPricingValues[module["name"]] = module["ref"].getExportPrice() self.importPricingValues[module["name"]] = module["ref"].getImportPrice() + def getWeekImportPrice(self): + for module in self.getModulesByType("Pricing"): + if module["ref"].getWeekImportPrice(): + return module["ref"].getWeekImportPrice() + return 0 + + def getPricingInAdvanceAvailable(self): + for module in self.getModulesByType("Pricing"): + if module["ref"].getPricingInAdvanceAvailable(): + return True + return False + + def getScheduleChargingFromYesterday(self,dayName): + daysNames = [ "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" ] + ltNow = time.localtime() + startHour = self.getScheduledAmpsStartHour() + endHour = self.getScheduledAmpsEndHour() + if startHour < endHour or ltNow.tm_hour > endHour: + return dayName + + for i in range(0,6): + if daysNames[i] == dayName: + if i > 0: + yesterday = daysNames[i-1] + else: + yesterday = daysNames[6] + + return yesterday + + def getCheaperDayChargeTime(self,dayName): + daySchedule = self.getScheduleChargingFromYesterday(dayName) + return self.settings["Schedule"][daySchedule]["cheaper"] + + def getScheduledFlexStartDay(self,dayName): + daySchedule = self.getScheduleChargingFromYesterday(dayName) + return self.settings["Schedule"][daySchedule]["flex"] + + def getActualHDayChargeTime(self,dayName): + daySchedule = self.getScheduleChargingFromYesterday(dayName) + return self.settings["Schedule"][daySchedule]["actualH"] + + def getCheapestStartHour(self,numHours,ini,end): + # We take the first modul data + cheapestStartHour = ini + for module in self.getModulesByType("Pricing"): + cheapestStartHour = module["ref"].getCheapestStartHour(numHours,ini,end) + if cheapestStartHour != None: + break + cheapestStartHour= ini + return cheapestStartHour + + def queryGreenEnergyWhDay(self, day,hour): + # We take the first modul data + energyOffset = 0 + for module in self.getModulesByType("Logging"): + if module["ref"].greenEnergyQueryAvailable() != None: + energyOffset = module["ref"].queryGreenEnergyWhDay(day,hour) + if energyOffset != None: + break + energyOffset = 0 + return energyOffset + + def getScheduledAmpsDaysBitmap(self): return self.settings.get("scheduledAmpsDaysBitmap", 0x7F) @@ -289,22 +354,92 @@ def getScheduledAmpsMax(self): else: return 0 + def getFlexBatterySize(self): + schedkwh = int(self.settings.get("flexBatterySize", 0)) + if schedkwh > 0: + return schedkwh + else: + return 0 + + + def getActualHscheduledAmps(self): + return int(self.settings.get("actualHscheduledAmps", -1)) + def getScheduledAmpsStartHour(self): return int(self.settings.get("scheduledAmpsStartHour", -1)) + def getScheduledAmpsStartFlexHour(self): + return int(self.settings.get("scheduledAmpsStartFlexHour", -1)) + + def getScheduledAmpsCheaperFlex(self, startHour, endHour, daysBitmap): + startHourP = self.getScheduledAmpsStartHour() + endHourP = self.getScheduledAmpsEndHour() + + # adjust the charge start time to minimize the cost + if ( + not self.getPricingInAdvanceAvailable() + or self.getScheduledAmpsMax() < 0 + or startHour < 0 + or endHour < 0 + or daysBitmap <= 0 + ): + return (startHour, endHour, daysBitmap) + + daysNames = [ "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" ] + ltNow = time.localtime() + hourNow = ltNow.tm_hour + (ltNow.tm_min / 60) + if ltNow.tm_wday <6: + dayName = daysNames[ltNow.tm_wday+1] + else: + dayName = daysNames[0] + + if self.getCheaperDayChargeTime(dayName): + if self.getScheduledFlexStartDay(dayName): + #If flex start is active the actual charge duration will be taken from the flex period established + if startHour < endHour: + numHours = endHour-startHour + else: + numHours = 24-startHour+endHour + else: + #If flex start is not active the actual charge duration s taken from the scheduled flex cheaper hours config + numHours = self.getActualHDayChargeTime(dayName) + if numHours: + numHours = numHours/3600 + + if numHours: + cheapestStartHour = self.getCheapestStartHour(numHours,startHourP,endHourP) + startHour = cheapestStartHour + endHour = startHour+numHours + if endHour >= 24: + endHour = endHour - 24 + + return (startHour, endHour, daysBitmap) + def getScheduledAmpsTimeFlex(self): + daysNames = [ "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" ] + ltNow = time.localtime() startHour = self.getScheduledAmpsStartHour() + endHour = self.getScheduledAmpsEndHour() days = self.getScheduledAmpsDaysBitmap() + if ltNow.tm_wday < 6: + dayName = daysNames[ltNow.tm_wday+1] + else: + dayName = daysNames[0] + amps = self.getScheduledAmpsMax() if ( startHour >= 0 - and self.getScheduledAmpsFlexStart() + and amps > 0 + # The API HTTP does not seam to be implemented and flex is never set + #and self.getScheduledAmpsFlexStart() + and self.getScheduledFlexStartDay(dayName) and self.countSlaveTWC() == 1 + and days>0 ): # Try to charge at the end of the scheduled time slave = next(iter(self.slaveTWCs.values())) vehicle = slave.getLastVehicle() if vehicle != None: - amps = self.getScheduledAmpsMax() + amps = self.getRealAmpsAvailable(amps,startHour,endHour) watts = self.convertAmpsToWatts(amps) * self.getRealPowerFactor(amps) hoursForFullCharge = self.getScheduledAmpsBatterySize() / (watts / 1000) realChargeFactor = (vehicle.chargeLimit - vehicle.batteryLevel) / 100 @@ -323,13 +458,23 @@ def getScheduledAmpsTimeFlex(self): startHour = startHour + 24 # if startHour is smaller than the intial startHour, then it should begin beginn charging a day later # (if starting usually at 9pm and it calculates to start at 4am - it's already the next day) - if startHour < self.getScheduledAmpsDaysBitmap(): + if startHour < self.getScheduledAmpsStartHour(): + self.debugLog(10,"TWCMaster","cambiamos day bit map") days = self.rotl(days, 7) - return (startHour, self.getScheduledAmpsEndHour(), days) + + timeSettings = self.getScheduledAmpsCheaperFlex(startHour,endHour,days) + + self.setScheduledAmpsStartFlexHour(timeSettings[0]) + self.setScheduledAmpsEndFlexHour(timeSettings[1]) + + return timeSettings def getScheduledAmpsEndHour(self): return self.settings.get("scheduledAmpsEndHour", -1) + def getScheduledAmpsEndFlexHour(self): + return self.settings.get("scheduledAmpsEndFlexHour", -1) + def getScheduledAmpsFlexStart(self): return int(self.settings.get("scheduledAmpsFlexStart", False)) @@ -379,10 +524,15 @@ def getStatus(self): data["isGreenPolicy"] = "Yes" else: data["isGreenPolicy"] = "No" - - data["scheduledChargingStartHour"] = self.getScheduledAmpsStartHour() - data["scheduledChargingFlexStart"] = self.getScheduledAmpsTimeFlex()[0] - data["scheduledChargingEndHour"] = self.getScheduledAmpsEndHour() + if self.getScheduledAmpsStartHour() == self.getScheduledAmpsStartFlexHour(): + data["scheduledChargingStartHour"] = self.getScheduledAmpsStartHour() + else: + data["scheduledChargingStartHour"] = self.getScheduledAmpsStartFlexHour() + data["scheduledChargingFlexStart"] = self.getScheduledAmpsFlexStart() + if self.getScheduledAmpsEndHour() == self.getScheduledAmpsEndFlexHour(): + data["scheduledChargingEndHour"] = self.getScheduledAmpsEndHour() + else: + data["scheduledChargingEndHour"] = self.getScheduledAmpsEndFlexHour() scheduledChargingDays = self.getScheduledAmpsDaysBitmap() scheduledFlexTime = self.getScheduledAmpsTimeFlex() @@ -1245,6 +1395,12 @@ def setScheduledAmpsStartHour(self, hour): def setScheduledAmpsEndHour(self, hour): self.settings["scheduledAmpsEndHour"] = hour + def setScheduledAmpsStartFlexHour(self, hour): + self.settings["scheduledAmpsStartFlexHour"] = hour + + def setScheduledAmpsEndFlexHour(self, hour): + self.settings["scheduledAmpsEndFlexHour"] = hour + def setScheduledAmpsFlexStart(self, enabled): self.settings["scheduledAmpsFlexStart"] = enabled @@ -1370,15 +1526,42 @@ def getRealPowerFactor(self, amps): realPowerFactorMaxAmps = self.config["config"].get("realPowerFactorMaxAmps", 1) minAmps = self.config["config"]["minAmpsPerTWC"] maxAmps = self.config["config"]["wiringMaxAmpsAllTWCs"] + if minAmps == maxAmps: return realPowerFactorMaxAmps else: - return ( + return ( (amps - minAmps) / (maxAmps - minAmps) * (realPowerFactorMaxAmps - realPowerFactorMinAmps) ) + realPowerFactorMinAmps + + + def getRealAmpsAvailable(self, amps,startHour,endHour): + + daysNames = [ "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" ] + ampsAllowedFromGrid = self.config["config"].get("maxAmpsAllowedFromGrid", 0) + + #In case maxAmpsAllowedFromGrid is configured we need to evaluate how it impacts + #if there is historical info it will be use to establish the actual amps available + ampsOtherConsum = 0 + ampsAvailable = amps + if ampsAllowedFromGrid: + for module in self.getModulesByType("Logging"): + if module["ref"].greenEnergyQueryAvailable() != None: + energyOtherConsum = module["ref"].queryEnergyNotAvailable(startHour,endHour) + if energyOtherConsum != None: + ampsOtherComsum = self.convertWattsToAmps(energyOtherConsum) + ampsAvailable = ampsAllowedFromGrid - ampsOtherComsum + break + + if ampsAvailable > amps: + ampsAvailable = amps + + return ampsAvailable + + def rotl(self, num, bits): bit = num & (1 << (bits - 1)) num <<= 1 diff --git a/lib/TWCManager/Vehicle/TeslaAPI.py b/lib/TWCManager/Vehicle/TeslaAPI.py index a5d95dbf..c277c35d 100644 --- a/lib/TWCManager/Vehicle/TeslaAPI.py +++ b/lib/TWCManager/Vehicle/TeslaAPI.py @@ -1,16 +1,26 @@ +import base64 +import hashlib +import os +import re +import requests +import time +from urllib.parse import parse_qs + + class TeslaAPI: import json - import re - import requests - import time + authURL = "https://auth.tesla.com/oauth2/v3/authorize" + callbackURL = "https://auth.tesla.com/void/callback" carApiLastErrorTime = 0 carApiBearerToken = "" carApiRefreshToken = "" carApiTokenExpireTime = time.time() carApiLastStartOrStopChargeTime = 0 carApiLastChargeLimitApplyTime = 0 + clientID = "81527cff06843c8634fdc09e8ac0abefb46ac849f38fe1e431c2ef2106796384" + clientSecret = "c7257eb71a564034f9419ee651c7d0e5f7aa6bfbd18bafb5c5c033b093bb2fa3" lastChargeLimitApplied = 0 lastChargeCheck = 0 chargeUpdateInterval = 1800 @@ -18,7 +28,10 @@ class TeslaAPI: config = None debugLevel = 0 master = None + maxLoginRetries = 10 minChargeLevel = -1 + refreshURL = "https://owner-api.teslamotors.com/oauth/token" + verifier = "" # Transient errors are ones that usually disappear if we retry the car API # command a minute or less later. @@ -54,10 +67,202 @@ def addVehicle(self, json): self.carApiVehicles.append(CarApiVehicle(json, self, self.config)) return True + def apiLogin(self, email, password): + + # GET parameters are used both for phase 1 and phase 2 + params = None + + for attempt in range(self.maxLoginRetries): + + self.verifier = base64.urlsafe_b64encode(os.urandom(86)).rstrip(b"=") + challenge = base64.urlsafe_b64encode( + hashlib.sha256(self.verifier).digest() + ).rstrip(b"=") + state = ( + base64.urlsafe_b64encode(os.urandom(16)).rstrip(b"=").decode("utf-8") + ) + + params = ( + ("client_id", "ownerapi"), + ("code_challenge", challenge), + ("code_challenge_method", "S256"), + ("redirect_uri", self.callbackURL), + ("response_type", "code"), + ("scope", "openid email offline_access"), + ("state", state), + ) + + session = requests.Session() + resp = session.get(self.authURL, params=params) + + if resp.ok and "" in resp.text: + self.master.debugLog( + 6, + "TeslaAPI", + "Tesla Auth form fetch success, attempt: " + str(attempt), + ) + break + else: + self.master.debugLog( + 6, + "TeslaAPI", + "Tesla auth form fetch failed, attempt: " + str(attempt), + ) + + time.sleep(3) + else: + self.master.debugLog( + 2, + "TeslaAPI", + "Wasn't able to find authentication form after " + + str(attempt) + + " attempts", + ) + return "Phase1Error" + + csrf = re.search(r'name="_csrf".+value="([^"]+)"', resp.text).group(1) + transaction_id = re.search( + r'name="transaction_id".+value="([^"]+)"', resp.text + ).group(1) + + if not csrf or not transaction_id: + # These two parameters are required for Phase 1 (Authentication) auth + # If they are missing, we raise an appropriate error to the user's attention + return "Phase1Error" + + data = { + "_csrf": csrf, + "_phase": "authenticate", + "_process": "1", + "transaction_id": transaction_id, + "cancel": "", + "identity": email, + "credential": password, + } + + for attempt in range(self.maxLoginRetries): + resp = session.post( + self.authURL, params=params, data=data, allow_redirects=False + ) + if resp.ok and (resp.status_code == 302 or "<title>" in resp.text): + self.master.debugLog( + 2, + "TeslaAPI", + "Posted auth form successfully after " + str(attempt) + " attempts", + ) + break + time.sleep(3) + else: + self.master.debugLog( + 2, + "TeslaAPI", + "Wasn't able to post authentication form after " + + str(attempt) + + " attempts", + ) + return "Phase2Error" + + if resp.status_code == 200 and "/mfa/verify" in resp.text: + # This account is using MFA, redirect to MFA code entry page + return "MFA" + + try: + code = parse_qs(resp.headers["location"])[self.callbackURL + "?code"] + except KeyError: + return "Phase2ErrorTip" + + data = { + "grant_type": "authorization_code", + "client_id": "ownerapi", + "code_verifier": self.verifier.decode("utf-8"), + "code": code, + "redirect_uri": self.callbackURL, + } + + resp = session.post("https://auth.tesla.com/oauth2/v3/token", json=data) + access_token = resp.json()["access_token"] + + headers = {"authorization": "bearer " + access_token} + + data = { + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "client_id": self.clientID, + } + resp = session.post( + "https://owner-api.teslamotors.com/oauth/token", headers=headers, json=data + ) + try: + self.setCarApiBearerToken(resp.json()["access_token"]) + self.setCarApiRefreshToken(resp.json()["refresh_token"]) + self.setCarApiTokenExpireTime(time.time() + resp.json()["expires_in"]) + self.master.queue_background_task({"cmd": "saveSettings"}) + + except KeyError: + self.master.debugLog( + 2, + "TeslaAPI", + "ERROR: Can't access Tesla car via API. Please log in again via web interface.", + ) + self.updateCarApiLastErrorTime() + # Instead of just setting carApiLastErrorTime, erase tokens to + # prevent further authorization attempts until user enters password + # on web interface. I feel this is safer than trying to log in every + # ten minutes with a bad token because Tesla might decide to block + # remote access to your car after too many authorization errors. + self.setCarApiBearerToken("") + self.setCarApiRefreshToken("") + self.master.queue_background_task({"cmd": "saveSettings"}) + + def apiRefresh(self): + # Refresh tokens expire in 45 + # days when first issued, so we'll get a new token every 15 days. + headers = { + "accept": "application/json", + "Content-Type": "application/json", + } + data = { + "client_id": self.clientID, + "client_secret": self.clientSecret, + "grant_type": "refresh_token", + "refresh_token": self.getCarApiRefreshToken(), + } + req = None + try: + req = requests.post(self.refreshURL, headers=headers, json=data) + self.master.debugLog(2, "TeslaAPI", "Car API request" + str(req)) + apiResponseDict = self.json.loads(req.text) + except: + pass + + try: + self.master.debugLog( + 4, "TeslaAPI", "Car API auth response" + str(apiResponseDict) + ) + self.setCarApiBearerToken(apiResponseDict["access_token"]) + self.setCarApiRefreshToken(apiResponseDict["refresh_token"]) + self.setCarApiTokenExpireTime(now + apiResponseDict["expires_in"]) + self.master.queue_background_task({"cmd": "saveSettings"}) + + except KeyError: + self.master.debugLog( + 2, + "TeslaAPI", + "ERROR: Can't access Tesla car via API. Please log in again via web interface.", + ) + self.updateCarApiLastErrorTime() + # Instead of just setting carApiLastErrorTime, erase tokens to + # prevent further authorization attempts until user enters password + # on web interface. I feel this is safer than trying to log in every + # ten minutes with a bad token because Tesla might decide to block + # remote access to your car after too many authorization errors. + self.setCarApiBearerToken("") + self.setCarApiRefreshToken("") + self.master.queue_background_task({"cmd": "saveSettings"}) + def car_api_available( self, email=None, password=None, charge=None, applyLimit=None ): - now = self.time.time() + now = time.time() apiResponseDict = {} if self.getCarApiRetryRemaining(): @@ -87,88 +292,37 @@ def car_api_available( "Entering car_api_available - next step is to query Tesla API", ) - # Tesla car API info comes from https://timdorr.docs.apiary.io/ + # Authentiate to Tesla API if ( self.getCarApiBearerToken() == "" or self.getCarApiTokenExpireTime() - now < 30 * 24 * 60 * 60 ): - req = None - client_id = ( - "81527cff06843c8634fdc09e8ac0abefb46ac849f38fe1e431c2ef2106796384" - ) - client_secret = ( - "c7257eb71a564034f9419ee651c7d0e5f7aa6bfbd18bafb5c5c033b093bb2fa3" - ) - url = "https://owner-api.teslamotors.com/oauth/token" - headers = None - data = None - - # If we don't have a bearer token or our refresh token will expire in - # under 30 days, get a new bearer token. Refresh tokens expire in 45 - # days when first issued, so we'll get a new token every 15 days. if self.getCarApiRefreshToken() != "": headers = { "accept": "application/json", "Content-Type": "application/json", } data = { - "client_id": client_id, - "client_secret": client_secret, + "client_id": self.clientID, + "client_secret": self.clientSecret, "grant_type": "refresh_token", "refresh_token": self.getCarApiRefreshToken(), } self.master.debugLog(8, "TeslaAPI", "Attempting token refresh") + self.apiRefresh() elif email != None and password != None: - headers = { - "accept": "application/json", - "Content-Type": "application/json", - } - data = { - "client_id": client_id, - "client_secret": client_secret, - "grant_type": "password", - "email": email, - "password": password, - } self.master.debugLog(8, "TeslaAPI", "Attempting password auth") + ret = self.apiLogin(email, password) - if headers and data: - try: - req = self.requests.post(url, headers=headers, json=data) - self.master.debugLog(2, "TeslaAPI", "Car API request" + str(req)) - # Example response: - # b'{"access_token":"4720d5f980c9969b0ca77ab39399b9103adb63ee832014fe299684201929380","token_type":"bearer","expires_in":3888000,"refresh_token":"110dd4455437ed351649391a3425b411755a213aa815171a2c6bfea8cc1253ae","created_at":1525232970}' - - apiResponseDict = self.json.loads(req.text) - except: - pass - else: - self.master.debugLog(2, "TeslaAPI", "Car API request is empty") - - try: - self.master.debugLog( - 4, "TeslaAPI", "Car API auth response" + str(apiResponseDict) - ) - self.setCarApiBearerToken(apiResponseDict["access_token"]) - self.setCarApiRefreshToken(apiResponseDict["refresh_token"]) - self.setCarApiTokenExpireTime(now + apiResponseDict["expires_in"]) - except KeyError: - self.master.debugLog( - 2, - "TeslaAPI", - "ERROR: Can't access Tesla car via API. Please log in again via web interface.", - ) - self.updateCarApiLastErrorTime() - # Instead of just setting carApiLastErrorTime, erase tokens to - # prevent further authorization attempts until user enters password - # on web interface. I feel this is safer than trying to log in every - # ten minutes with a bad token because Tesla might decide to block - # remote access to your car after too many authorization errors. - self.setCarApiBearerToken("") - self.setCarApiRefreshToken("") - - self.master.queue_background_task({"cmd": "saveSettings"}) + # If any string is returned, we redirect to it. This helps with MFA login flow + if ( + str(ret) != "True" + and str(ret) != "False" + and str(ret) != "" + and str(ret) != "None" + ): + return ret if self.getCarApiBearerToken() != "": if self.getVehicleCount() < 1: @@ -178,7 +332,7 @@ def car_api_available( "Authorization": "Bearer " + self.getCarApiBearerToken(), } try: - req = self.requests.get(url, headers=headers) + req = requests.get(url, headers=headers) self.master.debugLog( 8, "TeslaAPI", "Car API cmd vehicles " + str(req) ) @@ -280,7 +434,7 @@ def car_api_available( "Authorization": "Bearer " + self.getCarApiBearerToken(), } try: - req = self.requests.post(url, headers=headers) + req = requests.post(url, headers=headers) self.master.debugLog( 8, "TeslaAPI", "Car API cmd wake_up" + str(req) ) @@ -359,7 +513,7 @@ def car_api_available( # fast and only a reboot of the Raspberry resultet in # possible reconnect to the API (even the Tesla App # couldn't connect anymore). - self.time.sleep(5) + time.sleep(5) if now - vehicle.firstWakeAttemptTime <= 31 * 60: # A car in offline state is presumably not connected # wirelessly so our wake_up command will not reach @@ -499,7 +653,7 @@ def car_api_available( # quickly after we send wake_up. I haven't seen a problem sending a # command immediately, but it seems safest to sleep 5 seconds after # waking before sending a command. - self.time.sleep(5) + time.sleep(5) return True @@ -547,7 +701,7 @@ def car_api_charge(self, charge): # Do not call this function directly. Call by using background thread: # queue_background_task({'cmd':'charge', 'charge':<True/False>}) - now = self.time.time() + now = time.time() apiResponseDict = {} if not charge: # Whenever we are going to tell vehicles to stop charging, set @@ -632,7 +786,7 @@ def car_api_charge(self, charge): # {'response': {'result': False, 'reason': 'could_not_wake_buses'}} # Waiting 2 seconds seems to consistently avoid the error, but let's # wait 5 seconds in case of hardware differences between cars. - self.time.sleep(5) + time.sleep(5) if charge: self.applyChargeLimit(self.lastChargeLimitApplied, checkArrival=True) @@ -647,7 +801,7 @@ def car_api_charge(self, charge): # Retry up to 3 times on certain errors. for _ in range(0, 3): try: - req = self.requests.post(url, headers=headers) + req = requests.post(url, headers=headers) self.master.debugLog( 8, "TeslaAPI", @@ -704,7 +858,7 @@ def car_api_charge(self, charge): + error + "' when trying to start charging. Try again in 1 minute.", ) - self.time.sleep(60) + time.sleep(60) foundKnownError = True break if foundKnownError: @@ -752,7 +906,7 @@ def car_api_charge(self, charge): # If all retries fail, we'll try again in a # minute because we set # carApiLastStartOrStopChargeTime = now earlier. - self.time.sleep(5) + time.sleep(5) continue else: # Start or stop charge failed with an error I @@ -809,7 +963,7 @@ def applyChargeLimit(self, limit, checkArrival=False, checkDeparture=False): ) return "error" - now = self.time.time() + now = time.time() if ( not checkArrival and not checkDeparture @@ -953,7 +1107,7 @@ def applyChargeLimit(self, limit, checkArrival=False, checkDeparture=False): # the vehicle sometimes refuses the start command because it's # "fully charged" under the old limit, but then continues to say # charging was stopped once the new limit is in place. - self.time.sleep(5) + time.sleep(5) if checkArrival: self.updateChargeAtHome() @@ -985,7 +1139,7 @@ def getCarApiRetryRemaining(self, vehicleLast=0): return 0 else: backoff = self.getCarApiErrorRetryMins() * 60 - lasterrortime = self.time.time() - lastError + lasterrortime = time.time() - lastError if lasterrortime >= backoff: return 0 else: @@ -1041,7 +1195,7 @@ def setCarApiTokenExpireTime(self, value): return True def updateCarApiLastErrorTime(self): - timestamp = self.time.time() + timestamp = time.time() self.master.debugLog( 8, "TeslaAPI", @@ -1054,14 +1208,14 @@ def updateCarApiLastErrorTime(self): return True def updateLastStartOrStopChargeTime(self): - self.carApiLastStartOrStopChargeTime = self.time.time() + self.carApiLastStartOrStopChargeTime = time.time() return True def updateChargeAtHome(self): for car in self.carApiVehicles: if car.atHome: car.update_charge() - self.lastChargeCheck = self.time.time() + self.lastChargeCheck = time.time() @property def numCarsAtHome(self): @@ -1069,11 +1223,11 @@ def numCarsAtHome(self): @property def minBatteryLevelAtHome(self): - if self.time.time() - self.lastChargeCheck > self.chargeUpdateInterval: - self.master.queue_background_task({"cmd":"checkCharge"}) + if time.time() - self.lastChargeCheck > self.chargeUpdateInterval: + self.master.queue_background_task({"cmd": "checkCharge"}) return min( [car.batteryLevel for car in self.carApiVehicles if car.atHome], - default=10000 + default=10000, ) @@ -1131,7 +1285,7 @@ def ready(self): if ( self.firstWakeAttemptTime == 0 - and self.time.time() - self.lastAPIAccessTime < 2 * 60 + and time.time() - self.lastAPIAccessTime < 2 * 60 ): # If it's been less than 2 minutes since we successfully woke this car, it # should still be awake. No need to check. It returns to sleep state about @@ -1171,7 +1325,7 @@ def get_car_api(self, url, checkReady=True, provesOnline=True): # Retry up to 3 times on certain errors. for _ in range(0, 3): try: - req = self.requests.get(url, headers=headers) + req = requests.get(url, headers=headers) self.carapi.master.debugLog( 8, "TeslaVehic", "Car API cmd " + url + " " + str(req) ) @@ -1205,7 +1359,7 @@ def get_car_api(self, url, checkReady=True, provesOnline=True): + error + "' when trying to get status. Try again in 1 minute.", ) - self.time.sleep(60) + time.sleep(60) foundKnownError = True break if foundKnownError: @@ -1221,7 +1375,7 @@ def get_car_api(self, url, checkReady=True, provesOnline=True): ): # Retry after 5 seconds. See notes in car_api_charge where # 'could_not_wake_buses' is handled. - self.time.sleep(5) + time.sleep(5) continue except (KeyError, TypeError): # This catches cases like trying to access @@ -1234,11 +1388,11 @@ def get_car_api(self, url, checkReady=True, provesOnline=True): + self.name + ". Will try again later.", ) - self.lastErrorTime = self.time.time() + self.lastErrorTime = time.time() return (False, None) if provesOnline: - self.lastAPIAccessTime = self.time.time() + self.lastAPIAccessTime = time.time() return (True, response) @@ -1247,7 +1401,7 @@ def update_location(self, cacheTime=60): url = "https://owner-api.teslamotors.com/api/1/vehicles/" url = url + str(self.ID) + "/data_request/drive_state" - now = self.time.time() + now = time.time() if now - self.lastDriveStatusTime < cacheTime: return True @@ -1266,7 +1420,7 @@ def update_charge(self): url = "https://owner-api.teslamotors.com/api/1/vehicles/" url = url + str(self.ID) + "/data_request/charge_state" - now = self.time.time() + now = time.time() if now - self.lastChargeStatusTime < 60: return True @@ -1274,7 +1428,7 @@ def update_charge(self): (result, response) = self.get_car_api(url) if result: - self.lastChargeStatusTime = self.time.time() + self.lastChargeStatusTime = time.time() self.chargeLimit = response["charge_limit_soc"] self.batteryLevel = response["battery_level"] self.timeToFullCharge = response["time_to_full_charge"] @@ -1285,7 +1439,7 @@ def apply_charge_limit(self, limit): if self.stopTryingToApplyLimit: return True - now = self.time.time() + now = time.time() if ( now - self.lastLimitAttemptTime <= 300 @@ -1309,7 +1463,7 @@ def apply_charge_limit(self, limit): for _ in range(0, 3): try: - req = self.requests.post(url, headers=headers, json=body) + req = requests.post(url, headers=headers, json=body) self.carapi.master.debugLog( 8, "TeslaVehic", "Car API cmd set_charge_limit " + str(req) ) @@ -1334,7 +1488,7 @@ def apply_charge_limit(self, limit): self.lastAPIAccessTime = now return True elif reason == "could_not_wake_buses": - self.time.sleep(5) + time.sleep(5) continue elif apiResponseDict["response"] == None: if "error" in apiResponseDict: @@ -1353,7 +1507,7 @@ def apply_charge_limit(self, limit): + error + "' when trying to set charge limit. Try again in 1 minute.", ) - self.time.sleep(60) + time.sleep(60) foundKnownError = True break if foundKnownError: diff --git a/svisorTWC.sh b/svisorTWC.sh new file mode 100755 index 00000000..dba0c589 --- /dev/null +++ b/svisorTWC.sh @@ -0,0 +1,24 @@ +PROGRAM=/usr/bin/python3.5 +PIDFILE=/etc/twcmanager/TWCManager.pid +TWCMANAGER_PATH=/home/pi/TWCManager + +while true +do + +if [ -f $PIDFILE ]; then + read PID <$PIDFILE + echo $PID + if [ -d /proc/$PID ] && [ "$(readlink -f /proc/$PID/exe)" = "$PROGRAM" ]; then + echo "done." + else + echo "PID not found, Starting..." + screen -dm -S TWCManager $TWCMANAGER_PATH/TWCManager.py + fi +else + echo "PID file not found "; echo $PIDFILE; echo ", Starting..." + screen -dm -S TWCManager $TWCMANAGER_PATH/TWCManager.py +fi +sleep 30 +done + +
- {% if url.path == "/apiacct/False" %} + {% if url.path == "/teslaAccount/False" %} Failed to log in to Tesla Account. Please check username and password and try again. + {% elif url.path == "/teslaAccount/MFA" %} + + Tesla MFA account login is not yet available, sorry! Check back shortly. + + {% elif url.path == "/teslaAccount/Phase1Error" %} + + Error encountered during Phase 1 (GET) of the Tesla Authentication process. + + {% elif url.path == "/teslaAccount/Phase2Error" or url.path == "/teslaAccount/Phase2ErrorTip" %} + + Error encountered during Phase 2 (POST) of the Tesla Authentication process. + {% if url.path == "/teslaAccount/Phase2ErrorTip" %} +

TIP: If login fails at this point, it could be due to a locked Tesla account from too many login attempts, even if the last attempt was the correct password. Try logging out and then into your Tesla account to verify

+ {% endif %} +
{% endif %} {% if not master.teslaLoginAskLater - and url.path != "/apiacct/True" %} + and url.path != "/teslaAccount/True" + and url.path != "/teslaAccount/MFA" %} {% if not apiAvailable %} {% include 'request_teslalogin.html.j2' %} {% endif %} {% endif %} - {% if url.path == "/apiacct/True" %} + {% if url.path == "/teslaAccount/True" %} Thank you, successfully fetched Tesla API token. {% endif %} diff --git a/lib/TWCManager/Control/themes/Default/navbar.html.j2 b/lib/TWCManager/Control/themes/Default/navbar.html.j2 index 3897f611..a441ae97 100644 --- a/lib/TWCManager/Control/themes/Default/navbar.html.j2 +++ b/lib/TWCManager/Control/themes/Default/navbar.html.j2 @@ -11,6 +11,7 @@ {{ navbarItem("/schedule", "Schedule")|safe }} {{ navbarItem("/settings", "Settings")|safe }} {{ navbarItem("/debug", "Debug")|safe }} + {{ navbarItem("/graphs", "Graphs")|safe }} {{ navbarItem("https://github.com/ngardiner/TWCManager", "GitHub")|safe }} v{{ master.version }} diff --git a/lib/TWCManager/Control/themes/Default/nographs.html.j2 b/lib/TWCManager/Control/themes/Default/nographs.html.j2 new file mode 100644 index 00000000..4566f369 --- /dev/null +++ b/lib/TWCManager/Control/themes/Default/nographs.html.j2 @@ -0,0 +1,11 @@ + + + + TWCManager + {% include 'bootstrap.html.j2' %} + + + {% include 'navbar.html.j2' %} + You need to activate a Logging module that allows to get historical green energy information (MySQL) + + diff --git a/lib/TWCManager/Control/themes/Default/request_teslalogin.html.j2 b/lib/TWCManager/Control/themes/Default/request_teslalogin.html.j2 index 2ab03dde..0d0ed655 100644 --- a/lib/TWCManager/Control/themes/Default/request_teslalogin.html.j2 +++ b/lib/TWCManager/Control/themes/Default/request_teslalogin.html.j2 @@ -1,4 +1,4 @@ -
+

Enter your email and password to allow TWCManager to start and stop Tesla vehicles you own from charging. These credentials are sent once to Tesla and are not stored. Credentials must be entered diff --git a/lib/TWCManager/Control/themes/Default/schedule.html.j2 b/lib/TWCManager/Control/themes/Default/schedule.html.j2 index 2ae9b9cf..1d8fbcfa 100644 --- a/lib/TWCManager/Control/themes/Default/schedule.html.j2 +++ b/lib/TWCManager/Control/themes/Default/schedule.html.j2 @@ -38,6 +38,17 @@ )|safe }}

  Scheduled Flex Battery Size: + {{ optionList( + kwhList, + { + "name": "flexBatterySize", + "value": 71, + }, + )|safe + }} +
Scheduled Charge Time:
Manual Tesla API key override (link): + + + + + + + + + +
Access TokenRefresh Token
+