Skip to content

Instantly share code, notes, and snippets.

@mountbatt
Last active June 9, 2024 02:58
Show Gist options
  • Save mountbatt/772e4512089802a2aa2622058dd1ded7 to your computer and use it in GitHub Desktop.
Save mountbatt/772e4512089802a2aa2622058dd1ded7 to your computer and use it in GitHub Desktop.
Scriptable iOS widget that displays the status of your Renault ZOE (or Megane, Dacia Spring) on your iPhone and iPad.
// Variables used by Scriptable.
// These must be at the very top of the file. Do not edit.
// icon-color: light-gray; icon-glyph: car;
// version 2024-04-19
// latest changes:
// new kameron api key
// added language strings so you can translate it by yourself!
// add your my-renault account data:
// let myRenaultUser = "user" // email
// let myRenaultPass = "pass" // password
let myRenaultUser = "your_email" // email
let myRenaultPass = "your_password" // password
// set your ZOE Model (Phase 1 or 2) // bitte eingeben!
let ZOE_Phase = "2" // "1" or "2"
// set your battery size in kWh // bitte eingeben!
let ZOE_Battery = "52" // "52" or "41" or "22" or "21"
// should we use apple-maps or google maps?
let mapProvider = "apple" // "apple" or "google"
// optional:
// change that number to eg. 2 if you want to select an another car from your account.
// 1 is the first car, 2 will be the second.
let carNumber = 1;
// optional
// account number
// if you have problems with accessing the data, try to change this number from 0 to 1
let accountNumber = 0;
// enter your VIN / FIN if you have any problems
// leave it empty if everything works // leer lassen, wenn alles läuft
let VIN = "" // starts with VF1... enter like this: "VF1XXXXXXXXX"
// Language Strings
// GERMAN / DEUTSCH
let text_ladestand = "Ladestand"
let text_reichweite = "Reichweite"
let text_restenergie = "Restenergie"
let text_kilometerstand = "Kilometerstand"
let text_position = "Position"
let text_klimatisierung = "Klimatisierung"
let text_karte_oeffnen = "➤ Karte öffnen"
let text_starten = "➤ Starten"
let text_stoppen = "➤ Stoppen"
let text_wird_geladen = "⚡ Wird geladen …"
let text_gekoppelt = "🟢 Gekoppelt"
let text_entkoppelt = "⚫ Entkoppelt"
let text_laden_starten = "➤ Laden starten"
let text_akkutemp = "Akkutemp."
/*
// ENGLISH
let text_ladestand = "Charge level"
let text_reichweite = "Range"
let text_restenergie = "Remaining energy"
let text_kilometerstand = "Mileage"
let text_position = "Position"
let text_klimatisierung = "Climate control"
let text_karte_oeffnen = "➤ Open map"
let text_starten = "➤ Start"
let text_stoppen = "➤ Stop"
let text_wird_geladen = "⚡ Charging..."
let text_gekoppelt = "🟢 Connected"
let text_entkoppelt = "⚫ Disconnected"
let text_laden_starten = "➤ Start charging"
let text_akkutemp = "Battery temp."
*/
/*
// FRENCH:
let text_ladestand = "Niveau de charge"
let text_reichweite = "Autonomie"
let text_restenergie = "Énergie restante"
let text_kilometerstand = "Kilométrage"
let text_position = "Position"
let text_klimatisierung = "Climatisation"
let text_karte_oeffnen = "➤ Ouvrir la carte"
let text_starten = "➤ Démarrer"
let text_stoppen = "➤ Arrêter"
let text_wird_geladen = "⚡ En charge ..."
let text_gekoppelt = "🟢 Connecté"
let text_entkoppelt = "⚫ Déconnecté"
let text_laden_starten = "➤ Démarrer la charge"
let text_akkutemp = "Temp. de la batterie"
*/
// do not edit
let kamareonURL = "https://api-wired-prod-1-euw1.wrd-aws.com"
let kamareonAPI = "YjkKtHmGfaceeuExUDKGxrLZGGvtVS0J"
let gigyaURL = "https://accounts.eu1.gigya.com"
let gigyaAPI = "3_7PLksOyBRkHv126x5WhHb-5pqC1qFR8pQjxSeLB6nhAnPERTUlwnYoznHSxwX668" // austria: "3__B4KghyeUb0GlpU62ZXKrjSfb7CPzwBS368wioftJUL5qXE0Z_sSy0rX69klXuHy"
const timenow = new Date().toJSON().slice(0, 13).replace(/-/g, '').replace(/T/g, '-') //20201028-14 (14 = hour)
// clear everything from keychain if we are on an other day
if (Keychain.contains('lastJWTCall') && Keychain.get('lastJWTCall') != timenow) {
clearKeychain()
//console.log("Keychain cleared")
}
// clear keychain, if script gets called with action parameters (to get new tokens)
if (args.queryParameters.action != "") {
clearKeychain()
//console.log("Keychain cleared cause of action parameters")
}
function clearKeychain() {
if (Keychain.contains('VIN')) { Keychain.remove('VIN') }
if (Keychain.contains('carPicture')) { Keychain.remove('carPicture') } // enable if picture is wrong
if (Keychain.contains('account_id')) { Keychain.remove('account_id') }
if (Keychain.contains('gigyaJWTToken')) { Keychain.remove('gigyaJWTToken') }
if (Keychain.contains('gigyaCookieValue')) { Keychain.remove('gigyaCookieValue') }
if (Keychain.contains('gigyaPersonID')) { Keychain.remove('gigyaPersonID') }
}
if (VIN && VIN != "") {
Keychain.set('VIN', VIN)
}
const widget = new ListWidget()
await createWidget()
// used for debugging if script runs inside the app
if (!config.runsInWidget) {
await widget.presentMedium()
}
Script.setWidget(widget)
Script.complete()
// build the widget
async function createWidget(items) {
// get all data in a single variable
const data = await getData()
console.log(data)
//widget.refreshAfterDate = new Date(Date.now() + 300) // dont know if this works
widget.setPadding(10, 0, 10, 20)
const wrap = widget.addStack()
wrap.layoutHorizontally()
wrap.topAlignContent()
wrap.spacing = 15
const column0 = wrap.addStack()
column0.layoutVertically()
if (data.carPicture) {
const icon = await getImage("my-renault-car-" + VIN + ".png", data.carPicture)
//console.log("getting my-renault-car-"+VIN+".png")
//console.log("current icon: " + data.carPicture)
let CarStack = column0.addStack()
let iconImg = CarStack.addImage(icon)
// simple hack if we have a phase 1 model (no location data & no hvac-status available) – resize car-image
// not the smartest solution - but i try to check if the results show only 1 column.
// if column2 is empty, we have to resizes the car-image for better styling
if (typeof(data.locationStatus) == 'undefined' && typeof(data.hvacStatus) == 'undefined') {
iconImg.imageSize = new Size(130, 73)
}
}
column0.addSpacer(8)
if (typeof(data.batteryStatus) != 'undefined') {
let plugIcon
let plugStateLabel
let plugStateUrl
let scriptName = encodeURIComponent(Script.name())
const PlugWrap = column0.addStack()
PlugWrap.layoutHorizontally()
//PlugWrap.setPadding(0,15,0,15)
if (data.batteryStatus.attributes.plugStatus != 1) {
//plugIcon = await getImage("zoe-plug-off.png", "")
plugStateLabel = text_entkoppelt
} else {
//plugIcon = await getImage("zoe-plug-on.png", "")
plugStateLabel = text_gekoppelt
}
if (data.batteryStatus.attributes.chargingStatus == "1.0") {
plugStateLabel = text_wird_geladen
}
if (data.batteryStatus.attributes.plugStatus == 1 && data.batteryStatus.attributes.chargingStatus == "0") {
plugStateLabel = text_laden_starten
plugStateUrl = `scriptable:///run?scriptName=${scriptName}&action=start_charge`;
}
const PlugText = PlugWrap.addStack()
PlugText.setPadding(0, 10, 0, 0)
PlugText.layoutVertically()
plugStateLabel = PlugText.addText(plugStateLabel)
plugStateLabel.font = Font.regularSystemFont(10)
plugStateLabel.url = plugStateUrl
PlugText.addSpacer(6)
if (data.batteryStatus.attributes.chargingStatus == "1.0") { // must be 1.0, debug = 0
// (Akku minus verf. Energie) geteilt durch Restzeit = Ladegeschwindigkeit (by Marc)
let batteryAvailableEnergy = data.batteryStatus.attributes.batteryAvailableEnergy;
let chargingInstantaneousPower = data.batteryStatus.attributes.chargingInstantaneousPower
chargingInstantaneousPower = Math.round(chargingInstantaneousPower)
// check if the numbers are in Watt or kW
if (chargingInstantaneousPower > 150) {
// if over 200, we believe the value is in watt :-)
chargingInstantaneousPower = chargingInstantaneousPower / 1000
}
//console.log('chargingInstantaneousPower: ' + chargingInstantaneousPower)
chargingInstantaneousPower = Math.round(chargingInstantaneousPower).toLocaleString()
//console.log('chargingInstantaneousPower rounded: ' + chargingInstantaneousPower)
let chargingPower = chargingInstantaneousPower
if (ZOE_Battery) {
chargingPower = (ZOE_Battery - batteryAvailableEnergy) / (data.batteryStatus.attributes.chargingRemainingTime / 60)
//console.log("chargingPower calculated: " + chargingPower);
chargingPower = chargingPower.toFixed(1).toLocaleString()
}
//console.log('chargingPower final result: ' + chargingPower)
let chargingRemainingTime = time_convert(data.batteryStatus.attributes.chargingRemainingTime)
chargingRemainingTimeString = " | " + chargingRemainingTime + " h"
chargeStateLabel = " " + chargingPower + " kW" + chargingRemainingTimeString
chargeStateLabel = PlugText.addText(chargeStateLabel)
chargeStateLabel.font = Font.regularSystemFont(10)
PlugText.addSpacer(2)
}
}
const column1 = wrap.addStack()
column1.layoutVertically()
//column1.addSpacer(3)
// simple quota-limit check:
// (battery status is the first request – if it reports nothing, we can be sure, that there will be no other data available at the moment)
if (!data.batteryStatus || typeof(data.batteryStatus) == "undefined") {
if (config.runsInWidget) { // only in widget
throw new Error('Quota Limit! – Datenabruf zur Zeit nicht möglich. Später nochmals versuchen oder bei Renault beschweren.')
} else {
console.log('Quota Limit! – Datenabruf zur Zeit nicht möglich. Später nochmals versuchen oder bei Renault beschweren.')
}
}
if (typeof(data.batteryStatus) != 'undefined') {
let BatteryStack = column1.addStack()
BatteryStack.layoutVertically()
const batteryStatusLabel = BatteryStack.addText(text_ladestand)
batteryStatusLabel.font = Font.mediumSystemFont(12)
const batteryStatusVal = BatteryStack.addText(data.batteryStatus.attributes.batteryLevel.toString() + " %")
batteryStatusVal.font = Font.boldSystemFont(16)
column1.addSpacer(10)
// push Message if maxSoC reachead
/* under development! */
/*
let maxSoC = 62
// if(batteryStatusVal == maxSoC && data.batteryStatus.attributes.chargingStatus != "-1.0"){
const delaySeconds = 1;
let currentDate = new Date;
let newDate = new Date(currentDate.getTime() + (delaySeconds * 1000));
chargeFull = new Notification()
chargeFull.identifier = "maxSoCReached"
chargeFull.title = "🔋 Geladen"
chargeFull.body = "Die Batterie Deines Fahrzeugs wurde zu " + maxSoC + " % geladen!"
chargeFull.sound = "complete"
chargeFull.setTriggerDate(newDate);
chargeFull.schedule()
// } */
}
if (typeof(data.batteryStatus) != 'undefined') {
let RangeStack = column1.addStack()
RangeStack.layoutVertically()
const RangeStatusLabel = RangeStack.addText(text_reichweite)
RangeStatusLabel.font = Font.mediumSystemFont(12)
const RangeStatusVal = RangeStack.addText(data.batteryStatus.attributes.batteryAutonomy.toString() + " km")
RangeStatusVal.font = Font.boldSystemFont(16)
column1.addSpacer(10)
}
if (ZOE_Phase == 1 && typeof(data.batteryStatus) != 'undefined') {
if (typeof(data.batteryStatus.attributes.batteryTemperature) != 'undefined') {
let TempStack = column1.addStack()
TempStack.layoutVertically()
const TempStatusLabel = TempStack.addText(text_akkutemp)
TempStatusLabel.font = Font.mediumSystemFont(12)
const TempStatusVal = TempStack.addText(data.batteryStatus.attributes.batteryTemperature.toString() + " °C")
TempStatusVal.font = Font.boldSystemFont(16)
}
}
if (ZOE_Phase == 2 && typeof(data.batteryStatus) != 'undefined') {
if (typeof(data.batteryStatus.attributes.batteryAvailableEnergy) != 'undefined') {
let AvEnergyStack = column1.addStack()
AvEnergyStack.layoutVertically()
const AvEnergyStatusLabel = AvEnergyStack.addText(text_restenergie)
AvEnergyStatusLabel.font = Font.mediumSystemFont(12)
const AvEnergyStatusVal = AvEnergyStack.addText(data.batteryStatus.attributes.batteryAvailableEnergy.toString() + " kWh")
AvEnergyStatusVal.font = Font.boldSystemFont(16)
}
}
const column2 = wrap.addStack()
column2.layoutVertically()
//column2.addSpacer(3)
if (typeof(data.cockpitStatus) != 'undefined') {
let MileageStack = column2.addStack()
MileageStack.layoutVertically()
const MileageStatusLabel = MileageStack.addText(text_kilometerstand)
MileageStatusLabel.font = Font.mediumSystemFont(12)
let mileage = Math.round(data.cockpitStatus.attributes.totalMileage).toLocaleString()
const MileageStatusVal = MileageStack.addText(mileage.toString() + " km")
MileageStatusVal.font = Font.boldSystemFont(16)
column2.addSpacer(10)
}
if (typeof(data.locationStatus) != 'undefined') {
let LocationStack = column2.addStack()
LocationStack.spacing = 2
LocationStack.layoutVertically()
const LocationLabel = LocationStack.addText(text_position)
LocationLabel.font = Font.mediumSystemFont(12)
const LocationVal = LocationStack.addText(text_karte_oeffnen)
LocationVal.font = Font.boldSystemFont(12)
if (mapProvider == "google") {
// https://www.google.com/maps/search/?api=1&query=58.698017,-152.522067
LocationVal.url = "https://www.google.com/maps/search/?api=1&query=" + data.locationStatus.attributes.gpsLatitude + "," + data.locationStatus.attributes.gpsLongitude
} else {
// fallback to apple…
// http://maps.apple.com/?ll=50.894967,4.341626
LocationVal.url = "http://maps.apple.com/?q=ZOE&ll=" + data.locationStatus.attributes.gpsLatitude + "," + data.locationStatus.attributes.gpsLongitude
}
//LocationStack.addSpacer(0.5)
column2.addSpacer(12)
}
//if(typeof(data.hvacStatus) != 'undefined'){ // we have to uncomment this later!
let AcStack = column2.addStack()
AcStack.spacing = 2
AcStack.layoutVertically()
const AcLabel = AcStack.addText(text_klimatisierung)
AcLabel.font = Font.mediumSystemFont(12)
// create a self-opening url to run the start_ac function
// could be nicer, but seems to work at the moment.
let scriptName = encodeURIComponent(Script.name())
let AcVal
let ac_url
if (args.queryParameters.action == 'start_ac') {
AcVal = AcStack.addText(text_stoppen)
ac_url = `scriptable:///run?scriptName=${scriptName}&action=stop_ac`;
} else {
AcVal = AcStack.addText(text_starten)
ac_url = `scriptable:///run?scriptName=${scriptName}&action=start_ac`;
}
AcVal.font = Font.boldSystemFont(12)
AcVal.url = ac_url
//} // we have to uncomment this later!
}
// fetch all data
async function getData() {
// we are going now a long way through multiple servers to get access to our data
// 1. fetch session and user data from gigya
let gigyaCookieValue
let gigyaPersonID
if (Keychain.contains('gigyaCookieValue') && Keychain.get('gigyaCookieValue') != "") {
gigyaCookieValue = Keychain.get('gigyaCookieValue')
}
//console.log('gigyaCookieValue (from keychain): ' + gigyaCookieValue)
if (Keychain.contains('gigyaPersonID') && Keychain.get('gigyaPersonID') != "") {
gigyaPersonID = Keychain.get('gigyaPersonID')
}
//console.log('gigyaPersonID (from keychain): ' + gigyaPersonID)
if (gigyaCookieValue == "" || gigyaPersonID == "" ||
typeof(gigyaCookieValue) == "undefined" || typeof(gigyaPersonID) == "undefined") {
let url = gigyaURL + '/accounts.login?loginID=' + encodeURIComponent(myRenaultUser) + '&password=' + encodeURIComponent(myRenaultPass) + '&include=data&apiKey=' + gigyaAPI
let req = new Request(url)
let apiResult = await req.loadString()
apiResult = JSON.parse(apiResult)
//console.log("1.: " + apiResult.statusCode)
if (apiResult.statusCode == "403") {
let loginMessage = "Login nicht möglich. Zugangsdaten prüfen."
throw new Error(loginMessage);
} else {
gigyaCookieValue = apiResult.sessionInfo.cookieValue
gigyaPersonID = apiResult.data.personId
Keychain.set('gigyaCookieValue', gigyaCookieValue)
Keychain.set('gigyaPersonID', gigyaPersonID)
//console.log('gigyaCookieValue (new generated): ' + gigyaCookieValue)
//console.log('gigyaPersonID (new generated): ' + gigyaPersonID)
}
}
// 2. fetch JWT data from gigya
// renew gigyaJWTToken once a day
if (Keychain.contains('lastJWTCall') == false) {
Keychain.set('lastJWTCall', 'never')
}
let gigyaJWTToken
if (Keychain.contains('gigyaJWTToken')) {
gigyaJWTToken = Keychain.get('gigyaJWTToken')
}
//console.log('gigyaJWTToken (from keychain): ' + gigyaJWTToken)
if (gigyaJWTToken == "" || typeof(gigyaJWTToken) == "undefined") {
let expiration = 87000
url = gigyaURL + '/accounts.getJWT?oauth_token=' + gigyaCookieValue + '&login_token=' + gigyaCookieValue + '&expiration=' + expiration + '&fields=data.personId,data.gigyaDataCenter&ApiKey=' + gigyaAPI
req = new Request(url)
apiResult = await req.loadString()
apiResult = JSON.parse(apiResult)
//console.log("3.: " + apiResult.statusCode)
gigyaJWTToken = apiResult.id_token
Keychain.set('gigyaJWTToken', gigyaJWTToken)
//console.log('gigyaJWTToken (new generated): ' + gigyaJWTToken)
const callDate = new Date().toJSON().slice(0, 13).replace(/-/g, '').replace(/T/g, '-')
Keychain.set('lastJWTCall', callDate)
//console.log('lastJWTCall (new generated): ' + callDate)
}
// 3. fetch data from kamereon (person)
// if not in Keychain (we try to avoid quota limits here)
let account_id
if (Keychain.contains('account_id')) {
account_id = Keychain.get('account_id')
}
//console.log('account_id (from keychain): ' + account_id)
if (account_id == "" || typeof(account_id) == "undefined") {
url = kamareonURL + '/commerce/v1/persons/' + gigyaPersonID + '?country=DE'
req = new Request(url)
req.method = "GET"
req.headers = { "x-gigya-id_token": gigyaJWTToken, "apikey": kamareonAPI }
apiResult = await req.loadString()
//console.log("4.: " + apiResult)
apiResult = JSON.parse(apiResult)
if (apiResult.type == "FUNCTIONAL") {
let quotaMessage = apiResult.messages[0].message + " – Login derzeit nicht möglich. Später nochmal versuchen."
throw new Error(quotaMessage);
} else {
account_id = apiResult.accounts[accountNumber].accountId
Keychain.set('account_id', account_id)
//console.log('account_id (new generated): ' + account_id)
}
}
// 4. fetch data from kamereon (all vehicles data)
// we need this only once to get the picture of the car and the VIN!
let carPicture
if (Keychain.contains('carPicture')) {
carPicture = Keychain.get('carPicture')
}
//console.log('carPicture (from keychain): ' + carPicture)
if (Keychain.contains('VIN') && Keychain.get('VIN') != "") {
VIN = Keychain.get('VIN')
}
//console.log('VIN (from keychain): ' + VIN)
if (carPicture == "" || typeof(carPicture) == "undefined" || VIN == "" || typeof(VIN) == "undefined") {
url = kamareonURL + '/commerce/v1/accounts/' + account_id + '/vehicles?country=DE'
req = new Request(url)
req.method = "GET"
req.headers = { "x-gigya-id_token": gigyaJWTToken, "apikey": kamareonAPI }
apiResult = await req.loadString()
apiResult = JSON.parse(apiResult)
if (carNumber == "") { // fallback
carNumber = 0;
}
// set correct carNumber to array (starts with 0)
carNumber = carNumber - 1;
//console.log("carNumber: " + carNumber)
// set carPicture
carPicture = await apiResult.vehicleLinks[carNumber].vehicleDetails.assets[0].renditions[0].url
Keychain.set('carPicture', carPicture)
//console.log('carPicture (new): ' + carPicture)
// set VIN
if (VIN == "" || typeof(VIN) == "undefined") {
VIN = apiResult.vehicleLinks[carNumber].vin
Keychain.set('VIN', VIN)
//console.log('VIN (new generated): ' + VIN)
}
}
// log all vehicle data:
// console.log(apiResult.vehicleLinks)
// NOW WE CAN READ AND SET EVERYTHING INTO AN OBJECT:
const allResults = {};
// real configurator picture of the vehicle
// old call: let carPicture = allVehicleData.vehicleLinks[0].vehicleDetails.assets[0].renditions[0].url // renditions[0] = large // renditions[1] = small image
allResults["carPicture"] = carPicture
// batteryStatus
// version: 2
// batteryLevel = Num (percentage)
// plugStatus = bolean (0/1)
// chargeStatus = bolean (0/1) (?)
let batteryStatus = await getStatus('battery-status', 2, kamareonURL, account_id, VIN, gigyaJWTToken, kamareonAPI)
allResults["batteryStatus"] = batteryStatus
// cockpitStatus
// version: 2
// totalMileage = Num (in Kilometres!)
let cockpitStatus = await getStatus('cockpit', 1, kamareonURL, account_id, VIN, gigyaJWTToken, kamareonAPI)
allResults["cockpitStatus"] = cockpitStatus
// locationStatus
// version: 1
// gpsLatitude
// gpsLongitude
// LastUpdateTime
let locationStatus = await getStatus('location', 1, kamareonURL, account_id, VIN, gigyaJWTToken, kamareonAPI)
allResults["locationStatus"] = locationStatus
// chargeSchedule
// note: unused at the moment!
// version: 1
let chargeSchedule = await getStatus('charging-settings', 1, kamareonURL, account_id, VIN, gigyaJWTToken, kamareonAPI)
allResults["chargeSchedule"] = chargeSchedule
// hvacStatus
// version: 1
let hvacStatus = await getStatus('hvac-status', 1, kamareonURL, account_id, VIN, gigyaJWTToken, kamareonAPI)
allResults["hvacStatus"] = hvacStatus
//console.log('hvacStatus: ' + hvacStatus)
// query parameter / args
// if query action = "start_ac" we start "vorklimatisierung"
// default temperature will be 21°C
let query_action = args.queryParameters.action
if (query_action == "start_ac") {
let attr_data = '{"data":{"type":"HvacStart","attributes":{"action":"start","targetTemperature":"21"}}}'
let action = await postStatus('hvac-start', attr_data.toString(), 1, kamareonURL, account_id, VIN, gigyaJWTToken, kamareonAPI)
//console.log("start_ac_action: " + action)
//throw new Error(action)
}
if (query_action == "stop_ac") {
let attr_data = '{"data":{"type":"HvacStart","attributes":{"action":"cancel"}}}'
let action = await postStatus('hvac-start', attr_data.toString(), 1, kamareonURL, account_id, VIN, gigyaJWTToken, kamareonAPI)
//console.log("stop_ac_action: " + action)
}
if (query_action == "start_charge") {
let attr_data = '{"data":{"type":"ChargingStart","attributes":{"action":"start"}}}'
let action = await postStatus('charging-start', attr_data.toString(), 1, kamareonURL, account_id, VIN, gigyaJWTToken, kamareonAPI)
console.log("start_charge_action: " + action)
}
//console.log(allResults)
// return array
return allResults
}
// general function to get status-values from our vehicle
async function getStatus(endpoint, version = 1, kamareonURL, account_id, VIN, gigyaJWTToken, kamareonAPI) {
// fetch data from kamereon (single vehicle)
url = kamareonURL + '/commerce/v1/accounts/' + account_id + '/kamereon/kca/car-adapter/v' + version + '/cars/' + VIN + '/' + endpoint + '?country=DE'
req = new Request(url)
req.method = "GET"
req.headers = { "x-gigya-id_token": gigyaJWTToken, "apikey": kamareonAPI, "Content-type": "application/vnd.api+json" }
apiResult = await req.loadString()
if (req.response.statusCode == 200) {
apiResult = JSON.parse(apiResult)
}
return apiResult.data
console.log(apiResult.data)
}
// general function to POST status-values to our vehicle
async function postStatus(endpoint, jsondata, version, kamareonURL, account_id, VIN, gigyaJWTToken, kamareonAPI) {
url = kamareonURL + '/commerce/v1/accounts/' + account_id + '/kamereon/kca/car-adapter/v' + version + '/cars/' + VIN + '/actions/' + endpoint + '?country=DE'
request = new Request(url)
request.method = "POST"
request.body = jsondata
request.headers = { "x-gigya-id_token": gigyaJWTToken, "apikey": kamareonAPI, "Content-type": "application/vnd.api+json" }
apiResult = await request.loadString()
//console.log(apiResult)
//debug:
// throw new Error(url)
let pushBody
let sound
if (request.response.statusCode == 200) {
pushBody = "Die Übermittlung des Befehls war erfolgreich."
sound = "piano_success"
} else {
pushBody = "Es ist ein Fehler beim Senden des Befehls aufgetreten. Keine Verbindung. Code:" + request.response.statusCode
sound = "piano_error"
}
pushMessage = new Notification()
pushMessage.identifier = "zoePostStatus"
if (endpoint == "hvac-start") {
pushMessage.title = "Kommando an Klimaanlage gesendet"
}
if (endpoint == "charge-start") {
pushMessage.title = "Kommando an Ladeanlage gesendet"
}
//pushMessage.title = "Befehl gesendet"
pushMessage.body = pushBody
pushMessage.sound = sound
//pushMessage.setTriggerDate(newDate);
pushMessage.schedule()
return apiResult
}
function time_convert(num) {
var hours = Math.floor(num / 60);
var minutes = num % 60;
return hours + ":" + minutes;
}
// get images from local filestore or download them once
// this part is inspired by the dm-toilet-paper widget
// credits: https://gist.github.com/marco79cgn
async function getImage(image, imgUrl) {
let fm = FileManager.local()
let dir = fm.documentsDirectory()
let path = fm.joinPath(dir, image)
if (fm.fileExists(path)) {
return fm.readImage(path)
//fm.remove(path)
} else {
// download once
let imageUrl
switch (image) {
case 'my-renault-car-' + VIN + '.png':
imageUrl = imgUrl
break
default:
//console.log(`Sorry, couldn't find ${image}.`);
}
if (imageUrl) {
let iconImage = await loadImage(imageUrl)
fm.writeImage(path, iconImage)
return iconImage
}
}
}
// helper function to download an image from a given url
async function loadImage(imgUrl) {
const req = new Request(imgUrl)
return await req.loadImage()
}
// end of script
@epenet
Copy link

