Skip to content

Instantly share code, notes, and snippets.

@niklasvieth
Last active December 10, 2024 17:55
Show Gist options
  • Save niklasvieth/159c13dd7ef94bd608358ce964b66c7c to your computer and use it in GitHub Desktop.
Save niklasvieth/159c13dd7ef94bd608358ce964b66c7c to your computer and use it in GitHub Desktop.
An iOS medium widget to display the current state of charge (SoC) of your Polestar 2. See https://github.com/niklasvieth/polestar-ios-medium-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-medium-widget
*/
// Mandatory Config
const POLESTAR_EMAIL = "EMAIL";
const POLESTAR_PASSWORD = "PASSWORD";
// Optional (only necessary if more than one car linked to account)
let VIN;
// let VIN = "VIN";
let VEHICLE_NAME;
// let VEHICLE_NAME = "Polestar Custom Name";
// Additional optional configuration
const IMAGE_ANGLE = "0"; // Possible values 0,1,2,3,4,5
const RANGE_IN_MILES = false; // true
const LAST_SEEN_RELATIVE_DATE = false; // true
const MIN_SOC_GREEN = 60;
const MIN_SOC_ORANGE = 30;
const DARK_MODE = Device.isUsingDarkAppearance(); // or set manually to (true or false)
const DARK_BG_COLOR = "000000";
const LIGHT_BG_COLOR = "FFFFFF";
// API config
const POLESTAR_BASE_URL = "https://pc-api.polestar.com/eu-north-1";
const POLESTAR_API_URL_V2 = `${POLESTAR_BASE_URL}/mystar-v2`;
const POLESTAR_API_URL = `${POLESTAR_BASE_URL}/my-star`;
const POLESTAR_REDIRECT_URI = "https://www.polestar.com/sign-in-callback";
const POLESTAR_ICON = "https://www.polestar.com/w3-assets/coast-228x228.png";
const CLIENT_ID = "l3oopkc_10";
const CODE_VERIFIER = "polestar-ios-widgets-are-enabled-by-scriptable";
const CODE_CHALLENGE = "adYJTSAVqq6CWBJn7yNdGKwcsmJb8eBewG8WpxnUzaE";
// Check that params are set
if (POLESTAR_EMAIL === "EMAIL") {
throw new Error("POLESTAR_EMAIL is not configured");
}
if (POLESTAR_PASSWORD === "PASSWORD") {
throw new Error("POLESTAR_PASSWORD is not configured");
}
if (VIN === "VIN") {
throw new Error("VIN is not configured");
}
// Create Widget
const accessToken = await getAccessToken();
const vehicleData = await getVehicles(accessToken);
const [batteryData, odometerData] = await Promise.all([
getBattery(accessToken),
getOdometerData(accessToken),
]);
// You can run the script in the app to preview the widget or you can go to the Home Screen, add a new Scriptable widget and configure the widget to run this script.
// You can also try creating a shortcut that runs this script. Running the shortcut will show widget.
const widget = await createPolestarWidget(
batteryData,
odometerData,
vehicleData
);
if (config.runsInWidget) {
// The script runs inside a widget, so we pass our instance of ListWidget to be shown inside the widget on the Home Screen.
Script.setWidget(widget);
} else {
// The script runs inside the app, so we preview the widget.
widget.presentMedium();
}
// Calling Script.complete() signals to Scriptable that the script have finished running.
// This can speed up the execution, in particular when running the script from Shortcuts or using Siri.
Script.complete();
// Create polestar widget
async function createPolestarWidget(batteryData, odometerData, vehicle) {
const batteryPercent = parseInt(batteryData.batteryChargeLevelPercentage);
const isCharging = batteryData.chargingStatus === "CHARGING_STATUS_CHARGING";
const remainingChargingTime = batteryData.estimatedChargingTimeToFullMinutes;
const rangeKm = batteryData.estimatedDistanceToEmptyKm;
const rangeMiles = batteryData.estimatedDistanceToEmptyMiles;
const isChargingDone = batteryData.chargingStatus === "CHARGING_STATUS_DONE";
const isConnected =
batteryData.chargerConnectionStatus ===
"CHARGER_CONNECTION_STATUS_CONNECTED";
const chargingAmps = batteryData.chargingCurrentAmps ?? 0;
const chargingWatts = batteryData.chargingPowerWatts ?? 0;
const chargingKw = parseInt(chargingWatts / 1000);
// Prepare image
if (!vehicle.content.images.studio.angles.includes(IMAGE_ANGLE)) {
throw new Error(
`IMG_ANGLE ${IMAGE_ANGLE} is not in ${vehicle.content.images.studio.angles}`
);
}
const imgUrl = `${
vehicle.content.images.studio.url
}&angle=${IMAGE_ANGLE}&bg=${
DARK_MODE ? DARK_BG_COLOR : LIGHT_BG_COLOR
}&width=600`;
const appIcon = await loadImage(POLESTAR_ICON);
const title = VEHICLE_NAME ?? vehicle.content.model.name;
const widget = new ListWidget();
widget.url = "polestar-explore://";
const mainStack = widget.addStack();
mainStack.layoutVertically();
// Add background color
widget.backgroundColor = DARK_MODE
? new Color(DARK_BG_COLOR)
: new Color(LIGHT_BG_COLOR);
// Show app icon and title
mainStack.addSpacer();
const titleStack = mainStack.addStack();
const titleElement = titleStack.addText(title);
titleElement.textColor = DARK_MODE ? Color.white() : Color.black();
titleElement.textOpacity = 0.7;
titleElement.font = Font.mediumSystemFont(18);
titleStack.addSpacer();
const appIconElement = titleStack.addImage(appIcon);
appIconElement.imageSize = new Size(30, 30);
appIconElement.cornerRadius = 4;
mainStack.addSpacer();
// Center Stack
const contentStack = mainStack.addStack();
const carImage = await loadImage(imgUrl);
const carImageElement = contentStack.addImage(carImage);
carImageElement.imageSize = new Size(150, 90);
contentStack.addSpacer();
// Battery Info
const batteryInfoStack = contentStack.addStack();
batteryInfoStack.layoutVertically();
batteryInfoStack.addSpacer();
// Range
const rangeStack = batteryInfoStack.addStack();
rangeStack.addSpacer();
const rangeText = RANGE_IN_MILES ? `${rangeMiles} mi` : `${rangeKm} km`;
const rangeElement = rangeStack.addText(rangeText);
rangeElement.font = Font.mediumSystemFont(20);
rangeElement.textColor = DARK_MODE ? Color.white() : Color.black();
rangeElement.rightAlignText();
batteryInfoStack.addSpacer();
// Battery Percent Value
const batteryPercentStack = batteryInfoStack.addStack();
batteryPercentStack.addSpacer();
batteryPercentStack.centerAlignContent();
const { batteryIcon, batteryIconColor } = getBatteryIcon(
batteryPercent,
isConnected,
isCharging,
isChargingDone
);
const batterySymbolElement = batteryPercentStack.addImage(batteryIcon.image);
batterySymbolElement.imageSize = new Size(25, 25);
batterySymbolElement.tintColor = batteryIconColor;
batteryPercentStack.addSpacer(8);
const batteryPercentText = batteryPercentStack.addText(`${batteryPercent} %`);
batteryPercentText.textColor = getBatteryPercentColor(batteryPercent);
batteryPercentText.font = Font.boldSystemFont(20);
if (isCharging) {
const batteryChargingTimeStack = batteryInfoStack.addStack();
batteryChargingTimeStack.addSpacer();
const remainingChargeTimeHours = parseInt(remainingChargingTime / 60);
const remainingChargeTimeMinsRemainder = remainingChargingTime % 60;
const chargingTimeElement = batteryChargingTimeStack.addText(
`${chargingKw} kW - ${remainingChargeTimeHours}h ${remainingChargeTimeMinsRemainder}m`
);
chargingTimeElement.font = Font.mediumSystemFont(14);
chargingTimeElement.textOpacity = 0.9;
chargingTimeElement.textColor = DARK_MODE ? Color.white() : Color.black();
chargingTimeElement.rightAlignText();
}
batteryInfoStack.addSpacer();
// Footer
const footerStack = mainStack.addStack();
// Add odometer
const odometerText = RANGE_IN_MILES
? `${parseInt(odometerData.odometerMeters / 1609.344).toLocaleString()} mi`
: `${parseInt(odometerData.odometerMeters / 1000).toLocaleString()} km`;
const odometerElement = footerStack.addText(odometerText);
odometerElement.font = Font.mediumSystemFont(10);
odometerElement.textColor = DARK_MODE ? Color.white() : Color.black();
odometerElement.textOpacity = 0.5;
odometerElement.minimumScaleFactor = 0.5;
odometerElement.leftAlignText();
footerStack.addSpacer();
// Add last seen indicator
const lastSeenDate = new Date(batteryData.eventUpdatedTimestamp.iso);
const lastSeenText = lastSeenDate.toLocaleString();
let lastSeenElement;
if (LAST_SEEN_RELATIVE_DATE) {
lastSeenElement = footerStack.addDate(lastSeenDate);
lastSeenElement.applyRelativeStyle();
} else {
lastSeenElement = footerStack.addText(lastSeenText);
}
lastSeenElement.font = Font.mediumSystemFont(10);
lastSeenElement.textOpacity = 0.5;
lastSeenElement.textColor = DARK_MODE ? Color.white() : Color.black();
lastSeenElement.minimumScaleFactor = 0.5;
lastSeenElement.rightAlignText();
mainStack.addSpacer();
return widget;
}
/**********************
* 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?client_id=${CLIENT_ID}`
);
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 codeRegex = /code=([^&]+)/;
let codeMatch = redirectUrl.match(codeRegex);
const uidRegex = /uid=([^&]+)/;
const uidMatch = redirectUrl.match(uidRegex);
if (!codeMatch || codeMatch.length === 0) {
console.warn("No code found");
if (uidMatch && uidMatch.length > 0) {
const uid = uidMatch[1];
const reqConfirm = new Request(
`https://polestarid.eu.polestar.com/as/${pathToken}/resume/as/authorization.ping?client_id=${CLIENT_ID}`
);
reqConfirm.method = "post";
reqConfirm.body = getUrlEncodedParams({
"pf.submit": true,
subject: uid,
});
reqConfirm.headers = {
"Content-Type": "application/x-www-form-urlencoded",
Cookie: cookie,
};
reqConfirm.onRedirect = (redReq) => {
return null;
};
await reqConfirm.load();
const redirectUrl2 = reqConfirm.response.headers.Location;
const codeRegex = /code=([^&]+)/;
codeMatch = redirectUrl2.match(codeRegex);
if (!codeMatch || codeMatch.length === 0) {
throw new Error("No token found after confirmation");
}
} else {
throw new Error("Not authenticated, please check login email & password");
}
}
const tokenRequestCode = codeMatch[1];
return tokenRequestCode;
}
async function getLoginFlowTokens() {
const params = getUrlEncodedParams({
response_type: "code",
client_id: CLIENT_ID,
redirect_uri: POLESTAR_REDIRECT_URI,
scope: "openid profile email customer:attributes",
state: "ea5aa2860f894a9287a4819dd5ada85c",
code_challenge: CODE_CHALLENGE,
code_challenge_method: "S256",
});
const req = new Request(
`https://polestarid.eu.polestar.com/as/authorization.oauth2?${params}`
);
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(`https://polestarid.eu.polestar.com/as/token.oauth2`);
req.method = "POST";
req.headers = {
"Content-Type": "application/x-www-form-urlencoded",
};
req.body = getUrlEncodedParams({
grant_type: "authorization_code",
code: tokenRequestCode,
code_verifier: CODE_VERIFIER,
client_id: CLIENT_ID,
redirect_uri: POLESTAR_REDIRECT_URI,
});
req.onRedirect = (redReq) => {
return null;
};
const apiCreds = await req.loadJSON();
return {
access_token: apiCreds.access_token,
refresh_token: apiCreds.refresh_token,
expires_in: apiCreds.expires_in,
};
}
async function getVehicles(accessToken) {
if (!accessToken) {
throw new Error("Not authenticated");
}
const searchParams = {
query:
"query getCars{getConsumerCarsV2{vin,internalVehicleIdentifier,modelYear,content{model{code,name},images,{studio,{url,angles}}},hasPerformancePackage,registrationNo,deliveryDate,currentPlannedDeliveryDate}}",
variables: {},
};
const req = new Request(POLESTAR_API_URL_V2);
req.method = "POST";
req.headers = {
"Content-Type": "application/json",
Authorization: "Bearer " + accessToken,
};
req.body = JSON.stringify(searchParams);
const response = await req.loadJSON();
const vehicleData = response?.data?.getConsumerCarsV2;
if (!vehicleData) {
throw new Error("No vehicle data fetched");
}
const vehicle =
vehicleData.find((vehicle) => vehicle.vin === VIN) ?? vehicleData[0];
if (!vehicle) {
throw new Error(`No vehicle found with VIN ${VIN}`);
}
VIN = vehicle.vin;
return vehicle;
}
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_V2);
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");
}
return response.data.getBatteryData;
}
async function getOdometerData(accessToken) {
if (!accessToken) {
throw new Error("Not authenticated");
}
const searchParams = {
query:
"query GetOdometerData($vin: String!){getOdometerData(vin:$vin){averageSpeedKmPerHour,eventUpdatedTimestamp,{iso,unix},odometerMeters,tripMeterAutomaticKm,tripMeterManualKm}}",
variables: {
vin: VIN,
},
};
const req = new Request(POLESTAR_API_URL_V2);
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?.getOdometerData) {
throw new Error("No odometer data fetched");
}
return response.data.getOdometerData;
}
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("&");
}
/*************
* Formatters
*************/
function getBatteryPercentColor(percent) {
if (percent > MIN_SOC_GREEN) {
return Color.green();
} else if (percent > MIN_SOC_ORANGE) {
return Color.orange();
} else {
return Color.red();
}
}
function getBatteryIcon(
batteryPercent,
isConnected,
isCharging,
isChargingDone
) {
let icon;
let iconColor;
if (isCharging || isChargingDone) {
icon = isCharging
? SFSymbol.named("bolt.fill")
: SFSymbol.named("bolt.badge.checkmark.fill");
iconColor = Color.green();
} else if (isConnected) {
icon = SFSymbol.named("bolt.badge.xmark");
iconColor = Color.red();
} else {
let percentRounded = 0;
iconColor = Color.red();
if (batteryPercent > 90) {
percentRounded = 100;
} else if (batteryPercent > 60) {
percentRounded = 75;
} else if (batteryPercent > 40) {
percentRounded = 50;
} else if (batteryPercent > 10) {
percentRounded = 25;
}
iconColor = getBatteryPercentColor(batteryPercent);
icon = SFSymbol.named(`battery.${percentRounded}`);
}
return { batteryIcon: icon, batteryIconColor: iconColor };
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment