Last active
January 18, 2024 09:53
-
-
Save niklasvieth/9cb306b53835a9a283e34b77f0f2513f to your computer and use it in GitHub Desktop.
An iOS lockscreen widget to display the current state of charge (SoC) of your Polestar 2. See https://github.com/niklasvieth/polestar-ios-lockscreen-widget for details.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// icon-color: green; icon-glyph: battery-half; | |
/** | |
* This widget has been developed by Niklas Vieth. | |
* Installation and configuration details can be found at https://github.com/niklasvieth/polestar-ios-lockscreen-widget | |
*/ | |
// Config | |
const POLESTAR_EMAIL = "EMAIL_ADDRESS"; | |
const POLESTAR_PASSWORD = "PASSWORD"; | |
const VIN = "VIN"; | |
const POLESTAR_BASE_URL = "https://pc-api.polestar.com/eu-north-1"; | |
const POLESTAR_API_URL = `${POLESTAR_BASE_URL}/mystar-v2`; // v1 API: "https://pc-api.polestar.com/eu-north-1/my-star" | |
const POLESTAR_ICON = "https://www.polestar.com/w3-assets/coast-228x228.png"; | |
// Check that params are set | |
if (POLESTAR_EMAIL === "EMAIL_ADDRESS") { | |
throw new Error("Parameter POLESTAR_EMAIL is not configured"); | |
} | |
if (POLESTAR_PASSWORD === "PASSWORD") { | |
throw new Error("Parameter POLESTAR_PASSWORD is not configured"); | |
} | |
if (VIN === "VIN") { | |
throw new Error("Parameter VIN is not configured"); | |
} | |
// Create Widget | |
const accessToken = await getAccessToken(); | |
const batteryData = await getBattery(accessToken); | |
const batteryPercent = parseInt(batteryData.batteryChargeLevelPercentage); | |
const isCharging = batteryData.chargingStatus === "CHARGING_STATUS_CHARGING"; | |
const isChargingDone = batteryData.chargingStatus === "CHARGING_STATUS_DONE"; | |
const isConnected = | |
batteryData.chargerConnectionStatus === "CHARGER_CONNECTION_STATUS_CONNECTED"; | |
const widget = new ListWidget(); | |
widget.url = "polestar-explore://"; | |
const progressStack = await drawArc(widget, batteryPercent, isCharging); | |
const batteryInfoStack = progressStack.addStack(); | |
batteryInfoStack.layoutVertically(); | |
// Polestar Icon | |
const imageStack = batteryInfoStack.addStack(); | |
imageStack.addSpacer(); | |
if (isCharging || isChargingDone) { | |
const chargingIcon = isCharging | |
? SFSymbol.named("bolt.fill") | |
: SFSymbol.named("checkmark.circle"); | |
const chargingSymbolElement = imageStack.addImage(chargingIcon.image); | |
chargingSymbolElement.tintColor = Color.green(); | |
chargingSymbolElement.imageSize = new Size(15, 15); | |
} else if (isConnected) { | |
const chargingIcon = SFSymbol.named("bolt.slash.fill"); | |
const chargingSymbolElement = imageStack.addImage(chargingIcon.image); | |
chargingSymbolElement.tintColor = Color.red(); | |
chargingSymbolElement.imageSize = new Size(15, 15); | |
} else { | |
const appIcon = await loadImage(POLESTAR_ICON); | |
const icon = imageStack.addImage(appIcon); | |
icon.imageSize = new Size(13, 13); | |
icon.cornerRadius = 4; | |
} | |
imageStack.addSpacer(); | |
// Percent Text | |
batteryInfoStack.addSpacer(2); | |
const textStack = batteryInfoStack.addStack(); | |
textStack.centerAlignContent(); | |
textStack.addSpacer(); | |
textStack.addText(`${batteryPercent}%`); | |
textStack.addSpacer(); | |
widget.presentAccessoryCircular(); | |
Script.setWidget(widget); | |
Script.complete(); | |
/********************** | |
* Polestar API helpers | |
**********************/ | |
async function getAccessToken() { | |
const { pathToken, cookie } = await getLoginFlowTokens(); | |
const tokenRequestCode = await performLogin(pathToken, cookie); | |
const apiCreds = await getApiToken(tokenRequestCode); | |
return apiCreds.access_token; | |
} | |
async function performLogin(pathToken, cookie) { | |
const req = new Request( | |
`https://polestarid.eu.polestar.com/as/${pathToken}/resume/as/authorization.ping` | |
); | |
req.method = "post"; | |
req.body = getUrlEncodedParams({ | |
"pf.username": POLESTAR_EMAIL, | |
"pf.pass": POLESTAR_PASSWORD, | |
}); | |
req.headers = { | |
"Content-Type": "application/x-www-form-urlencoded", | |
Cookie: cookie, | |
}; | |
req.onRedirect = (redReq) => { | |
return null; | |
}; | |
await req.load(); | |
const redirectUrl = req.response.headers.Location; | |
const regex = /code=([^&]+)/; | |
const match = redirectUrl.match(regex); | |
const tokenRequestCode = match ? match[1] : null; | |
return tokenRequestCode; | |
} | |
async function getLoginFlowTokens() { | |
const req = new Request( | |
"https://polestarid.eu.polestar.com/as/authorization.oauth2?response_type=code&client_id=polmystar&redirect_uri=https://www.polestar.com%2Fsign-in-callback&scope=openid+profile+email+customer%3Aattributes" | |
); | |
req.headers = { Cookie: "" }; | |
let redirectUrl; | |
req.onRedirect = (redReq) => { | |
redirectUrl = redReq.url; | |
return null; | |
}; | |
await req.loadString(); | |
const regex = /resumePath=(\w+)/; | |
const match = redirectUrl.match(regex); | |
const pathToken = match ? match[1] : null; | |
const cookies = req.response.headers["Set-Cookie"]; | |
const cookie = cookies.split("; ")[0] + ";"; | |
return { | |
pathToken: pathToken, | |
cookie: cookie, | |
}; | |
} | |
async function getApiToken(tokenRequestCode) { | |
const req = new Request(`${POLESTAR_BASE_URL}/auth`); | |
req.method = "POST"; | |
req.headers = { | |
"Content-Type": "application/json", | |
}; | |
req.body = JSON.stringify({ | |
query: | |
"query getAuthToken($code: String!){getAuthToken(code: $code){id_token,access_token,refresh_token,expires_in}}", | |
operationName: "getAuthToken", | |
variables: { code: tokenRequestCode }, | |
}); | |
req.onRedirect = (redReq) => { | |
return null; | |
}; | |
const response = await req.loadJSON(); | |
const apiCreds = response.data.getAuthToken; | |
return { | |
access_token: apiCreds.access_token, | |
refresh_token: apiCreds.refresh_token, | |
expires_in: apiCreds.expires_in, | |
}; | |
} | |
async function getBattery(accessToken) { | |
if (!accessToken) { | |
throw new Error("Not authenticated"); | |
} | |
const searchParams = { | |
query: | |
"query GetBatteryData($vin:String!){getBatteryData(vin:$vin){averageEnergyConsumptionKwhPer100Km,batteryChargeLevelPercentage,chargerConnectionStatus,chargingCurrentAmps,chargingPowerWatts,chargingStatus,estimatedChargingTimeMinutesToTargetDistance,estimatedChargingTimeToFullMinutes,estimatedDistanceToEmptyKm,estimatedDistanceToEmptyMiles,eventUpdatedTimestamp{iso,unix}}}", | |
variables: { | |
vin: VIN, | |
}, | |
}; | |
const req = new Request(POLESTAR_API_URL); | |
req.method = "POST"; | |
req.headers = { | |
"Content-Type": "application/json", | |
Authorization: "Bearer " + accessToken, | |
}; | |
req.body = JSON.stringify(searchParams); | |
const response = await req.loadJSON(); | |
if (!response?.data?.getBatteryData) { | |
throw new Error("No battery data fetched"); | |
} | |
const data = response.data.getBatteryData; | |
return data; | |
} | |
async function loadImage(url) { | |
const req = new Request(url); | |
return req.loadImage(); | |
} | |
function getUrlEncodedParams(object) { | |
return Object.keys(object) | |
.map((key) => `${key}=${encodeURIComponent(object[key])}`) | |
.join("&"); | |
} | |
/***************************** | |
* Draw battery percent circle | |
* Forked and adapted from https://gist.githubusercontent.com/Sillium/4210779bc2d759b494fa60ba4f464bd8/raw/9e172bac0513cc3cf0e70f3399e49d10f5d0589c/ProgressCircleService.js | |
*****************************/ | |
async function drawArc(on, percent) { | |
const canvSize = 200; | |
const canvas = new DrawContext(); | |
canvas.opaque = false; | |
const canvWidth = 18; // circle thickness | |
const canvRadius = 80; // circle radius | |
canvas.size = new Size(canvSize, canvSize); | |
canvas.respectScreenScale = true; | |
const deg = Math.floor(percent * 3.6); | |
let ctr = new Point(canvSize / 2, canvSize / 2); | |
const bgx = ctr.x - canvRadius; | |
const bgy = ctr.y - canvRadius; | |
const bgd = 2 * canvRadius; | |
const bgr = new Rect(bgx, bgy, bgd, bgd); | |
canvas.opaque = false; | |
canvas.setFillColor(Color.white()); | |
canvas.setStrokeColor(new Color("#333333")); | |
canvas.setLineWidth(canvWidth); | |
canvas.strokeEllipse(bgr); | |
for (let t = 0; t < deg; t++) { | |
const rect_x = ctr.x + canvRadius * sinDeg(t) - canvWidth / 2; | |
const rect_y = ctr.y - canvRadius * cosDeg(t) - canvWidth / 2; | |
const rect_r = new Rect(rect_x, rect_y, canvWidth, canvWidth); | |
canvas.fillEllipse(rect_r); | |
} | |
let stack = on.addStack(); | |
stack.size = new Size(65, 65); | |
stack.backgroundImage = canvas.getImage(); | |
let padding = 0; | |
stack.setPadding(padding, padding, padding, padding); | |
stack.centerAlignContent(); | |
return stack; | |
} | |
function sinDeg(deg) { | |
return Math.sin((deg * Math.PI) / 180); | |
} | |
function cosDeg(deg) { | |
return Math.cos((deg * Math.PI) / 180); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment