From dfa610949a8a11489d7579bafd9ba2c7a6c5ebc4 Mon Sep 17 00:00:00 2001 From: Nathan Gardiner Date: Thu, 21 Jan 2021 23:30:26 +1100 Subject: [PATCH 01/17] Add files via upload update screenshot --- docs/screenshot3.png | Bin 0 -> 54234 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/screenshot3.png diff --git a/docs/screenshot3.png b/docs/screenshot3.png new file mode 100644 index 0000000000000000000000000000000000000000..a373884b65726d6938626c4132728d2411f586d0 GIT binary patch literal 54234 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

mjGRa@wHj^|r(&DdkG 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 Date: Thu, 21 Jan 2021 23:31:16 +1100 Subject: [PATCH 02/17] Bump changelog and screenshot --- CHANGELOG.md | 2 ++ README.md | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) 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 6109f76f..ebcc9122 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 From 69903d5502b3c896abb59987cc237b35c5db8074 Mon Sep 17 00:00:00 2001 From: Nathan Gardiner Date: Sun, 24 Jan 2021 22:35:30 +1100 Subject: [PATCH 03/17] Removed confusing statement from WebUI --- html/index.php | 4 ---- 1 file changed, 4 deletions(-) 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 From 5a7c7f2729b61381ff4fe3b39e9f59f7ccc2fd11 Mon Sep 17 00:00:00 2001 From: Nathan Gardiner Date: Sat, 30 Jan 2021 15:20:30 +1100 Subject: [PATCH 04/17] Provide the ability to override the stored API bearer/refresh tokens to bypass authentication issues --- docs/Settings.md | 12 ++++++++++++ lib/TWCManager/Control/HTTPControl.py | 16 +++++++++++++++- .../Control/themes/Default/settings.html.j2 | 15 +++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) 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/lib/TWCManager/Control/HTTPControl.py b/lib/TWCManager/Control/HTTPControl.py index 3568acba..70732b34 100644 --- a/lib/TWCManager/Control/HTTPControl.py +++ b/lib/TWCManager/Control/HTTPControl.py @@ -757,8 +757,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 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 }} + + Manual Tesla API key override (link): + + + + + + + + + + +
Access TokenRefresh Token
+ +   From d9170a39b717ad38973d017b208f7cab4f08a0be Mon Sep 17 00:00:00 2001 From: Nathan Gardiner Date: Tue, 2 Feb 2021 23:37:35 +1100 Subject: [PATCH 05/17] Update Tesla API authentication to work with oAuth2 flow (no MFA support yet) --- lib/TWCManager/Vehicle/TeslaAPI.py | 320 ++++++++++++++++++++--------- 1 file changed, 222 insertions(+), 98 deletions(-) diff --git a/lib/TWCManager/Vehicle/TeslaAPI.py b/lib/TWCManager/Vehicle/TeslaAPI.py index a5d95dbf..98669956 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" + browserUA = "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" + 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,11 @@ class TeslaAPI: config = None debugLevel = 0 master = None + maxLoginRetries = 10 minChargeLevel = -1 + refreshURL = "https://owner-api.teslamotors.com/oauth/token" + teslaUA = "TeslaApp/3.10.9-433/adff2e065/android/10" + verifier = "" # Transient errors are ones that usually disappear if we retry the car API # command a minute or less later. @@ -54,10 +68,180 @@ def addVehicle(self, json): self.carApiVehicles.append(CarApiVehicle(json, self, self.config)) return True + def apiLogin(self, email, password): + + headers = { + "User-Agent": self.browserUA, + "x-tesla-user-agent": self.teslaUA, + "X-Requested-With": "com.teslamotors.tesla", + } + + 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, headers=headers, 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 0 + + csrf = re.search(r'name="_csrf".+value="([^"]+)"', resp.text).group(1) + transaction_id = re.search(r'name="transaction_id".+value="([^"]+)"', resp.text).group(1) + + 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, headers=headers, 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", + ) + + code = parse_qs(resp.headers["location"])[self.callbackURL + "?code"] + + headers = {"user-agent": self.browserUA, + "x-tesla-user-agent": self.teslaUA} + payload = { + "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", headers=headers, json=payload) + access_token = resp.json()["access_token"] + + headers["authorization"] = "bearer " + access_token + payload = { + "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=payload) + 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"]) + + 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 +271,28 @@ 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") - - 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"}) + self.apiLogin(email, password) if self.getCarApiBearerToken() != "": if self.getVehicleCount() < 1: @@ -178,7 +302,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 +404,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 +483,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 +623,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 +671,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 +756,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 +771,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 +828,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 +876,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 +933,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 +1077,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 +1109,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 +1165,7 @@ def setCarApiTokenExpireTime(self, value): return True def updateCarApiLastErrorTime(self): - timestamp = self.time.time() + timestamp = time.time() self.master.debugLog( 8, "TeslaAPI", @@ -1054,14 +1178,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,7 +1193,7 @@ def numCarsAtHome(self): @property def minBatteryLevelAtHome(self): - if self.time.time() - self.lastChargeCheck > self.chargeUpdateInterval: + 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], @@ -1131,7 +1255,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 +1295,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 +1329,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 +1345,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 +1358,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 +1371,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 +1390,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 +1398,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 +1409,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 +1433,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 +1458,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 +1477,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: From 3ee4dfb56070e9cb15dc07c389d23aa4342fdc42 Mon Sep 17 00:00:00 2001 From: Nathan Gardiner <ngardiner@gmail.com> Date: Wed, 3 Feb 2021 14:21:08 +1100 Subject: [PATCH 06/17] Remove spoofed UA headers from requests (not needed), standardise variables used with requests, update the login flow to allow branching based on specific errors or MFA code requests (not yet implemented), and handle locked Tesla accounts without crashing --- lib/TWCManager/Control/HTTPControl.py | 23 ++++---- .../Control/themes/Default/main.html.j2 | 22 ++++++-- .../themes/Default/request_teslalogin.html.j2 | 2 +- lib/TWCManager/Vehicle/TeslaAPI.py | 52 ++++++++++++------- 4 files changed, 64 insertions(+), 35 deletions(-) diff --git a/lib/TWCManager/Control/HTTPControl.py b/lib/TWCManager/Control/HTTPControl.py index 70732b34..18bff965 100644 --- a/lib/TWCManager/Control/HTTPControl.py +++ b/lib/TWCManager/Control/HTTPControl.py @@ -493,10 +493,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") @@ -567,14 +574,6 @@ 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." - self.wfile.write(page.encode("utf-8")) - return - # All other routes missed, return 404 self.send_response(404) @@ -605,7 +604,7 @@ 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() @@ -813,7 +812,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 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 @@ <table border='0' padding='0' margin='0' width='100%'> <tr width='100%'> <td valign='top' width='70%'> - {% if url.path == "/apiacct/False" %} + {% if url.path == "/teslaAccount/False" %} <font color='red'> <b>Failed to log in to Tesla Account. Please check username and password and try again.</b> </font> + {% elif url.path == "/teslaAccount/MFA" %} + <font color='red'> + <b>Tesla MFA account login is not yet available, sorry! Check back shortly.</b> + </font> + {% elif url.path == "/teslaAccount/Phase1Error" %} + <font color='red'> + <b>Error encountered during Phase 1 (GET) of the Tesla Authentication process.</b> + </font> + {% elif url.path == "/teslaAccount/Phase2Error" or url.path == "/teslaAccount/Phase2ErrorTip" %} + <font color='red'> + <b>Error encountered during Phase 2 (POST) of the Tesla Authentication process.</b> + {% if url.path == "/teslaAccount/Phase2ErrorTip" %} + <p><b>TIP: If login fails at this point, it <i>could</i> 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</b></p> + {% endif %} + </font> {% endif %} {% if not master.teslaLoginAskLater - and url.path != "/apiacct/True" %} + and url.path != "/teslaAccount/True" + and url.path != "/teslaAccount/MFA" %} <!-- Check if we have already stored the Tesla credentials If we can access the Tesla API okay, don't prompt --> {% if not apiAvailable %} {% include 'request_teslalogin.html.j2' %} {% endif %} {% endif %} - {% if url.path == "/apiacct/True" %} + {% if url.path == "/teslaAccount/True" %} <b>Thank you, successfully fetched Tesla API token.</b> {% endif %} 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 @@ -<form action='/tesla-login' method='POST'> +<form action='/teslaAccount/login' method='POST'> <p>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/Vehicle/TeslaAPI.py b/lib/TWCManager/Vehicle/TeslaAPI.py index 98669956..ea1d0814 100644 --- a/lib/TWCManager/Vehicle/TeslaAPI.py +++ b/lib/TWCManager/Vehicle/TeslaAPI.py @@ -11,7 +11,6 @@ class TeslaAPI: import json authURL = "https://auth.tesla.com/oauth2/v3/authorize" - browserUA = "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" callbackURL = "https://auth.tesla.com/void/callback" carApiLastErrorTime = 0 carApiBearerToken = "" @@ -31,7 +30,6 @@ class TeslaAPI: maxLoginRetries = 10 minChargeLevel = -1 refreshURL = "https://owner-api.teslamotors.com/oauth/token" - teslaUA = "TeslaApp/3.10.9-433/adff2e065/android/10" verifier = "" # Transient errors are ones that usually disappear if we retry the car API @@ -70,11 +68,8 @@ def addVehicle(self, json): def apiLogin(self, email, password): - headers = { - "User-Agent": self.browserUA, - "x-tesla-user-agent": self.teslaUA, - "X-Requested-With": "com.teslamotors.tesla", - } + # GET parameters are used both for phase 1 and phase 2 + params = None for attempt in range(self.maxLoginRetries): @@ -93,7 +88,7 @@ def apiLogin(self, email, password): ) session = requests.Session() - resp = session.get(self.authURL, headers=headers, params=params) + resp = session.get(self.authURL, params=params) if resp.ok and "<title>" in resp.text: self.master.debugLog( @@ -116,11 +111,16 @@ def apiLogin(self, email, password): "TeslaAPI", "Wasn't able to find authentication form after " + str(attempt) + " attempts", ) - return 0 + 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", @@ -133,7 +133,7 @@ def apiLogin(self, email, password): for attempt in range(self.maxLoginRetries): resp = session.post( - self.authURL, headers=headers, params=params, data=data, allow_redirects=False + 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( @@ -149,12 +149,18 @@ def apiLogin(self, email, password): "TeslaAPI", "Wasn't able to post authentication form after " + str(attempt) + " attempts", ) + return "Phase2Error" - code = parse_qs(resp.headers["location"])[self.callbackURL + "?code"] + 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" - headers = {"user-agent": self.browserUA, - "x-tesla-user-agent": self.teslaUA} - payload = { + data = { "grant_type": "authorization_code", "client_id": "ownerapi", "code_verifier": self.verifier.decode("utf-8"), @@ -162,15 +168,18 @@ def apiLogin(self, email, password): "redirect_uri": self.callbackURL, } - resp = session.post("https://auth.tesla.com/oauth2/v3/token", headers=headers, json=payload) + resp = session.post("https://auth.tesla.com/oauth2/v3/token", json=data) access_token = resp.json()["access_token"] - headers["authorization"] = "bearer " + access_token - payload = { + 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=payload) + 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"]) @@ -221,6 +230,7 @@ def apiRefresh(self): 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( @@ -292,7 +302,11 @@ def car_api_available( elif email != None and password != None: self.master.debugLog(8, "TeslaAPI", "Attempting password auth") - self.apiLogin(email, password) + ret = self.apiLogin(email, password) + + # 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: From 872cf7e00d9db905b1619d387538cc889498075c Mon Sep 17 00:00:00 2001 From: Mike Bishop <mbishop@evequefou.be> Date: Wed, 3 Feb 2021 11:16:14 -0500 Subject: [PATCH 07/17] Black on TeslaAPI.py --- lib/TWCManager/Vehicle/TeslaAPI.py | 42 +++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/lib/TWCManager/Vehicle/TeslaAPI.py b/lib/TWCManager/Vehicle/TeslaAPI.py index ea1d0814..c277c35d 100644 --- a/lib/TWCManager/Vehicle/TeslaAPI.py +++ b/lib/TWCManager/Vehicle/TeslaAPI.py @@ -6,6 +6,7 @@ import time from urllib.parse import parse_qs + class TeslaAPI: import json @@ -74,8 +75,12 @@ def apiLogin(self, email, password): 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") + 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"), @@ -109,12 +114,16 @@ def apiLogin(self, email, password): self.master.debugLog( 2, "TeslaAPI", - "Wasn't able to find authentication form after " + str(attempt) + " attempts", + "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) + 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 @@ -147,7 +156,9 @@ def apiLogin(self, email, password): self.master.debugLog( 2, "TeslaAPI", - "Wasn't able to post authentication form after " + str(attempt) + " attempts", + "Wasn't able to post authentication form after " + + str(attempt) + + " attempts", ) return "Phase2Error" @@ -159,7 +170,7 @@ def apiLogin(self, email, password): code = parse_qs(resp.headers["location"])[self.callbackURL + "?code"] except KeyError: return "Phase2ErrorTip" - + data = { "grant_type": "authorization_code", "client_id": "ownerapi", @@ -171,15 +182,15 @@ def apiLogin(self, email, password): resp = session.post("https://auth.tesla.com/oauth2/v3/token", json=data) access_token = resp.json()["access_token"] - headers = { - "authorization": "bearer " + 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) + 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"]) @@ -305,7 +316,12 @@ def car_api_available( ret = self.apiLogin(email, password) # 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": + if ( + str(ret) != "True" + and str(ret) != "False" + and str(ret) != "" + and str(ret) != "None" + ): return ret if self.getCarApiBearerToken() != "": @@ -1208,10 +1224,10 @@ def numCarsAtHome(self): @property def minBatteryLevelAtHome(self): if time.time() - self.lastChargeCheck > self.chargeUpdateInterval: - self.master.queue_background_task({"cmd":"checkCharge"}) + self.master.queue_background_task({"cmd": "checkCharge"}) return min( [car.batteryLevel for car in self.carApiVehicles if car.atHome], - default=10000 + default=10000, ) From e6547227db8131b59bb9c21238cbe12ab8bd5e2a Mon Sep 17 00:00:00 2001 From: juanjoqg <juanjoqg@gmail.com> Date: Wed, 3 Feb 2021 23:21:33 +0100 Subject: [PATCH 08/17] New feature that allows to limit the max power TWC will take from the Grid q --- TWCManager.py | 30 +++++++++++++ etc/twcmanager/config.json | 45 +++++++++++++++++++ .../Control/themes/Default/jsrefresh.html.j2 | 2 +- lib/TWCManager/TWCMaster.py | 38 ++++++++++++++++ setup.py | 4 +- svisorTWC.sh | 20 +++++++++ 6 files changed, 136 insertions(+), 3 deletions(-) create mode 100755 svisorTWC.sh diff --git a/TWCManager.py b/TWCManager.py index 63b76607..ba662dc8 100755 --- a/TWCManager.py +++ b/TWCManager.py @@ -97,6 +97,15 @@ debugLog(1, "Unable to find a configuration file.") sys.exit() + + + +######################################################################## +# Write the PID in order to let a supervisor restart it in case of crash +PIDTWCManager=open("/home/pi/TWCManager/TWCManager.pid","w") +PIDTWCManager.write(str(os.getpid())) +PIDTWCManager.close() + # 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. @@ -237,6 +246,9 @@ def background_tasks_thread(master): requests.post(task["url"], json=body) elif task["cmd"] == "saveSettings": master.saveSettings() + elif task["cmd"] == "checkMaxPowerFromGrid": + check_max_power_from_grid() + except: master.debugLog( @@ -285,6 +297,24 @@ def check_green_energy(): master.setMaxAmpsToDivideAmongSlaves(master.getMaxAmpsToDivideGreenEnergy()) +def check_max_power_from_grid(): + global config, hass, master + + # Check solar panel generation using an API exposed by + # the HomeAssistant API. + # + # You may need to customize the sensor entity_id values + # to match those used in your environment. This is configured + # in the config section at the top of this file. + # + # Poll all loaded EMS modules for consumption and generation values + for module in master.getModulesByType("EMS"): + master.setConsumption(module["name"], module["ref"].getConsumption()) + master.setGeneration(module["name"], module["ref"].getGeneration()) + master.setMaxAmpsToDivideFromGrid(master.getMaxAmpsToDivideFromGrid()) + + + def update_statuses(): # Print a status update if we are on track green energy showing the diff --git a/etc/twcmanager/config.json b/etc/twcmanager/config.json index 755f7969..c4e87eb4 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":[ diff --git a/lib/TWCManager/Control/themes/Default/jsrefresh.html.j2 b/lib/TWCManager/Control/themes/Default/jsrefresh.html.j2 index eaa93f10..2523259e 100644 --- a/lib/TWCManager/Control/themes/Default/jsrefresh.html.j2 +++ b/lib/TWCManager/Control/themes/Default/jsrefresh.html.j2 @@ -20,7 +20,7 @@ $(document).ready(function() { } // Change the state of the Charge Now button based on Charge Policy - if (json["currentPolicy"] == "Charge Now") { + if (json["currentPolicy"] == "Charge Now" || json["currentPolicy"] == "Charge Now with Grid power limit") { document.getElementById("start_chargenow").value = "Update Charge Now"; document.getElementById("cancel_chargenow").disabled = false; } else { diff --git a/lib/TWCManager/TWCMaster.py b/lib/TWCManager/TWCMaster.py index a2c7cbb0..7f167c9a 100644 --- a/lib/TWCManager/TWCMaster.py +++ b/lib/TWCManager/TWCMaster.py @@ -32,6 +32,7 @@ class TWCMaster: lastTWCResponseMsg = None masterTWCID = "" maxAmpsToDivideAmongSlaves = 0 + maxAmpsToDivideFromGrid = 0 modules = {} nextHistorySnap = 0 overrideMasterHeartbeatData = b"" @@ -532,6 +533,29 @@ def getMaxAmpsToDivideGreenEnergy(self): amps = amps / self.getRealPowerFactor(amps) return round(amps, 2) + def getMaxAmpsToDivideFromGrid(self): + # Calculate our current generation and consumption in watts + generationW = float(self.getGeneration()) + consumptionW = float(self.getConsumption()) + + currentOffer = min( + self.getTotalAmpsInUse(), + self.getMaxAmpsToDivideAmongSlaves(), + ) + + # Calculate what we should max offer to align with max grid energy + amps = self.config["config"]["maxAmpsAllowedFromGrid"] + \ + self.convertWattsToAmps(generationW - consumptionW) + \ + currentOffer + + amps = amps / self.getRealPowerFactor(amps) + self.debugLog( + 10, "TWCMaster", "MaxAmpsToDivideFromGrid: +++++++++++++++: " + str(amps) + ) + + return round(amps, 2) + + def getNormalChargeLimit(self, ID): if "chargeLimits" in self.settings and str(ID) in self.settings["chargeLimits"]: result = self.settings["chargeLimits"][str(ID)] @@ -1167,6 +1191,15 @@ def setMaxAmpsToDivideAmongSlaves(self, amps): ) amps = self.config["config"]["wiringMaxAmpsAllTWCs"] + + if amps > self.maxAmpsToDivideFromGrid: + # Never tell the slaves to draw more amps from grid than allowed + amps = self.maxAmpsToDivideFromGrid + self.debugLog( + 10, "TWCMaster","maxAmpsToDivideAmongSlaves limited to not draw more power from the grid than allowed: " + str(amps) + ) + + self.maxAmpsToDivideAmongSlaves = amps self.releaseBackgroundTasksLock() @@ -1175,6 +1208,11 @@ def setMaxAmpsToDivideAmongSlaves(self, amps): # to console / MQTT / etc self.queue_background_task({"cmd": "updateStatus"}) + def setMaxAmpsToDivideFromGrid(self, amps): + # This is called when check_max_power_from_grid is run + # It stablished how much power we allow getting from the grid + self.maxAmpsToDivideFromGrid = amps + def setNonScheduledAmpsMax(self, amps): self.settings["nonScheduledAmpsMax"] = amps diff --git a/setup.py b/setup.py index 6170b58d..074e2904 100755 --- a/setup.py +++ b/setup.py @@ -1,12 +1,12 @@ #!/usr/bin/python3 -from setuptools import setup, find_namespace_packages +from setuptools import setup, find_packages setup( name="TWCManager", version="1.2.1", package_dir={"": "lib"}, - packages=find_namespace_packages(where="lib"), + packages=find_packages(where="lib"), # Dependencies install_requires=[ "commentjson>=0.8.3", diff --git a/svisorTWC.sh b/svisorTWC.sh new file mode 100755 index 00000000..ca28ec44 --- /dev/null +++ b/svisorTWC.sh @@ -0,0 +1,20 @@ +PROGRAM=/usr/bin/python3.5 +PIDFILE=/home/pi/TWCManager/TWCManager.pid + +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 /home/pi/TWCManager/TWCManager.py + fi +fi +sleep 30 +done + + From 0825b6d18ef0fba1957fa4b575a913c8266ad98d Mon Sep 17 00:00:00 2001 From: juanjoqg <juanjoqg@gmail.com> Date: Thu, 4 Feb 2021 11:37:57 +0100 Subject: [PATCH 09/17] Avoid using a hardcode path for the PID file, take the path from config.json --- TWCManager.py | 3 ++- svisorTWC.sh | 8 ++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/TWCManager.py b/TWCManager.py index ba662dc8..37526247 100755 --- a/TWCManager.py +++ b/TWCManager.py @@ -102,7 +102,8 @@ ######################################################################## # Write the PID in order to let a supervisor restart it in case of crash -PIDTWCManager=open("/home/pi/TWCManager/TWCManager.pid","w") +PIDfile=config["config"]["settingsPath"] + "/TWCManager.pid" +PIDTWCManager=open(PIDfile,"w") PIDTWCManager.write(str(os.getpid())) PIDTWCManager.close() diff --git a/svisorTWC.sh b/svisorTWC.sh index ca28ec44..dba0c589 100755 --- a/svisorTWC.sh +++ b/svisorTWC.sh @@ -1,5 +1,6 @@ PROGRAM=/usr/bin/python3.5 -PIDFILE=/home/pi/TWCManager/TWCManager.pid +PIDFILE=/etc/twcmanager/TWCManager.pid +TWCMANAGER_PATH=/home/pi/TWCManager while true do @@ -11,8 +12,11 @@ if [ -f $PIDFILE ]; then echo "done." else echo "PID not found, Starting..." - screen -dm -S TWCManager /home/pi/TWCManager/TWCManager.py + 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 From 4e781c0ee8c34abffec2d11cedd1de2fddff3643 Mon Sep 17 00:00:00 2001 From: juanjoqg <juanjoqg@gmail.com> Date: Thu, 4 Feb 2021 11:49:08 +0100 Subject: [PATCH 10/17] Rollback to the namespace remove --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 074e2904..6170b58d 100755 --- a/setup.py +++ b/setup.py @@ -1,12 +1,12 @@ #!/usr/bin/python3 -from setuptools import setup, find_packages +from setuptools import setup, find_namespace_packages setup( name="TWCManager", version="1.2.1", package_dir={"": "lib"}, - packages=find_packages(where="lib"), + packages=find_namespace_packages(where="lib"), # Dependencies install_requires=[ "commentjson>=0.8.3", From 5aec8ff0753ba22ce1e341b283c5cf3185e325f4 Mon Sep 17 00:00:00 2001 From: juanjoqg <juanjoqg@gmail.com> Date: Fri, 12 Feb 2021 13:52:48 +0100 Subject: [PATCH 11/17] Bug fix in the limit amps from the grid integration with track green energy --- lib/TWCManager/TWCMaster.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/TWCManager/TWCMaster.py b/lib/TWCManager/TWCMaster.py index 7f167c9a..1ccf2dc9 100644 --- a/lib/TWCManager/TWCMaster.py +++ b/lib/TWCManager/TWCMaster.py @@ -1192,7 +1192,7 @@ def setMaxAmpsToDivideAmongSlaves(self, amps): amps = self.config["config"]["wiringMaxAmpsAllTWCs"] - if amps > self.maxAmpsToDivideFromGrid: + if not self.getModuleByName("Policy").policyIsGreen() and amps > self.maxAmpsToDivideFromGrid: # Never tell the slaves to draw more amps from grid than allowed amps = self.maxAmpsToDivideFromGrid self.debugLog( From 86667687aa37416266240472580351bb6b4e02da Mon Sep 17 00:00:00 2001 From: juanjoqg <juanjoqg@gmail.com> Date: Sat, 13 Feb 2021 20:52:57 +0100 Subject: [PATCH 12/17] Change to ensure it just limit the amps from the grid when the right policy is enable --- lib/TWCManager/TWCMaster.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/TWCManager/TWCMaster.py b/lib/TWCManager/TWCMaster.py index 1ccf2dc9..41b46a4b 100644 --- a/lib/TWCManager/TWCMaster.py +++ b/lib/TWCManager/TWCMaster.py @@ -1191,8 +1191,10 @@ def setMaxAmpsToDivideAmongSlaves(self, amps): ) amps = self.config["config"]["wiringMaxAmpsAllTWCs"] - - if not self.getModuleByName("Policy").policyIsGreen() and amps > self.maxAmpsToDivideFromGrid: + activePolicy=str(self.getModuleByName("Policy").active_policy) + if (activePolicy== "Charge Now with Grid power limit" or \ + activePolicy== "Scheduled Charging with Grid power limit") and \ + amps > self.maxAmpsToDivideFromGrid: # Never tell the slaves to draw more amps from grid than allowed amps = self.maxAmpsToDivideFromGrid self.debugLog( From 3514c105e96034e3455143945be202438a513773 Mon Sep 17 00:00:00 2001 From: juanjoqg <juanjoqg@gmail.com> Date: Wed, 17 Feb 2021 01:05:48 +0100 Subject: [PATCH 13/17] New menu Graphs, it allows to represent energy graphs base on the SQL database --- lib/TWCManager/Control/HTTPControl.py | 96 +++++++++++++++ .../Control/themes/Default/drawChart.html.j2 | 110 ++++++++++++++++++ .../Control/themes/Default/graphs.html.j2 | 20 ++++ .../Control/themes/Default/navbar.html.j2 | 1 + lib/TWCManager/Logging/MySQLLogging.py | 36 ++++++ 5 files changed, 263 insertions(+) create mode 100644 lib/TWCManager/Control/themes/Default/drawChart.html.j2 create mode 100644 lib/TWCManager/Control/themes/Default/graphs.html.j2 diff --git a/lib/TWCManager/Control/HTTPControl.py b/lib/TWCManager/Control/HTTPControl.py index 18bff965..7172de90 100644 --- a/lib/TWCManager/Control/HTTPControl.py +++ b/lib/TWCManager/Control/HTTPControl.py @@ -574,6 +574,33 @@ def do_GET(self): self.wfile.write(page.encode("utf-8")) return + 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("graphs.html.j2") + 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) @@ -610,6 +637,19 @@ def do_POST(self): 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:%S") + objEnd = datetime.strptime(self.getFieldValue("dateEnd"), "%Y-%m-%dT%H:%M:%S") + 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() @@ -882,6 +922,62 @@ def show_twcs(self): page += "</tr></table></td></tr></table>" 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: + module = self.master.getModuleByName("MySQLLogging") + result= module.queryGreenEnergy( + { + "dateBegin": init, + "dateEnd": end + } + ) + except Exception as e: + master.debugLog(1, + "HTTPCtrl", + "Excepcion queryGreenEnergy: " + + e, + ) + + data = {} + data[0] = { + "initial":init.strftime("%Y-%m-%dT%H:%M:%S"), + "end":end.strftime("%Y-%m-%dT%H:%M:%S"), + } + i=1 + while i<len(result): + data[i] = { + "time": result[i][0].strftime("%Y-%m-%dT%H:%M:%S"), + "genW": str(result[i][1]), + "conW": str(result[i][2]), + "chgW": str(result[i][3]), + } + i=i+1 + + self.send_response(200) + self.send_header("Content-type", "application/json") + self.end_headers() + + json_data = json.dumps(data) + try: + self.wfile.write(json_data.encode("utf-8")) + except BrokenPipeError: + master.debugLog(10,"HTTPCtrl","Connection Error: Broken Pipe") + return + + def debugLogAPI(self, message): master.debugLog(10, "HTTPCtrl", diff --git a/lib/TWCManager/Control/themes/Default/drawChart.html.j2 b/lib/TWCManager/Control/themes/Default/drawChart.html.j2 new file mode 100644 index 00000000..b5b1c424 --- /dev/null +++ b/lib/TWCManager/Control/themes/Default/drawChart.html.j2 @@ -0,0 +1,110 @@ +<script src="https://code.jquery.com/jquery.js"></script> +<script src="http://code.highcharts.com/stock/highstock.js"></script> +<script src="http://code.highcharts.com/modules/exporting.js"></script> + +<script> + +// AJAJ refresh graph from SQL database +$(document).ready(function() { + + function drawChart() { + $.ajax({ + url: "/graphs/date", + dataType: "text", + cache: false, + success: function(data) { + var json = $.parseJSON(data); + + document.getElementById("initial").setAttribute('value',json[0]["initial"]); + document.getElementById("end").setAttribute('value',json[0]["end"]); + var d = new Date(); + var n = d.getTimezoneOffset(); + Highcharts.setOptions({ + time: { + timezoneOffset: n + } + }); + + + chartDiana = new Highcharts.StockChart({ + chart: { + renderTo: 'contenedor' + + }, + rangeSelector : { + enabled: false + }, + title: { + text: 'Generation - Consumption - Charger' + }, + xAxis: { + type: 'datetime' + //tickPixelInterval: 150, + //maxZoom: 20 * 1000 + }, + yAxis: { + minPadding: 0.05, + maxPadding: 0.05, + title: { + text: 'Watts', + margin: 100 + } + }, + series: [{ + name: 'PV', + data: (function() { + var data = []; + Object.keys(json).forEach(function(key) { + if(key>0) + { + data.push([Date.parse(json[key]["time"]),parseInt(json[key]["genW"])]); + } + }) + + return data; + })()}, + { + name: 'CONSUMPTION', + data: (function() { + var data = []; + Object.keys(json).forEach(function(key) { + if(key>0) + { + data.push([Date.parse(json[key]["time"]),parseInt(json[key]["conW"])]); + } + }) + + return data; + })()}, + { + name: 'CHARGE', + data: (function() { + var data = []; + Object.keys(json).forEach(function(key) { + if(key>0) + { + data.push([Date.parse(json[key]["time"]),parseInt(json[key]["chgW"])]); + } + }) + + return data; + })() + }], + credits: { + enabled: false + } + }); // end chatDiana + + } // end success + + }); //end ajax + + + } //end drawChart + + drawChart(); + +}); //end ready + + +</script> 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..8b6b5ac1 --- /dev/null +++ b/lib/TWCManager/Control/themes/Default/graphs.html.j2 @@ -0,0 +1,20 @@ +<!DOCTYPE html> +<html lang='en'> + <head> + <title>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/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/Logging/MySQLLogging.py b/lib/TWCManager/Logging/MySQLLogging.py index 29772920..7a1f3b37 100644 --- a/lib/TWCManager/Logging/MySQLLogging.py +++ b/lib/TWCManager/Logging/MySQLLogging.py @@ -91,6 +91,42 @@ def greenEnergy(self, data): self.db.rollback() cur.close() + def queryGreenEnergy(self, data): + # Check if this status is muted + if self.configLogging["mute"].get("GreenEnergy", 0): + return None + # Ensure database connection is alive, or reconnect if not + self.db.ping(reconnect=True) + + query = """ + SELECT * from green_energy 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 slavePower(self, data): # Check if this status is muted if self.configLogging["mute"].get("SlavePower", 0): From 32f6d41cc611d277257b95cd61dec1dc36462018 Mon Sep 17 00:00:00 2001 From: juanjoqg Date: Thu, 18 Feb 2021 11:05:55 +0100 Subject: [PATCH 14/17] Change step on the graphs from seconds to minutes --- lib/TWCManager/Control/HTTPControl.py | 8 ++++---- lib/TWCManager/Control/themes/Default/graphs.html.j2 | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/TWCManager/Control/HTTPControl.py b/lib/TWCManager/Control/HTTPControl.py index 7172de90..3e81e42b 100644 --- a/lib/TWCManager/Control/HTTPControl.py +++ b/lib/TWCManager/Control/HTTPControl.py @@ -639,8 +639,8 @@ def do_POST(self): 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:%S") - objEnd = datetime.strptime(self.getFieldValue("dateEnd"), "%Y-%m-%dT%H:%M:%S") + 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") @@ -953,8 +953,8 @@ def process_graphs(self,init,end): data = {} data[0] = { - "initial":init.strftime("%Y-%m-%dT%H:%M:%S"), - "end":end.strftime("%Y-%m-%dT%H:%M:%S"), + "initial":init.strftime("%Y-%m-%dT%H:%M"), + "end":end.strftime("%Y-%m-%dT%H:%M"), } i=1 while i {% include 'navbar.html.j2' %}
-

