I’ve been working on a Widget that consumes the TeslaFi API to give you battery state, locked/unlocked, and car status.
I’ve noticed that the other products that do this are either expensive (they have a lot of additional features), or they ping the car a lot preventing it from sleeping.
NOTE: You need a TeslaFi subscription to get an API Key.
I’m going to keep working on this, but I thought it might be of valuable to others:
// Variables used by Scriptable.
// These must be at the very top of the file. Do not edit.
// icon-color: red; icon-glyph: charging-station;
// TeslaFi Widget
// Version 0.5
// Jon Sweet (contact.driestone@surfspamfree.com)
//
// This pulls data from the TeslaFi API to display a widget on your iPhone
// This is better than other methods because TeslaFi tries to encourage your car to sleep to reduce phantom battery drain. Using this script will not directly connect to the car to respect the sleep status.
// Notice that there is ~5 minute lag for data
let APIkey = args.widgetParameter
const show_battery_percentage = true // show the battery percentage above the battery bar
const show_range = true // show the estimated range above the battery bar
const show_range_est = true // show range estimated by TeslaFi instead of the car's range estimate
const battery_display_3D = false // show a 3D version of the battery bar
// You can imbed your TeslaFi APIkey here, or add it as a widget parameter
//APIkey = "API KEY" // hardcode the API Key
const debugMode = false
deviceScreen = Device.screenSize()
let padding = ((deviceScreen.width - 240) /5)
let widgetSize = new Size(padding + 110, padding + 110)
if (APIkey == null){
let widget = errorWidget("TeslaFi APIkey Required")
Script.setWidget(widget)
widget.presentSmall()
Script.complete()
} else {
let items = await loadItems()
if (items.response == null){
if (config.runsInWidget || true) {
let widget = createWidget(items)
Script.setWidget(widget)
widget.presentSmall()
Script.complete()
} else {
let item = items[0]
Safari.open(item.url)
}
} else {
if (items.response.result == "unauthorized"){
let widget = errorWidget("Invalid TeslaFi APIkey")
Script.setWidget(widget)
widget.presentSmall()
Script.complete()
} else {
logError(items.response.result)
}
// probably too many requests
}
}
function createWidget(items) {
let w = new ListWidget()
w.setPadding(5,5,5,5)
let myGradient = new LinearGradient()
textColor = new Color("#333333cc")
inactiveColor = new Color("#33333366")
inverseColor = new Color("#cccccccc")
topBar = new Color("#44444422")
if (Device.isUsingDarkAppearance()){
// darkmode
w.backgroundColor = new Color("#333")
myGradient.colors = [new Color("#bbbbbb11"), new Color("#ffffff00")]
myGradient.locations = [0,0.3]
textColor = new Color("#cccccccc")
inactiveColor = new Color("#cccccc99")
} else {
// lightmode
w.backgroundColor = new Color("#ccc")
myGradient.colors = [new Color("#44444411"), new Color("#ffffff00")]
myGradient.locations = [0,0.3]
}
w.backgroundGradient = myGradient
// BUILD BATTERY BAR
const batteryPath = new Path()
batteryPath.addRoundedRect(new Rect(1,1,widgetSize.width-2,18),7,7)
const batteryPathInset = new Path()
batteryPathInset.addRoundedRect(new Rect(2,2,widgetSize.width-3,17),7,7)
batteryAmount = Number(items.usable_battery_level)*(widgetSize.width-2)/100
maxChargeAmount = Number(items.charge_limit_soc)*(widgetSize.width-2)/100
let myDrawContext = new DrawContext()
myDrawContext.opaque = false
myDrawContext.size = new Size(widgetSize.width,20)
myDrawContext.addPath(batteryPath)
myDrawContext.setFillColor(new Color("#33333355"))
myDrawContext.fillPath()
// charge_limit_soc
let batteryMaxCharge = new DrawContext()
batteryMaxCharge.opaque = false
batteryMaxCharge.size = new Size(maxChargeAmount,20)
batteryMaxCharge.setFillColor(new Color("#00000033"))
if (Device.isUsingDarkAppearance()){
batteryMaxCharge.setFillColor(new Color("#ffffff33"))
}
if (items.carState == "Charging"){
batteryMaxCharge.setFillColor(new Color("#4455ffcc")) // show it in blue to reitterate charging
}
batteryMaxCharge.addPath(batteryPath)
batteryMaxCharge.fillPath()
batteryMaxChargeImage = batteryMaxCharge.getImage()
myDrawContext.drawImageAtPoint(batteryMaxChargeImage,new Point(0,0))
if (batteryAmount>1){
let batteryFull = new DrawContext()
batteryFull.opaque = false
batteryFull.size = new Size(batteryAmount,20)
batteryFull.setFillColor(new Color("#2BD82E"))
batteryFull.addPath(batteryPath)
batteryFull.fillPath()
let highlightWidth = batteryAmount-10;
if (highlightWidth>4 && battery_display_3D){
const batteryHighlight = new Path()
batteryHighlight.addRoundedRect(new Rect(5,5,batteryAmount-6,3),1,1)
batteryFull.setFillColor(new Color("#ffffff",0.5))
batteryFull.addPath(batteryHighlight)
batteryFull.fillPath()
}
myFullBatteryImage = batteryFull.getImage()
myDrawContext.drawImageAtPoint(myFullBatteryImage,new Point(0,0))
myDrawContext.setFillColor(textColor)
myDrawContext.fillRect(new Rect(batteryAmount,0,1,18))
}
myDrawContext.addPath(batteryPath)// have to add the path again for some reason
myDrawContext.setStrokeColor(textColor)
myDrawContext.setLineWidth(1)
myDrawContext.strokePath()
if (battery_display_3D){
myDrawContext.addPath(batteryPathInset)
myDrawContext.setStrokeColor(new Color("#00000022"))
myDrawContext.setLineWidth(4)
myDrawContext.strokePath()
myDrawContext.setStrokeColor(new Color("#00000044"))
myDrawContext.setLineWidth(2)
myDrawContext.strokePath()
}
batteryBarData = myDrawContext.getImage()
let wBody = w.addStack()
wBody.layoutVertically()
let wState = w.addStack()
wState.size = new Size(widgetSize.width,widgetSize.height*0.20)
wState.topAlignContent()
wState.setPadding(0,6,0,6)
let wContent = w.addStack()
wContent.size = new Size(widgetSize.width,widgetSize.height*0.25)
wContent.centerAlignContent()
wContent.setPadding(0,3,5,3)
let wControls = w.addStack()
wControls.size = new Size(widgetSize.width,widgetSize.height*0.20)
wControls.bottomAlignContent()
wControls.backgroundColor = new Color("#ffffff33")
wControls.cornerRadius = 3
wControls.centerAlignContent()
wControls.setPadding(3,10,3,10)
let wRangeValue = w.addStack()
wRangeValue.size = new Size(widgetSize.width,widgetSize.height*0.15)
wRangeValue.centerAlignContent()
wRangeValue.setPadding(5,10,0,10)
let wBattery = w.addStack()
wBattery.size = new Size(widgetSize.width,widgetSize.height*0.20)
wBattery.topAlignContent()
wBattery.setPadding(3,0,0,0)
if (debugMode){
wState.borderWidth = 1
wContent.borderWidth = 1
wRangeValue.borderWidth = 1
wBattery.borderWidth = 1
}
//w.centerAlignContent()
// Car State (Sleeping, Idling, ?Driving?, ?Sentry?)
// moon.zzz.fill = sleep
// house.fill = idle
// sun.min.fill = sentry
// car.fill = driving
let sym = SFSymbol.named("questionmark.circle.fill")
let symColor = inactiveColor
switch(items.carState){
case "Sleeping":
sym = SFSymbol.named("moon.zzz.fill")
break;
case "Idling":
sym = SFSymbol.named("p.square.fill")
break;
case "Driving":
sym = SFSymbol.named("car.fill")
break;
case "Charging":
sym = SFSymbol.named("bolt.car.fill")
break;
default:
logError(items.carState)
}
if (items.sentry_mode == 1){
sym = SFSymbol.named("sun.min.fill")
symColor = Color.red()
}
let carStateSpacer = wState.addSpacer(null)
let carState = wState.addImage(sym.image)
carState.imageSize = scaleImage(sym.image.size,20)
carState.tintColor = symColor
carState.imageOpacity = 0.8
carState.rightAlignImage()
let carName = wContent.addText(items.display_name)
carName.textColor = textColor
carName.centerAlignText()
carName.font = Font.semiboldSystemFont(24)
carName.minimumScaleFactor = 0.5
// carName.shadowColor = new Color("#00000055")
// carName.shadowRadius = 5
// carName.shadowOffset = new Point (1,1)
if (items.locked == 1){
sym = SFSymbol.named("lock.fill")
let carControlIconLock = wControls.addImage(sym.image)
//logError(sym.image.size)
carControlIconLock.imageSize = scaleImage(sym.image.size,12)
carControlIconLock.tintColor = textColor
carControlIconLock.imageOpacity = 0.8
} else {
/*sym = SFSymbol.named("lock.open.fill")
let carControlIconLock = wControls.addImage(sym.image)
carControlIconLock.imageSize = scaleImage(sym.image.size,12)
carControlIconLock.tintColor = textColor
carControlIconLock.imageOpacity = 0.8*/
}
let carControlSpacer = wControls.addSpacer(null)
/*sym = SFSymbol.named("thermometer")
let carControlIconTemp = wControls.addImage(sym.image)
carControlIconTemp.imageSize = new Size(20,30)
carControlIconTemp.tintColor = symColor
carControlIconTemp.imageOpacity = 0.8*/
if (items.temperature == "F"){
let carTemp = wControls.addText(items.inside_tempF+"°")
carTemp.textColor = textColor
carTemp.font = Font.systemFont(15)
carTemp.textOpacity = 0.6
} else {
let carTemp = wControls.addText(items.inside_temp+"°")
carTemp.textColor = textColor
carTemp.font = Font.systemFont(15)
carTemp.textOpacity = 0.6
}
// inside_tempF
// items.locked
// items.time_to_full_charge
let batteryCurrentCharge = ""
if (show_battery_percentage){
batteryCurrentCharge = items.usable_battery_level + "%"
let batteryCurrentChargePercentTxt = wRangeValue.addText(batteryCurrentCharge)
batteryCurrentChargePercentTxt.textColor = textColor
batteryCurrentChargePercentTxt.textOpacity = 0.6
batteryCurrentChargePercentTxt.font = Font.systemFont(13)
batteryCurrentChargePercentTxt.centerAlignText()
}
if (show_range){
if (show_battery_percentage){
let carChargingSpacer1 = wRangeValue.addSpacer(null)
}
if (show_range_est) {
batteryCurrentCharge = Math.floor(items.est_battery_range)+" mi"
} else {
batteryCurrentCharge = Math.floor(items.battery_range)+" mi"
}
let batteryCurrentRangeTxt = wRangeValue.addText(batteryCurrentCharge)
batteryCurrentRangeTxt.textColor = textColor
batteryCurrentRangeTxt.textOpacity = 0.6
batteryCurrentRangeTxt.font = Font.systemFont(13)
batteryCurrentRangeTxt.centerAlignText()
}
if (items.carState == "Charging"){
if (show_battery_percentage || show_range){
let carChargingSpacer2 = wRangeValue.addSpacer(null)
}
// currently charging
minutes = Math.round((items.time_to_full_charge - Math.floor(items.time_to_full_charge)) * 12) * 5
if (minutes < 10) {minutes = "0" + minutes}
sym = SFSymbol.named("bolt.circle.fill")
let carControlIconBolt = wRangeValue.addImage(sym.image)
carControlIconBolt.imageSize = scaleImage(sym.image.size,12)
carControlIconBolt.tintColor = textColor
carControlIconBolt.imageOpacity = 0.8
let carChargeCompleteTime = wRangeValue.addText(" "+Math.floor(items.time_to_full_charge)+":"+minutes)
carChargeCompleteTime.textColor = textColor
carChargeCompleteTime.font = Font.systemFont(13)
carChargeCompleteTime.textOpacity = 0.6
wRangeValue.setPadding(5,5,0,5)
}
/*let rangeTxt = w.addText(items.maxRange)
rangeTxt.textColor = Color.white()*/
let batteryBarImg = wBattery.addImage(batteryBarData)
batteryBarImg.imageSize = new Size(130,20)
batteryBarImg.centerAlignImage()
return w
}
function errorWidget(reason){
let w = new ListWidget()
w.setPadding(5,5,5,5)
let myGradient = new LinearGradient()
w.backgroundColor = new Color("#933")
myGradient.colors = [new Color("#44444466"), new Color("#88888855"), new Color("#66666655")]
myGradient.locations = [0,0.8,1]
w.backgroundGradient = myGradient
let title = w.addText("Error")
title.textColor = Color.white()
title.font = Font.semiboldSystemFont(30)
title.minimumScaleFactor = 0.5
let reasonText = w.addText(reason)
reasonText.textColor = Color.white()
reasonText.minimumScaleFactor = 0.5
return w
}
async function loadItems() {
let url = "https://www.teslafi.com/feed.php?token="+APIkey+"&command=lastGood"
let req = new Request(url)
let json = await req.loadJSON()
return json
}
function scaleImage(imageSize,height){
scale = height/imageSize.height
return new Size(scale*imageSize.width,height)
}