TeslaFi Widget for battery & car state

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)
}
2 Likes

@simonbs, I’ve mentioned this elsewhere, but it appears as though SFSymbols don’t maintain the original aspect ratio. Notice in my screenshot that the p.square.filled and especially lock.fill are stretched wide. I’ve played around with imageSize to try to correct, but I can’t seem to change the aspect ratio with imageSize

This is outdated, the most recent version of this is in my github https://github.com/DrieStone/TeslaFi-Widget

1 Like