Initial Date: End Date: +

Initial Date: End Date: From 6c7921dc129ce01251d8f208cb7c8abca9c8f566 Mon Sep 17 00:00:00 2001 From: juanjoqg Date: Sat, 20 Feb 2021 01:45:49 +0100 Subject: [PATCH 15/17] New picing module for Spanish PVPC model, i adds a new schedule charging base on the forecast cost --- TWCManager.py | 1 + etc/twcmanager/config.json | 7 + lib/TWCManager/Control/HTTPControl.py | 14 +- lib/TWCManager/Pricing/PVPCesPricing.py | 189 +++++++++++++++++++++++ lib/TWCManager/Pricing/StaticPricing.py | 4 + lib/TWCManager/Pricing/aWATTarPricing.py | 2 + lib/TWCManager/TWCMaster.py | 68 +++++++- 7 files changed, 282 insertions(+), 3 deletions(-) create mode 100644 lib/TWCManager/Pricing/PVPCesPricing.py diff --git a/TWCManager.py b/TWCManager.py index 38389f1d..70ae2664 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 diff --git a/etc/twcmanager/config.json b/etc/twcmanager/config.json index 6f659596..e5889983 100644 --- a/etc/twcmanager/config.json +++ b/etc/twcmanager/config.json @@ -423,7 +423,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/lib/TWCManager/Control/HTTPControl.py b/lib/TWCManager/Control/HTTPControl.py index 416703b6..79f0eb28 100644 --- a/lib/TWCManager/Control/HTTPControl.py +++ b/lib/TWCManager/Control/HTTPControl.py @@ -661,6 +661,16 @@ def chargeScheduleDay(self, day): 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 @@ -709,11 +719,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: diff --git a/lib/TWCManager/Pricing/PVPCesPricing.py b/lib/TWCManager/Pricing/PVPCesPricing.py new file mode 100644 index 00000000..e1b3d807 --- /dev/null +++ b/lib/TWCManager/Pricing/PVPCesPricing.py @@ -0,0 +1,189 @@ +from datetime import datetime +from datetime import timedelta + +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 = {} + todayImportPrice = {} + + 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 update(self): + + # Fetch the current pricing data from the https://www.esios.ree.es/es/pvpc API + self.fetchFailed = False + now=datetime.now() + tomorrow=datetime.now() + timedelta(days=1) + if self.lastFetch == 0 or (now.hour < self.lastFetch.hour): + # Cache not feched or was feched yesterday. Fetch values from API. + ini=str(now.year)+"-"+str(now.month)+"-"+str(now.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(): + self.todayImportPrice=r.json() + + if self.todayImportPrice: + try: + self.importPrice = float( + self.todayImportPrice['indicator']['values'][now.hour]['value'] + ) + # Convert MWh price to KWh + self.importPrice = round(self.importPrice / 1000,5) + + except (KeyError, TypeError) as e: + self.master.debugLog( + 4, + "$PVPCes", + "Exception during parsing PVPCes pricing", + ) + + def getCheapestStartHour(self,numHours,ini,end): + # Perform updates if necessary + self.update() + + minPriceHstart=ini + if self.todayImportPrice: + 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 j 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..fd3847b5 100644 --- a/lib/TWCManager/Pricing/StaticPricing.py +++ b/lib/TWCManager/Pricing/StaticPricing.py @@ -73,3 +73,7 @@ def getImportPrice(self): # Return current import price return float(self.importPrice) + def getPricingInAdvanceAvailable(self): + # For future implementation + return False + diff --git a/lib/TWCManager/Pricing/aWATTarPricing.py b/lib/TWCManager/Pricing/aWATTarPricing.py index 64f90f4a..500c02c8 100644 --- a/lib/TWCManager/Pricing/aWATTarPricing.py +++ b/lib/TWCManager/Pricing/aWATTarPricing.py @@ -118,4 +118,6 @@ def update(self): "Exception during parsing aWATTar pricing", ) + def getPricingInAdvanceAvailable(self): + return False diff --git a/lib/TWCManager/TWCMaster.py b/lib/TWCManager/TWCMaster.py index deb7a8e2..4059ee1c 100644 --- a/lib/TWCManager/TWCMaster.py +++ b/lib/TWCManager/TWCMaster.py @@ -106,8 +106,8 @@ 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] @@ -145,6 +145,7 @@ def checkScheduledCharging(self): and (daysBitmap & (1 << ltNow.tm_wday)) ): blnUseScheduledAmps = 1 + return blnUseScheduledAmps def convertAmpsToWatts(self, amps): @@ -269,6 +270,26 @@ def getPricing(self): self.exportPricingValues[module["name"]] = module["ref"].getExportPrice() self.importPricingValues[module["name"]] = module["ref"].getImportPrice() + def getPricingInAdvanceAvailable(self): + for module in self.getModulesByType("Pricing"): + if module["ref"].getPricingInAdvanceAvailable(): + return True + return False + + def getCheaperDayChargeTime(self,dayName): + return self.settings["Schedule"][dayName]["cheaper"] + + def getActualHDayChargeTime(self,dayName): + return self.settings["Schedule"][dayName]["actualH"] + + def getCheapestStartHour(self,numHours,ini,end): + # We take the latest modul data + cheapestStartHour = ini + for module in self.getModulesByType("Pricing"): + cheapestStartHour = module["ref"].getCheapestStartHour(numHours,ini,end) + + return cheapestStartHour + def getScheduledAmpsDaysBitmap(self): return self.settings.get("scheduledAmpsDaysBitmap", 0x7F) @@ -289,9 +310,49 @@ def getScheduledAmpsMax(self): else: return 0 + def getActualHscheduledAmps(self): + return int(self.settings.get("actualHscheduledAmps", -1)) + def getScheduledAmpsStartHour(self): return int(self.settings.get("scheduledAmpsStartHour", -1)) + + + def getScheduledAmpsCheaperFlex(self, startHour, endHour, daysBitmap): + # adjust the charge start time to minimize the cost + if not self.getPricingInAdvanceAvailable(): + return (startHour, endHour, daysBitmap) + + daysNames = [ "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" ] + ltNow = time.localtime() + hourNow = ltNow.tm_hour + (ltNow.tm_min / 60) + dayName = daysNames[ltNow.tm_wday+1] + + if self.getCheaperDayChargeTime(dayName): + self.debugLog(10,"TWCMaster","getCheaperDayChargeTime True") + if self.getScheduledAmpsFlexStart(): + #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: + self.debugLog(10,"TWCMaster","numHours: "+str(numHours)) + cheapestStartHour = self.getCheapestStartHour(numHours,startHour,endHour) + self.debugLog(10,"TWCMaster","cheapestStartHour: "+str(cheapestStartHour)) + startHour = cheapestStartHour + endHour = startHour+numHours + if endHour >= 24: + endHour = endHour - 24 + + return (startHour, endHour, daysBitmap) + def getScheduledAmpsTimeFlex(self): startHour = self.getScheduledAmpsStartHour() days = self.getScheduledAmpsDaysBitmap() @@ -325,7 +386,10 @@ def getScheduledAmpsTimeFlex(self): # (if starting usually at 9pm and it calculates to start at 4am - it's already the next day) if startHour < self.getScheduledAmpsDaysBitmap(): days = self.rotl(days, 7) - return (startHour, self.getScheduledAmpsEndHour(), days) + + timeSettings = self.getScheduledAmpsCheaperFlex(startHour, self.getScheduledAmpsEndHour(), days) + + return timeSettings def getScheduledAmpsEndHour(self): return self.settings.get("scheduledAmpsEndHour", -1) From 4f78ad9bc9a4c104fb7fbe6bac3b7c439c0d9afe Mon Sep 17 00:00:00 2001 From: juanjoqg Date: Fri, 26 Feb 2021 22:42:56 +0100 Subject: [PATCH 16/17] New feature related with the pricing modules that allows to schedule charging base on price additionaly it implements the Graphs menu in a more general way --- .../Control/themes/Default/nographs.html.j2 | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 lib/TWCManager/Control/themes/Default/nographs.html.j2 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) + + From e676a3790a72386a653058cdd1c5c4918fb88aa1 Mon Sep 17 00:00:00 2001 From: juanjoqg Date: Fri, 26 Feb 2021 23:20:05 +0100 Subject: [PATCH 17/17] New features related to the pricing modules, schedule charging related to price --- TWCManager.py | 27 -- lib/TWCManager/Control/HTTPControl.py | 113 ++++++-- .../Control/themes/Default/jsrefresh.html.j2 | 2 +- .../Control/themes/Default/schedule.html.j2 | 11 + lib/TWCManager/Logging/CSVLogging.py | 5 + lib/TWCManager/Logging/ConsoleLogging.py | 5 + lib/TWCManager/Logging/FileLogging.py | 5 + lib/TWCManager/Logging/MySQLLogging.py | 274 +++++++++++++++++- lib/TWCManager/Logging/SQLiteLogging.py | 5 + lib/TWCManager/Policy/Policy.py | 4 +- lib/TWCManager/Pricing/PVPCesPricing.py | 120 ++++++-- lib/TWCManager/Pricing/StaticPricing.py | 7 +- lib/TWCManager/Pricing/aWATTarPricing.py | 9 +- lib/TWCManager/TWCMaster.py | 207 +++++++++---- 14 files changed, 650 insertions(+), 144 deletions(-) diff --git a/TWCManager.py b/TWCManager.py index a333f572..bab41ac8 100755 --- a/TWCManager.py +++ b/TWCManager.py @@ -104,12 +104,6 @@ ######################################################################## -# Write the PID in order to let a supervisor restart it in case of crash -PIDfile=config["config"]["settingsPath"] + "/TWCManager.pid" -PIDTWCManager=open(PIDfile,"w") -PIDTWCManager.write(str(os.getpid())) -PIDTWCManager.close() - # 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. @@ -252,8 +246,6 @@ def background_tasks_thread(master): requests.post(task["url"], json=body) elif task["cmd"] == "saveSettings": master.saveSettings() - elif task["cmd"] == "checkMaxPowerFromGrid": - check_max_power_from_grid() except: @@ -302,25 +294,6 @@ def check_green_energy(): master.setGeneration(module["name"], module["ref"].getGeneration()) master.setMaxAmpsToDivideAmongSlaves(master.getMaxAmpsToDivideGreenEnergy()) - -def check_max_power_from_grid(): - global config, hass, master - - # Check solar panel generation using an API exposed by - # the HomeAssistant API. - # - # You may need to customize the sensor entity_id values - # to match those used in your environment. This is configured - # in the config section at the top of this file. - # - # Poll all loaded EMS modules for consumption and generation values - for module in master.getModulesByType("EMS"): - master.setConsumption(module["name"], module["ref"].getConsumption()) - master.setGeneration(module["name"], module["ref"].getGeneration()) - master.setMaxAmpsToDivideFromGrid(master.getMaxAmpsToDivideFromGrid()) - - - def update_statuses(): # Print a status update if we are on track green energy showing the diff --git a/lib/TWCManager/Control/HTTPControl.py b/lib/TWCManager/Control/HTTPControl.py index 9c96a01f..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
" @@ -598,7 +651,12 @@ def do_GET(self): self.send_header("Content-type", "text/html") self.end_headers() # Load debug template and render - self.template = self.templateEnv.get_template("graphs.html.j2") + 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 @@ -690,13 +748,16 @@ 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" @@ -729,7 +790,7 @@ def log_message(self, format, *args): def optionList(self, list, opts={}): page = "