epenet commented Feb 2, 2021

I'm still unable to track Kamereon requests (the Android application complains about the certificates) but luckily both Gigya and Firebase requests are decrypted correctly and @db-EV was correct regarding Firebase.
I cleared all settings from my application, and on first launch it made a POST request to https://firebaseremoteconfig.googleapis.com/v1/projects/942374850736/namespaces/firebase:fetch
The resulting JSON contained a different key to yours: wRhd1ZSqXfOuF8oTNCkS029YU9qPGWa4 which I assume is linked to the country code (FR for me). Both your key and my key seem to work OK.

I have created a pull request on hacf-fr/renault-api#184 (branch https://github.com/hacf-fr/renault-api/tree/firebase) if anyone wants to help...

@dehsgr
Copy link

dehsgr commented Feb 2, 2021

@epenet did you install & trust Fiddler root certificate on Android? This is pre-requisite.

@epenet
Copy link

epenet commented Feb 2, 2021

@dehsgr yes I did, and that's how I'm able to decrypt Gigya and Firebase data.
If I add api-wired-prod-1-euw1.wrd-aws.com to the skip decryption option, then the application work but I can't see the data.
If I remove api-wired-prod-1-euw1.wrd-aws.com from the skip decryption option, then the application fail with a big red screen "security alert"

Maybe it's because I'm stuck on version 4.5.0 of the application

@lix-src
Copy link

lix-src commented Feb 2, 2021

With the new API key, it worked exactly once. Now the data no longer updates (without an error message) even though I am on the road.

I am at a loss.

@db-EV
Copy link

db-EV commented Feb 2, 2021

Maybe just "normal" problems with the Renault servers. Running here with 2 different accounts without problems so far.

@plin2
Copy link

plin2 commented Feb 2, 2021

No problem with the new Ae9... key neither. Still interested in finding the original source for the API keys.

@mountbatt
Copy link
Author

@lix-src I am stuck on the same problem. It worked once but now the data does not change anymore - without any errors.

@mountbatt
Copy link
Author

@lix-src But the MyRenault App is not updating too! So I think we have to wait …

@dehsgr
Copy link

dehsgr commented Feb 2, 2021

@lix-srv & @mountbatt did you intercept app traffic and get the same key? Maybe you have to use another key. Or Renault is changing sth. in the backend again.

@lix-src
Copy link

lix-src commented Feb 2, 2021

@lix-src But the MyRenault App is not updating too! So I think we have to wait …

I think you're right. The app also works quite sluggishly.

@dehsgr: No, i can't intercept the traffic :/

@plin2
Copy link

plin2 commented Feb 2, 2021

