Skip to content

Instantly share code, notes, and snippets.

@niklasvieth
Last active January 18, 2024 09:53
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save niklasvieth/9cb306b53835a9a283e34b77f0f2513f to your computer and use it in GitHub Desktop.
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.
// 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