" - page += "" % ( opts.get("name", ""), opts.get("name", ""), ) @@ -737,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 @@ -796,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"] = ( @@ -960,13 +1024,20 @@ def process_graphs(self,init,end): # This function will query the green_energy SQL table result={} try: - module = self.master.getModuleByName("MySQLLogging") - result= module.queryGreenEnergy( + 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", @@ -998,6 +1069,8 @@ def process_graphs(self,init,end): self.wfile.write(json_data.encode("utf-8")) except BrokenPipeError: master.debugLog(10,"HTTPCtrl","Connection Error: Broken Pipe") + + return diff --git a/lib/TWCManager/Control/themes/Default/jsrefresh.html.j2 b/lib/TWCManager/Control/themes/Default/jsrefresh.html.j2 index 2523259e..eaa93f10 100644 --- a/lib/TWCManager/Control/themes/Default/jsrefresh.html.j2 +++ b/lib/TWCManager/Control/themes/Default/jsrefresh.html.j2 @@ -20,7 +20,7 @@ $(document).ready(function() { } // Change the state of the Charge Now button based on Charge Policy - if (json["currentPolicy"] == "Charge Now" || json["currentPolicy"] == "Charge Now with Grid power limit") { + if (json["currentPolicy"] == "Charge Now") { document.getElementById("start_chargenow").value = "Update Charge Now"; document.getElementById("cancel_chargenow").disabled = false; } else { 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: 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 7a1f3b37..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,140 @@ 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 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 @@ -280,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 index e1b3d807..35ce7287 100644 --- a/lib/TWCManager/Pricing/PVPCesPricing.py +++ b/lib/TWCManager/Pricing/PVPCesPricing.py @@ -1,5 +1,6 @@ from datetime import datetime from datetime import timedelta +import time class PVPCesPricing: @@ -19,7 +20,7 @@ class PVPCesPricing: status = False timeout = 10 headers = {} - todayImportPrice = {} + weekImportPrice = {} def __init__(self, master): @@ -82,20 +83,37 @@ def getImportPrice(self): # 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 float(self.importPrice) + return self.weekImportPrice - def update(self): + 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 yesterday. Fetch values from API. - ini=str(now.year)+"-"+str(now.month)+"-"+str(now.day)+"T"+"00:00:00" + 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 @@ -125,44 +143,89 @@ def update(self): ) return False - if r.json(): - self.todayImportPrice=r.json() - - if self.todayImportPrice: - try: - self.importPrice = float( - self.todayImportPrice['indicator']['values'][now.hour]['value'] - ) - # Convert MWh price to KWh - self.importPrice = round(self.importPrice / 1000,5) + 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") - except (KeyError, TypeError) as e: - self.master.debugLog( - 4, - "$PVPCes", - "Exception during parsing PVPCes pricing", - ) 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 - if self.todayImportPrice: + 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 + 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 @@ -176,8 +239,9 @@ def getCheapestStartHour(self,numHours,ini,end): self.master.debugLog( 4, "$PVPCes", - "Exception during cheaper pricing analice", + "Exception during cheaper pricing analice: "+str(e), ) + minPriceHstart = ini if minPriceHstart > 23: minPriceHstart = minPriceHstart - 24 diff --git a/lib/TWCManager/Pricing/StaticPricing.py b/lib/TWCManager/Pricing/StaticPricing.py index fd3847b5..d3dfef54 100644 --- a/lib/TWCManager/Pricing/StaticPricing.py +++ b/lib/TWCManager/Pricing/StaticPricing.py @@ -73,7 +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 False + return 0 + return 0 diff --git a/lib/TWCManager/Pricing/aWATTarPricing.py b/lib/TWCManager/Pricing/aWATTarPricing.py index 500c02c8..8322298f 100644 --- a/lib/TWCManager/Pricing/aWATTarPricing.py +++ b/lib/TWCManager/Pricing/aWATTarPricing.py @@ -118,6 +118,13 @@ def update(self): "Exception during parsing aWATTar pricing", ) + def getWeekImportPrice(self): + # ToDo + return 0 + + def getPricingInAdvanceAvailable(self): - return False + # ToDo + return 0 + return 0 diff --git a/lib/TWCManager/TWCMaster.py b/lib/TWCManager/TWCMaster.py index dccfc9db..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 @@ -34,7 +34,6 @@ class TWCMaster: lastTWCResponseMsg = None masterTWCID = "" maxAmpsToDivideAmongSlaves = 0 - maxAmpsToDivideFromGrid = 0 modules = {} nextHistorySnap = 0 overrideMasterHeartbeatData = b"" @@ -53,6 +52,7 @@ class TWCMaster: "scheduledAmpsDaysBitmap": 0x7F, "scheduledAmpsEndHour": -1, "scheduledAmpsMax": 0, + "flexBatterySize": 100, "scheduledAmpsStartHour": -1, } slaveHeartbeatData = bytearray( @@ -104,7 +104,6 @@ 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 ltNow = time.localtime() @@ -121,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 @@ -271,26 +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): - return self.settings["Schedule"][dayName]["cheaper"] + 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): - return self.settings["Schedule"][dayName]["actualH"] + daySchedule = self.getScheduleChargingFromYesterday(dayName) + return self.settings["Schedule"][daySchedule]["actualH"] def getCheapestStartHour(self,numHours,ini,end): - # We take the latest modul data + # We take the first modul data cheapestStartHour = ini for module in self.getModulesByType("Pricing"): - cheapestStartHour = module["ref"].getCheapestStartHour(numHours,ini,end) - + 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) @@ -311,27 +354,47 @@ 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(): + 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) - dayName = daysNames[ltNow.tm_wday+1] + if ltNow.tm_wday <6: + dayName = daysNames[ltNow.tm_wday+1] + else: + dayName = daysNames[0] if self.getCheaperDayChargeTime(dayName): - self.debugLog(10,"TWCMaster","getCheaperDayChargeTime True") - if self.getScheduledAmpsFlexStart(): + 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 @@ -344,9 +407,7 @@ def getScheduledAmpsCheaperFlex(self, startHour, endHour, daysBitmap): numHours = numHours/3600 if numHours: - self.debugLog(10,"TWCMaster","numHours: "+str(numHours)) - cheapestStartHour = self.getCheapestStartHour(numHours,startHour,endHour) - self.debugLog(10,"TWCMaster","cheapestStartHour: "+str(cheapestStartHour)) + cheapestStartHour = self.getCheapestStartHour(numHours,startHourP,endHourP) startHour = cheapestStartHour endHour = startHour+numHours if endHour >= 24: @@ -355,18 +416,30 @@ def getScheduledAmpsCheaperFlex(self, startHour, endHour, daysBitmap): 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 @@ -385,16 +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) - timeSettings = self.getScheduledAmpsCheaperFlex(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)) @@ -444,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() @@ -648,29 +733,6 @@ def getMaxAmpsToDivideGreenEnergy(self): amps = amps / self.getRealPowerFactor(amps) return round(amps, 2) - def getMaxAmpsToDivideFromGrid(self): - # Calculate our current generation and consumption in watts - generationW = float(self.getGeneration()) - consumptionW = float(self.getConsumption()) - - currentOffer = min( - self.getTotalAmpsInUse(), - self.getMaxAmpsToDivideAmongSlaves(), - ) - - # Calculate what we should max offer to align with max grid energy - amps = self.config["config"]["maxAmpsAllowedFromGrid"] + \ - self.convertWattsToAmps(generationW - consumptionW) + \ - currentOffer - - amps = amps / self.getRealPowerFactor(amps) - self.debugLog( - 10, "TWCMaster", "MaxAmpsToDivideFromGrid: +++++++++++++++: " + str(amps) - ) - - return round(amps, 2) - - def getNormalChargeLimit(self, ID): if "chargeLimits" in self.settings and str(ID) in self.settings["chargeLimits"]: result = self.settings["chargeLimits"][str(ID)] @@ -1306,17 +1368,6 @@ def setMaxAmpsToDivideAmongSlaves(self, amps): ) amps = self.config["config"]["wiringMaxAmpsAllTWCs"] - activePolicy=str(self.getModuleByName("Policy").active_policy) - if (activePolicy== "Charge Now with Grid power limit" or \ - activePolicy== "Scheduled Charging with Grid power limit") and \ - amps > self.maxAmpsToDivideFromGrid: - # Never tell the slaves to draw more amps from grid than allowed - amps = self.maxAmpsToDivideFromGrid - self.debugLog( - 10, "TWCMaster","maxAmpsToDivideAmongSlaves limited to not draw more power from the grid than allowed: " + str(amps) - ) - - self.maxAmpsToDivideAmongSlaves = amps self.releaseBackgroundTasksLock() @@ -1325,11 +1376,6 @@ def setMaxAmpsToDivideAmongSlaves(self, amps): # to console / MQTT / etc self.queue_background_task({"cmd": "updateStatus"}) - def setMaxAmpsToDivideFromGrid(self, amps): - # This is called when check_max_power_from_grid is run - # It stablished how much power we allow getting from the grid - self.maxAmpsToDivideFromGrid = amps - def setNonScheduledAmpsMax(self, amps): self.settings["nonScheduledAmpsMax"] = amps @@ -1349,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 @@ -1474,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