My last update was at 17:55, so for me the Ae9... key is still working

@dehsgr
Copy link

dehsgr commented Feb 2, 2021

There seem to be some issues indeed. My Renault app hasn’t refreshed since 3pm. :-(

@g-mocken
Copy link

g-mocken commented Feb 3, 2021

After a clean install, I can see the Renault app retrieving the apikey Ae9FDWugRxZQAGm3Sxgk7uJn6Q4CGEA2 (among others) from https://firebaseremoteconfig.googleapis.com/v1/projects/renault-brand-ios/namespaces/firebase:fetch?key=...
but to do that, it uses yet another key, and I do not see where that one comes from. Any idea?

@mountbatt
Copy link
Author

my car is offline too ...

0D7FCF5D-A1D3-42E8-8EDC-F6806C50B9D4
1A3B67C5-7D61-487B-836D-DBEA1D13886E

@coolio2004
Copy link

new error on line 119:85 ReferenceError: cannot access uninitialized variable New key?

@dehsgr
Copy link

dehsgr commented Feb 4, 2021

@coolio2004 still working here...

@dehsgr
Copy link

dehsgr commented Feb 8, 2021

Renault changed sth. on their API again. Just confusing. :-(

@db-EV
Copy link

db-EV commented Feb 8, 2021

Do have problems getting data? For me is the API still working...

@coolio2004
Copy link

not for me in cologne

@dehsgr
Copy link

dehsgr commented Feb 8, 2021

On fetching user data I get code 403 now:
Invalid namespace 'accounts' or method 'getAccountInfo' or you do not have the required permissions to call it.

This is caused on line 340 in above code. This results in "345:34: Type Error: undefined is not an object (evaluating 'apiResult.data.personId".

But there seems to be another issue now

@mountbatt
Copy link
Author

mountbatt commented Feb 8, 2021

@dehsgr Same problem here … will have a look. Hope we will find it :) – Zeddy is killed too …

@db-EV
Copy link

db-EV commented Feb 8, 2021

It maybe is the "2. fetch user data from gigya" which causes the problem. My ZoePHP-Script goes straight from step 1 to step 3.

@dehsgr
Copy link

dehsgr commented Feb 8, 2021

@mountbatt I'm a bit further. We can summarize first Lines of getData() function a bit:

	// we are going now a long way through multiple servers to get access to our data
	// 1. fetch session and user data from gigya
	let gigyaCookieValue
	let gigyaPersonID
	let gigyaGigyaDataCenter
	if(Keychain.contains('gigyaCookieValue') && Keychain.get('gigyaCookieValue') != ""){
		gigyaCookieValue = Keychain.get('gigyaCookieValue')
	}
	console.log('gigyaCookieValue (from keychain): ' + gigyaCookieValue)
	if(Keychain.contains('gigyaPersonID') && Keychain.get('gigyaPersonID') != ""){
		gigyaPersonID = Keychain.get('gigyaPersonID')
	}
	console.log('gigyaPersonID (from keychain): ' + gigyaPersonID)
	if(Keychain.contains('gigyaGigyaDataCenter') && Keychain.get('gigyaGigyaDataCenter') != ""){
		gigyaGigyaDataCenter = Keychain.get('gigyaGigyaDataCenter')
	}
	console.log('gigyaGigyaDataCenter (from keychain): ' + gigyaGigyaDataCenter)
	if(gigyaCookieValue == "" || gigyaPersonID == "" || gigyaGigyaDataCenter == "" ||
	   typeof(gigyaCookieValue) == "undefined" || typeof(gigyaPersonID) == "undefined" || typeof(gigyaGigyaDataCenter) == "undefined")
	{
		let url = gigyaURL + '/accounts.login?loginID=' + encodeURIComponent(myRenaultUser) + '&password=' + encodeURIComponent(myRenaultPass) + '&include=data&apiKey=' + gigyaAPI
		let req = new Request(url)
		let apiResult = await req.loadString()
		apiResult = JSON.parse(apiResult)
		console.log("1.: " + apiResult.statusCode)
		if(apiResult.statusCode == "403"){
			let loginMessage = "Login nicht möglich. Zugangsdaten prüfen."
			throw new Error(loginMessage);
		} else {
			gigyaCookieValue = apiResult.sessionInfo.cookieValue
			gigyaPersonID = apiResult.data.personId
			gigyaGigyaDataCenter = apiResult.data.gigyaDataCenter
			Keychain.set('gigyaCookieValue', gigyaCookieValue)
			Keychain.set('gigyaPersonID', gigyaPersonID)
			Keychain.set('gigyaGigyaDataCenter', gigyaGigyaDataCenter)
			console.log('gigyaCookieValue (new generated): ' + gigyaCookieValue)
			console.log('gigyaPersonID (new generated): ' + gigyaPersonID)
			console.log('gigyaGigyaDataCenter (new generated): ' + gigyaGigyaDataCenter)
		}
	}

	// 2. fetch JWT data from gigya
	// renew gigyaJWTToken once a day
	if(Keychain.contains('lastJWTCall') == false){
		Keychain.set('lastJWTCall', 'never')
	} 
	let gigyaJWTToken
	
	if(Keychain.contains('gigyaJWTToken')){
		gigyaJWTToken = Keychain.get('gigyaJWTToken')
	}
	console.log('gigyaJWTToken (from keychain): ' + gigyaJWTToken)
	if(gigyaJWTToken == "" || typeof(gigyaJWTToken) == "undefined"){
		let expiration = 87000
		url = gigyaURL + '/accounts.getJWT?oauth_token=' + gigyaCookieValue + '&login_token=' + gigyaCookieValue + '&expiration=' + expiration + '&fields=data.personId,data.gigyaDataCenter&ApiKey=' + gigyaAPI
		req = new Request(url)
		apiResult = await req.loadString()
		apiResult = JSON.parse(apiResult)
		console.log("3.: " + apiResult.statusCode)
		gigyaJWTToken = apiResult.id_token
		Keychain.set('gigyaJWTToken', gigyaJWTToken)
		console.log('gigyaJWTToken (new generated): ' + gigyaJWTToken)
		
		const callDate = new Date().toJSON().slice(0,13).replace(/-/g,'').replace(/T/g,'-')
		Keychain.set('lastJWTCall', callDate)
		console.log('lastJWTCall (new generated): ' + callDate)
	}
	[...]				

This is what I was doing to get my personal API usage working from within node-red again. I saw that @db-EV at https://github.com/db-EV/ZoePHP/blob/main/src/index.php. But I can't get your code running completely with these changes. As I can see this shouldn't be an issue of using GET instead of POST requests here. But I'm missing sth. other?!

@db-EV
Copy link

db-EV commented Feb 8, 2021

Try this one on step 1:

		// 1. fetch session from gigya
		let gigyaCookieValue
		let gigyaPersonID
		if(Keychain.contains('gigyaCookieValue') && Keychain.get('gigyaCookieValue') != ""){
			gigyaCookieValue = Keychain.get('gigyaCookieValue')
		}
		console.log('gigyaCookieValue (from keychain): ' + gigyaCookieValue)
		if(gigyaCookieValue == "" || typeof(gigyaCookieValue) == "undefined"){
			let url = gigyaURL + '/accounts.login?loginID=' + encodeURIComponent(myRenaultUser) + '&password=' + encodeURIComponent(myRenaultPass) + '&apiKey=' + gigyaAPI
			let req = new Request(url)
			let apiResult = await req.loadString()
			apiResult = JSON.parse(apiResult)
			console.log("1.: " + apiResult.statusCode)
			if(apiResult.statusCode == "403"){
				let loginMessage = "Login nicht möglich. Zugangsdaten prüfen."
				throw new Error(loginMessage);
			} else {
				gigyaCookieValue = apiResult.sessionInfo.cookieValue
				gigyaPersonID = apiResult.data.personId
				Keychain.set('gigyaCookieValue', gigyaCookieValue)
				Keychain.set('gigyaPersonID', gigyaPersonID)
				console.log('gigyaCookieValue (new generated): ' + gigyaCookieValue)
				console.log('gigyaPersonID (new generated): ' + gigyaPersonID)
			}
		}

@dehsgr
Copy link

dehsgr commented Feb 8, 2021

@db-EV I think gigyaDataCenter is missing in your function. Or isn't it required anymore?

@db-EV
Copy link

db-EV commented Feb 8, 2021

I have no value there in my script. Maybe it's not exactly working the same in JS.

But I see another problem in your solution:
url = gigyaURL + '/accounts.getJWT?oauth_token=' + gigyaCookieValue + '&login_token=' + gigyaCookieValue + '&expiration=' + expiration + '&fields=data.personId,data.gigyaDataCenter&ApiKey=' + gigyaAPI
should be
url = gigyaURL + '/accounts.getJWT?login_token=' + gigyaCookieValue + '&expiration=' + expiration + '&fields=data.personId,data.gigyaDataCenter&ApiKey=' + gigyaAPI

@db-EV
Copy link

db-EV commented Feb 8, 2021

More exactly: you never need the gigyaDataCenter value, you just grab the gigyaPersonID to send it along with your request to the Renault Kamereon server to get the AccountID.

@dehsgr
Copy link

dehsgr commented Feb 8, 2021

I just edited my fork. This isn't completely working for now, but we can fetch all required data.

Line 121 is producing some error:
plugStateLabel.url = 'scriptable:///run?scriptName=${scriptName}&action=start_charge';

@dehsgr
Copy link

dehsgr commented Feb 8, 2021

@db-EV you're right. Thanks for that hint! I'll change that in my code too.

@db-EV
Copy link

db-EV commented Feb 8, 2021

The ' sign seems to be wrong in the line 121. It's looks more like a french '. I hope you understand what I mean...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment