- BMW i3 s funkčním Remote Services
- účet ConnectedDrive
- stroj FreeBSD 12 64bit (ideálně VPS virtuální privátní server) - pravidelné stahování a ukládání dat z BMW API + webový server pro následné zobrazení nasbíraných dat
https://shkspr.mobi/blog/2015/11/reverse-engineering-the-bmw-i3-api/
https://github.com/ipv6freely/bmw2018
Postupně si ukážeme jednotlivé kroky.
1. stroj FreeBSD 12 64bit
Lze objednat VPS u forpsicloud.cz za 70 Kč/měsíc. Výhoda nonstop běh stroje v cloudu. Zde pro příklad použijeme systém nainstalovaný ve virtualboxu.
1. nainstalujeme virtualbox https://www.virtualbox.org/wiki/Downloads
2. stáhneme FreeBSD virtual machine disk images https://download.freebsd.org/ftp/releases/VM-IMAGES/12.0-RELEASE/i386/Latest/FreeBSD-12.0-RELEASE-i386.vhd.xz
3. stažený image rozblíme a přidáme do virtualboxu
4. spustíme FreeBSD a přihlásíme se jako uživatel root
2. mc, python, první skript
2.1 nainstalujeme Midnight Commander
Kód: Vybrat vše
pkg install mc
2.2 nainstalujeme python a balíčky pythonu
Kód: Vybrat vše
pkg install python3
pkg install py36-boto3
pkg install py36-pip
pkg install py36-pymysql
pkg install py36-requests
2.3 Vytvoříme adresář, soubor bmw.py přeneseme z windows do freebsd a upravíme práva
Kód: Vybrat vše
mkdir /root/bmw
touch /root/bmw/bmw.py
chmod 754 /root/bmw/bmw.py
bmw.py
Kód: Vybrat vše
#! /usr/bin/env python
import requests
import json
import urllib
import boto3
import logging
import sys
import pickle
import pymysql
# Import smtplib for the actual sending function
import smtplib
# Import the email modules we'll need
from email.mime.text import MIMEText
logging.basicConfig(filename='/root/bmw/bmw.log',
filemode='a',
format='%(asctime)s,%(msecs)d %(name)s %(levelname)s %(message)s',
datefmt='%H:%M:%S',
level=logging.INFO)
# API Gateway
# North America: b2vapi.bmwgroup.us
# Rest of World: b2vapi.bmwgroup.com
# China: b2vapi.bmwgroup.cn:8592
BASE_URL = 'b2vapi.bmwgroup.com'
def getToken(BASE_URL, username, password):
try:
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Content-Length": "124",
"Connection": "Keep-Alive",
"Host": BASE_URL,
"Accept-Encoding": "gzip",
"Authorization": "Basic blF2NkNxdHhKdVhXUDc0eGYzQ0p3VUVQOjF6REh4NnVuNGNEanli"
"TEVOTjNreWZ1bVgya0VZaWdXUGNRcGR2RFJwSUJrN3JPSg==",
"Credentials": "nQv6CqtxJuXWP74xf3CJwUEP:1zDHx6un4cDjybLENN3kyfumX2kEYigWPcQpdvDRpIBk7rOJ",
"User-Agent": "okhttp/2.60"}
data = {
'grant_type': 'password',
'scope': 'authenticate_user vehicle_data remote_services',
'username': username,
'password': password}
data = urllib.parse.urlencode(data)
url = 'https://' + BASE_URL + '/webapi/oauth/token'
r = requests.post(url, data=data, headers=headers)
if r.status_code == 200:
logging.info('Access token acquired')
return r.json()['access_token']
else:
raise ValueError('Unable to login')
except Exception as msg:
logging.error(msg)
sys.exit(1)
def getSocData(BASE_URL, token, vin):
try:
headers = {'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_1_1 like Mac OS X) AppleWebKit/604.3.5 (KHTML, like Gecko) Version/11.0 Mobile/15B150 Safari/604.1',
'Authorization': 'Bearer ' + token}
url = 'https://' + BASE_URL + '/api/vehicle/navigation/v1/' + vin
r = requests.get(url, headers=headers)
if r.status_code == 200:
status = r.json()
if status.get('socmax'):
socMax = status['socmax']
if status.get('socMax'):
socMax = status['socMax']
logging.info('SoC Data retreived successfully')
return socMax
else:
raise ValueError('Unable to retreive SoC data from vehicle')
except Exception as msg:
logging.error(msg)
sys.exit(1)
def getBattery(BASE_URL, token, vin):
try:
headers = {'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_1_1 like Mac OS X) AppleWebKit/604.3.5 (KHTML, like Gecko) Version/11.0 Mobile/15B150 Safari/604.1',
'Authorization': 'Bearer ' + token}
url = 'https://' + BASE_URL + '/webapi/v1/user/vehicles/' + vin + '/status'
r = requests.get(url, headers=headers)
if r.status_code == 200:
status = r.json()
chargingLevelHv = status['vehicleStatus']['chargingLevelHv']
remainingRangeElectric = status['vehicleStatus']['remainingRangeElectric']
connectionStatus = status['vehicleStatus']['connectionStatus']
chargingStatus = status['vehicleStatus']['chargingStatus']
mileage = status['vehicleStatus']['mileage']
positionlat = status['vehicleStatus']['position']['lat']
positionlon = status['vehicleStatus']['position']['lon']
logging.info('Battery data retreived successfully')
return chargingLevelHv, remainingRangeElectric, connectionStatus, chargingStatus, mileage, positionlat, positionlon
else:
raise ValueError('Unable to retreive battery data from vehicle')
except Exception as msg:
logging.error(msg)
sys.exit(1)
def sendEMAIL(message_chargingLevelHv,message_connectionStatus,message_chargingStatus,message_subjectEmoji):
try:
logging.info(message_chargingLevelHv)
logging.info(message_connectionStatus)
logging.info(message_chargingStatus)
SMTP_SERVER = 'smtp.gmail.com'
SMTP_PORT = 587
GMAIL_USERNAME = 'XXX@gmail.com'
GMAIL_PASSWORD = 'XXXhesloXXX' #CAUTION: This is stored in plain text!
recipient = 'XXX@gmail.com'
Message_chargingLevelHv=str(message_chargingLevelHv)
# message_subjectEmoji = '=?utf-8?Q? =F0=9F=9A=97 ?='
subject = message_subjectEmoji + ' BMW ' + Message_chargingLevelHv + '% '
emailText = 'pripojeni:' + message_connectionStatus + '<br>nabijeni:' + message_chargingStatus
headers = ["From: " + GMAIL_USERNAME,
"Subject: " + subject,
"To: " + recipient,
"MIME-Version: 1.0",
"Content-Type: text/html"]
headers = "\r\n".join(headers)
session = smtplib.SMTP(SMTP_SERVER, SMTP_PORT)
session.ehlo()
session.starttls()
session.ehlo
session.login(GMAIL_USERNAME, GMAIL_PASSWORD)
session.sendmail(GMAIL_USERNAME, recipient, headers + "\r\n\r\n" + emailText)
session.quit()
logging.info('EMAIL message sent successfully')
except Exception as msg:
logging.error(msg)
sys.exit(1)
def getCredentials():
with open('/root/bmw/credentials.json', 'r') as cf:
credentials = json.load(cf)
return credentials
def getLastPercent():
try:
with open ('/root/bmw/last_percent', 'rb') as lp:
last_percent = pickle.load(lp)
logging.info(f'last_percent loaded with a value of {last_percent}')
return last_percent
except FileNotFoundError:
logging.error('last_percent file not found')
with open('/root/bmw/last_percent', 'wb') as lp:
pickle.dump(0, lp)
logging.info('last_percent file created with value of 0')
with open ('/root/bmw/last_percent', 'rb') as lp:
last_percent = pickle.load(lp)
return last_percent
def setLastPercent(chargingLevelHv):
try:
with open('/root/bmw/last_percent', 'wb') as lp:
pickle.dump(chargingLevelHv, lp)
logging.info(f'last_percent file updated with {chargingLevelHv}')
except Exception as msg:
logging.error(msg)
sys.exit(1)
try:
last_percent = int(getLastPercent())
credentials = getCredentials()
token = getToken(BASE_URL, credentials['username'], credentials['password'])
chargingLevelHv, remainingRangeElectric, connectionStatus, chargingStatus, mileage, positionlat, positionlon = getBattery(BASE_URL, token, credentials['vin'])
socMax = getSocData(BASE_URL, token, credentials['vin'])
message = 'BMW i3 Status:\n' + f'Battery: {chargingLevelHv}% ({remainingRangeElectric} km) - {connectionStatus}/{chargingStatus}\nMileage: {mileage} km\nPosition http://maps.apple.com/?ll={positionlat},{positionlon}\n' + f'socMax: {socMax} kWh\n'
# conn = pymysql.connect(host='127.0.0.1', unix_socket='/tmp/mysql.sock', user='root', passwd='XXXhesloXXX', db='bmw')
cur = conn.cursor()
try:
# affected_count = cur.execute('''INSERT INTO i3 (mileage,positionlat,positionlon,chargingLevelHv,remainingRangeElectric,socMax,connectionStatus,chargingStatus) VALUES (%s,%s,%s,%s,%s,%s,%s,%s)''',(mileage,positionlat,positionlon,chargingLevelHv,remainingRangeElectric,socMax,connectionStatus,chargingStatus))
# conn.commit()
logging.info("inserted values to DB")
except pymysql.IntegrityError:
logging.warn("failed to insert values")
finally:
cur.close()
conn.close()
# nabijeni poprve dosalo 100 procent (mame plne nabito)
if chargingLevelHv > last_percent and chargingLevelHv == 100:
setLastPercent(chargingLevelHv)
message_subjectEmoji = ''
sendEMAIL(chargingLevelHv,connectionStatus,chargingStatus,message_subjectEmoji)
logging.info(message)
# blizime se uz nabitemu stavu
elif chargingLevelHv > last_percent and chargingLevelHv > 83:
setLastPercent(chargingLevelHv)
message_subjectEmoji = ''
sendEMAIL(chargingLevelHv,connectionStatus,chargingStatus,message_subjectEmoji)
logging.info(message)
# chargingStatus
# INVALID
# ERROR
# NOT_CHARGING
# CHARGING
# FINISHED_FULLY_CHARGED
# connectionStatus
# CONNECTED
# DISCONNECTED
# DISCONNECTED INVALID vuz neni pripojen a nenabiji se - nejcasteji
# CONNECTED NOT_CHARGING vuz je pripojen a nenabiji se - vypadek napajeni napr. BILLA
# kabel pripojen a charging INVALID
elif connectionStatus == 'CONNECTED' and chargingStatus == 'INVALID':
setLastPercent(chargingLevelHv)
message_subjectEmoji = '=?utf-8?Q? =E2=9D=8C ?='
sendEMAIL(chargingLevelHv,connectionStatus,chargingStatus,message_subjectEmoji)
logging.info(message)
# kabel pripojen a charging ERROR
elif connectionStatus == 'CONNECTED' and chargingStatus == 'ERROR':
setLastPercent(chargingLevelHv)
message_subjectEmoji = '=?utf-8?Q? =E2=9D=97=E2=9D=97 ?='
sendEMAIL(chargingLevelHv,connectionStatus,chargingStatus,message_subjectEmoji)
logging.info(message)
# kabel pripojen a charging NOT_CHARGING
elif connectionStatus == 'CONNECTED' and chargingStatus == 'NOT_CHARGING' and chargingLevelHv > last_percent:
setLastPercent(chargingLevelHv)
message_subjectEmoji = '=?utf-8?Q? =E2=9A=A0=F0=9F=94=8C ?='
sendEMAIL(chargingLevelHv,connectionStatus,chargingStatus,message_subjectEmoji)
logging.info(message)
# kabel pripojen a charging FINISHED_FULLY_CHARGED
elif connectionStatus == 'CONNECTED' and chargingStatus == 'FINISHED_FULLY_CHARGED' and chargingLevelHv > last_percent:
setLastPercent(chargingLevelHv)
message_subjectEmoji = '=?utf-8?Q? =F0=9F=92=AF ?='
sendEMAIL(chargingLevelHv,connectionStatus,chargingStatus,message_subjectEmoji)
logging.info(message)
else:
setLastPercent(chargingLevelHv)
message_subjectEmoji = ''
# message_subjectEmoji = '=?utf-8?Q? =F0=9F=94=B4=E2=9A=A0=F0=9F=94=8C ?='
# message_subjectEmoji = '=?utf-8?Q? =E2=9D=97=E2=9D=97 ?='
# sendEMAIL(chargingLevelHv,connectionStatus,chargingStatus,message_subjectEmoji)
logging.info(message)
except Exception as msg:
logging.error(msg)
sys.exit(1)
Ve Windows 10 nainstalujeme Server OpenSSH podle návodu
https://winaero.com/blog/enable-openssh-server-windows-10/
Stáhneme bmw.py z Windows 10
Kód: Vybrat vše
scp windows_username@10.0.2.2:/bmw.py /root/bmw/
2.4 Vytvoříme credentials.json a upravíme práva
Kód: Vybrat vše
touch /root/bmw/credentials.json
chmod 754 /root/bmw/credentials.json
credentials.json
Kód: Vybrat vše
{
"username": "uzivatelske_jmeno_ci_email@pro_ucet_ConnectedDrive.com",
"access_token": "TOKEN",
"vin": "WBY0Z12345V678901",
"password": "heslo_do_uctu_ConnectedDrive"
}
Username a password - přihlašovací údaje které používáme u BMW iRemote appky
2.5 první spuštění skriptu bmw.py a kontrola logu
Spustíme skript
Kód: Vybrat vše
python3 /root/bmw/bmw.py
Skript by si neměl na nic stěžovat. Pokud ano, nutno řešit... Pokud si nestěžuje, můžeme zkontrolovat log:
Kód: Vybrat vše
cat /root/bmw/bmw.log
V případě úspěchu uvidíme:
Kód: Vybrat vše
05:03:31,649 root ERROR last_percent file not found
05:03:31,649 root INFO last_percent file created with value of 0
05:03:32,548 root INFO Access token acquired
05:03:33,210 root INFO Battery data retreived successfully
05:03:34,393 root INFO SoC Data retreived successfully
05:03:34,393 root ERROR name 'conn' is not defined
Pokud v logu vidíme předposlední dva řádky Data retreived successfully, pak máme vše v pořádku a těšíme se na další díl seriálu.
V případě neúspěchu uvidíme:
Kód: Vybrat vše
06:35:33,888 root INFO last_percent loaded with a value of 0
06:35:35,930 root ERROR Unable to login
Unable to login značí nejspíše překlep v credentials.json
3. databáze
3.1 instalujeme MySQL
instalace
Kód: Vybrat vše
pkg install mysql56-server
Přidá do /etc/rc.conf mysql_enable=YES
Kód: Vybrat vše
sysrc mysql_enable=YES
Nastartujeme mysql server
Kód: Vybrat vše
service mysql-server start
instalace py36-mysql-connector-python
Kód: Vybrat vše
pkg install py36-mysql-connector-python
3.2 Pomocí mysql interactive interface se připojíme na MySQL server
Kód: Vybrat vše
/usr/local/bin/mysql -u root -p
3.3 V mysql interactive interface zadáme následující příkazy:
Změna privilegií
Kód: Vybrat vše
GRANT ALL PRIVILEGES ON *.* TO 'root'@'localhost' IDENTIFIED BY 'XXXhesloXXX';
Vytvoření databáze
Kód: Vybrat vše
CREATE DATABASE bmw;
Použítí databáze
Kód: Vybrat vše
USE bmw;
Vytvoření tabulek
Kód: Vybrat vše
CREATE TABLE `i3` (`datum` timestamp NULL DEFAULT CURRENT_TIMESTAMP, `mileage` mediumint(9) NOT NULL, `positionlat` decimal(10,8) NOT NULL, `positionlon` decimal(11,8) NOT NULL, `chargingLevelHv` tinyint(4) NOT NULL, `remainingRangeElectric` tinyint(4) NOT NULL, `socMax` float NOT NULL, `connectionStatus` tinytext COLLATE utf8mb4_unicode_ci NOT NULL, `chargingStatus` tinytext COLLATE utf8mb4_unicode_ci NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
A ukončíme práci s databází:
Kód: Vybrat vše
quit
3.4 Upravíme skript pro uložení dat do databáze
Kód: Vybrat vše
mcedit /root/bmw/bmw.py
kde odkomentujeme řádky 182, 186 a 187 (odmažeme znak #)
Stávající stav skriptu:
Kód: Vybrat vše
# conn = pymysql.connect(host='127.0.0.1', unix_socket='/tmp/mysql.sock', user='root', passwd='XXXhesloXXX', db='bmw')
# affected_count = cur.execute('''INSERT INTO i3 (mileage,positionlat,positionlon,chargingLevelHv,remainingRangeElectric,socMax,connectionStatus,chargingStatus) VALUES (%s,%s,%s,%s,%s,%s,%s,%s)''',(mileage,positionlat,positionlon,chargingLevelHv,remainingRangeElectric,socMax,connectionStatus,chargingStatus))
# conn.commit()
Po editaci
Kód: Vybrat vše
conn = pymysql.connect(host='127.0.0.1', unix_socket='/tmp/mysql.sock', user='root', passwd='XXXhesloXXX', db='bmw')
affected_count = cur.execute('''INSERT INTO i3 (mileage,positionlat,positionlon,chargingLevelHv,remainingRangeElectric,socMax,connectionStatus,chargingStatus) VALUES (%s,%s,%s,%s,%s,%s,%s,%s)''',(mileage,positionlat,positionlon,chargingLevelHv,remainingRangeElectric,socMax,connectionStatus,chargingStatus))
conn.commit()
POZOR !!! výplň před "conn ..." nesmí být ze znaků mezer. Musí zde být odsazení tabulátorem (smažeme znaky mezery a odsadíme stiskem klávesy tabulátor). Jinak bude skript selhávat !!
3.5 Spustíme skrip a zkontolujeme log
Kód: Vybrat vše
python3 /root/bmw/bmw.py
Vypíšeme log
Kód: Vybrat vše
cat /root/bmw/bmw.log
Kde uvidíme řádky:
Kód: Vybrat vše
02:22:09,80 root INFO last_percent loaded with a value of 100
02:22:09,632 root INFO Access token acquired
02:22:10,235 root INFO Battery data retreived successfully
02:22:11,270 root INFO SoC Data retreived successfully
02:22:11,274 root INFO inserted values to DB
02:22:11,275 root INFO last_percent file updated with 100
02:22:11,275 root INFO BMW i3 Status:
Battery: 100% (93 km) - CONNECTED/FINISHED_FULLY_CHARGED
3.6 Zajistíme pravidelné spouštění skriptu CRONem
editujeme crontab
Kód: Vybrat vše
mcedit /etc/crontab
a přidáme řádek
Kód: Vybrat vše
*/5 * * * * root /usr/local/bin/python3 /root/bmw/bmw.py
4. web server
4.1 instalujeme PHP
Kód: Vybrat vše
pkg install php71
4.2 Instalujeme nginx
Kód: Vybrat vše
pkg install nginx
Přidá do /etc/rc.conf nginx_enable=YES
Kód: Vybrat vše
sysrc nginx_enable=YES