From 4556055e586fc32eaee6bf676b9b05fc459a8696 Mon Sep 17 00:00:00 2001 From: rleidner Date: Sat, 27 Apr 2024 17:20:58 +0200 Subject: [PATCH] BMW SOC: add token refresh, improve logging --- modules/soc_i3/i3soc.py | 227 ++++++++++++++++++++++++++++++++++------ 1 file changed, 197 insertions(+), 30 deletions(-) diff --git a/modules/soc_i3/i3soc.py b/modules/soc_i3/i3soc.py index e8e023657..2eae51a5c 100755 --- a/modules/soc_i3/i3soc.py +++ b/modules/soc_i3/i3soc.py @@ -4,7 +4,8 @@ import requests import string import sys -# import time +import os +import time import urllib import uuid import hashlib @@ -13,9 +14,35 @@ # ---------------Constants------------------------------------------- auth_server = 'customer.bmwgroup.com' api_server = 'cocoapi.bmwgroup.com' +REGION = '0' # rest_of_world +storeFile = 'i3soc.json' + + +# --------------- Global Variables -------------------------------------- +store = {} +config = {} +DEBUGLEVEL = 0 +method = '' # ---------------Helper Function------------------------------------------- + +def _error(txt: str): + print(txt) + + +def _info(txt: str): + global DEBUGLEVEL + if DEBUGLEVEL >= 1: + print(txt) + + +def _debug(txt: str): + global DEBUGLEVEL + if DEBUGLEVEL > 1: + print(txt) + + def get_random_string(length: int) -> str: letters = string.ascii_letters result_str = ''.join(random.choice(letters) for i in range(length)) @@ -36,21 +63,68 @@ def create_s256_code_challenge(code_verifier: str) -> str: return base64.urlsafe_b64encode(data).rstrip(b"=").decode("UTF-8") +# initialize store structures when no store is available +def init_store(): + global store + store = {} + store['Token'] = {} + store['expires_at'] = int(0) + + +# load store from file, initialize store structure if no file exists +def load_store(): + global store + global storeFile + try: + tf = open(storeFile, 'r', encoding='utf-8') + store = json.load(tf) + if 'Token' not in store: + init_store() + tf.close() + except FileNotFoundError: + _error("load_store: store file not found, new authentication required") + store = {} + init_store() + except Exception as e: + _error("init: loading stored data failed, file: " + + storeFile + ", error=" + str(e)) + store = {} + init_store() + + +# write store file +def write_store(): + global store + global storeFile + try: + tf = open(storeFile, 'w', encoding='utf-8') + except Exception as e: + _error("write_store_file: Exception " + str(e)) + os.system("sudo rm -f " + storeFile) + tf = open(storeFile, 'w', encoding='utf-8') + json.dump(store, tf, indent=4) + tf.close() + try: + os.chmod(storeFile, 0o777) + except Exception as e: + os.system("sudo chmod 0777 " + storeFile) + + # ---------------HTTP Function------------------------------------------- def getHTTP(url: str = '', headers: str = '', cookies: str = '', timeout: int = 30) -> str: try: response = requests.get(url, headers=headers, cookies=cookies, timeout=timeout) except requests.Timeout: - print("Connection Timeout") + _error("Connection Timeout") raise except Exception as e: - print("HTTP Error:" + str(e)) + _error("HTTP Error:" + str(e)) raise if response.status_code == 200 or response.status_code == 204: return response.text else: - print('Request failed, StatusCode: ' + str(response.status_code)) + _error('Request failed, StatusCode: ' + str(response.status_code)) raise RuntimeError @@ -58,6 +132,9 @@ def postHTTP(url: str = '', data: str = '', headers: str = '', cookies: str = '' timeout: int = 30, allow_redirects: bool = True, authId: str = '', authSec: str = '') -> str: try: + _debug("postHTTP: url=" + url + + ",\nheaders=" + json.dumps(headers, indent=4) + + ",\ndata=" + json.dumps(data, indent=4)) if authId != '': response = requests.post(url, data=data, headers=headers, cookies=cookies, timeout=timeout, auth=(authId, authSec), @@ -66,10 +143,10 @@ def postHTTP(url: str = '', data: str = '', headers: str = '', cookies: str = '' response = requests.post(url, data=data, headers=headers, cookies=cookies, timeout=timeout, allow_redirects=allow_redirects) except requests.Timeout: - print("Connection Timeout") + _error("Connection Timeout") raise except: - print("HTTP Error") + _error("HTTP Error") raise if response.status_code == 200 or response.status_code == 204: @@ -77,11 +154,11 @@ def postHTTP(url: str = '', data: str = '', headers: str = '', cookies: str = '' elif response.status_code == 302: return response.headers["location"] else: - print('Request failed, StatusCode: ' + str(response.status_code)) + _error('Request failed, StatusCode: ' + str(response.status_code)) raise RuntimeError -def authStage0(region: str, username: str, password: str) -> str: +def authStage0(region: str) -> str: try: id0 = str(uuid.uuid4()) id1 = str(uuid.uuid4()) @@ -95,11 +172,11 @@ def authStage0(region: str, username: str, password: str) -> str: 'x-correlation-id': id1, 'bmw-correlation-Id': id1, 'user-agent': 'Dart/3.0 (dart:io)', - 'x-user-agent': 'android(TQ2A.230405.003.B2);bmw;3.11.1(29513);0'} + 'x-user-agent': 'android(TQ2A.230405.003.B2);bmw;3.11.1(29513);' + region} body = getHTTP(url, headers) cfg = json.loads(body) except: - print("authStage0 failed") + _error("authStage0 failed") raise return cfg @@ -113,7 +190,6 @@ def authStage1(url: str, nonce: str) -> str: global config try: - # url = 'https://' + auth_server + '/gcdm/oauth/authenticate' headers = { 'Content-Type': 'application/x-www-form-urlencoded', 'user-agent': 'Dart/3.0 (dart:io)', @@ -134,9 +210,9 @@ def authStage1(url: str, resp = postHTTP(url, data, headers) response = json.loads(resp) authcode = dict(urllib.parse.parse_qsl(response["redirect_to"]))["authorization"] - # print("authStage1: authcode=" + authcode) + _debug("authStage1: authcode=" + authcode) except: - print("Authentication stage 1 failed") + _error("Authentication stage 1 failed") raise return authcode @@ -144,7 +220,6 @@ def authStage1(url: str, def authStage2(url: str, authcode1: str, code_challenge: str, state: str, nonce: str) -> str: try: - # url = 'https://' + auth_server + '/gcdm/oauth/authenticate' headers = { 'Content-Type': 'application/x-www-form-urlencoded', 'user-agent': 'Dart/3.0 (dart:io)', @@ -163,11 +238,11 @@ def authStage2(url: str, authcode1: str, code_challenge: str, state: str, nonce: 'GCDMSSO': authcode1} response = postHTTP(url, data, headers, cookies, allow_redirects=False) - # print("authStage2: response=" + response) + _debug("authStage2: response=" + response) authcode = dict(urllib.parse.parse_qsl(response.split("?", 1)[1]))["code"] - # print("authStage2: authcode=" + authcode) + _debug("authStage2: authcode=" + authcode) except: - print("Authentication stage 2 failed") + _error("Authentication stage 2 failed") raise return authcode @@ -190,11 +265,11 @@ def authStage3(token_url: str, authcode2: str, code_verifier: str) -> dict: authId = config['clientId'] authSec = config['clientSecret'] response = postHTTP(url, data, headers, authId=authId, authSec=authSec, allow_redirects=False) - # print("authStage3: response=" + response) + _debug("authStage3: response=" + response) token = json.loads(response) - # print("authStage3: token=" + json.dumps(token, indent=4)) + _debug("authStage3: token=" + json.dumps(token, indent=4)) except: - print("Authentication stage 3 failed") + _error("Authentication stage 3 failed") raise return token @@ -202,10 +277,12 @@ def authStage3(token_url: str, authcode2: str, code_verifier: str) -> dict: def requestToken(username: str, password: str) -> dict: global config + global method try: # new: get oauth config from server - config = authStage0('0', username, password) - # print('config=\n' + json.dumps(config, indent=4)) + method += ' requestToken' + config = authStage0(REGION) + _debug('config=\n' + json.dumps(config, indent=4)) token_url = config['tokenEndpoint'] authenticate_url = token_url.replace('/token', '/authenticate') code_verifier = get_random_string(86) @@ -217,7 +294,34 @@ def requestToken(username: str, password: str) -> dict: authcode2 = authStage2(authenticate_url, authcode1, code_challenge, state, nonce) token = authStage3(token_url, authcode2, code_verifier) except: - print("Login failed") + _error("Login failed") + raise + + return token + + +def refreshToken(refreshToken: str) -> dict: + global config + global method + try: + method += ' refreshToken' + config = authStage0(REGION) + url = config['tokenEndpoint'] + headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'user-agent': 'Dart/3.0 (dart:io)', + 'x-user-agent': 'android(TQ2A.230405.003.B2);bmw;3.11.1(29513);0'} + data = { + 'scope': ' '.join(config['scopes']), + 'redirect_uri': config['returnUrl'], + 'grant_type': 'refresh_token', + 'refresh_token': refreshToken} + authId = config['clientId'] + authSec = config['clientSecret'] + resp = postHTTP(url, data, headers, authId=authId, authSec=authSec, allow_redirects=False) + token = json.loads(resp) + except: + _error("Login failed") raise return token @@ -225,13 +329,15 @@ def requestToken(username: str, password: str) -> dict: # ---------------Interface Function------------------------------------------- def requestData(token: str, vin: str) -> dict: + global method try: + method += ' requestData' if vin[:2] == 'WB': brand = 'bmw' elif vin[:2] == 'WM': brand = 'mini' else: - print("Unknown VIN") + _error("Unknown VIN") raise RuntimeError url = 'https://' + api_server + '/eadrax-vcs/v4/vehicles/state' @@ -243,7 +349,9 @@ def requestData(token: str, vin: str) -> dict: body = getHTTP(url, headers) response = json.loads(body) except: - print("Data-Request failed") + _error("Data-Request failed") + _error("requestData: url=" + url + + ",\nheaders=" + json.dumps(headers, indent=4)) raise return response @@ -251,7 +359,18 @@ def requestData(token: str, vin: str) -> dict: # ---------------Main Function------------------------------------------- def main(): + global store + global storeFile + global DEBUGLEVEL + global method try: + method = '' + CHARGEPOINT = os.environ.get("CHARGEPOINT", "1") + DEBUGLEVEL = int(os.environ.get("debug", "0")) + RAMDISKDIR = os.environ.get("RAMDISKDIR", "undefined") + storeFile = RAMDISKDIR + '/soc_i3_cp' + CHARGEPOINT + '.json' + _debug('storeFile =' + storeFile) + argsStr = base64.b64decode(str(sys.argv[1])).decode('utf-8') argsDict = json.loads(argsStr) @@ -262,16 +381,64 @@ def main(): meterfile = str(argsDict["meterfile"]) statefile = str(argsDict["statefile"]) except: - print("Parameters could not be processed") + _error("Parameters could not be processed") raise try: - token = requestToken(username, password) + # try to read store file from ramdisk + expires_in = -1 + load_store() + now = int(time.time()) + _debug('main0: store=\n' + json.dumps(store, indent=4)) + # if OK, check if refreshToken is required + if 'expires_at' in store and \ + 'Token' in store and \ + 'expires_in' in store['Token'] and \ + 'refresh_token' in store['Token']: + expires_in = store['Token']['expires_in'] + expires_at = store['expires_at'] + token = store['Token'] + _debug('main0: expires_in=' + str(expires_in) + ', now=' + str(now) + + ', expires_at=' + str(expires_at) + ', diff=' + str(expires_at - now)) + if now > expires_at - 120: + _debug('call refreshToken') + token = refreshToken(token['refresh_token']) + if 'expires_in' in token: + expires_in = int(token['expires_in']) + expires_at = now + expires_in + store['expires_at'] = expires_at + store['Token'] = token + write_store() + else: + _error("refreshToken failed, re-authenticate") + expires_in = -1 + else: + expires_in = store['Token']['expires_in'] + + # if refreshToken fails, call requestToken + if expires_in == -1: + _debug('call requestToken') + token = requestToken(username, password) + + # compute expires_at and store file in ramdisk + if 'expires_in' in token: + if expires_in != int(token['expires_in']): + expires_in = int(token['expires_in']) + expires_at = now + expires_in + store['expires_at'] = expires_at + store['Token'] = token + write_store() + else: + _error("requestToken failed") + store['expires_at'] = 0 + store['Token'] = token + write_store() + _debug('main: token=\n' + json.dumps(token, indent=4)) data = requestData(token, vin) soc = int(data["state"]["electricChargingState"]["chargingLevelPercent"]) - print("Download sucessful - SoC: " + str(soc) + "%") + _info("Successful - SoC: " + str(soc) + "%" + ', method=' + method) except: - print("Request failed") + _error("Request failed") raise try: @@ -284,7 +451,7 @@ def main(): with open(statefile, 'w') as f: f.write(json.dumps(state)) except: - print("Saving SoC failed") + _error("Saving SoC failed") raise