Skip to content

Instantly share code, notes, and snippets.

@shayelkin
Last active May 8, 2026 17:38
Show Gist options
  • Select an option

  • Save shayelkin/cd80808017dcfb4abbe1cfe7b86ec57c to your computer and use it in GitHub Desktop.

Select an option

Save shayelkin/cd80808017dcfb4abbe1cfe7b86ec57c to your computer and use it in GitHub Desktop.
Scriptable.app Battery Widget for Hyundai USA's EVs
/**
* A Scriptable <https://scriptable.app/> Battery Widget for Hyundai USA's EVs.
* SPDX-License-Identifier: MIT
*/
"use strict";
const DEBUG = false;
// Loaded from (device) cache -> ms after which VehicleStatus would be considered stale.
const STATUS_FRESHNESS_MS = {
true: 4_000_000,
false: 10_000_000,
}
// KEYCHAIN
const KEYCHAIN_PASSWORD_KEY= "HyundaiPassword";
const KEYCHAIN_TOKEN_KEY = "HyundaiToken";
function getFromKeychain(key, defaultValue) {
if (!Keychain.contains(key)) {
console.log(`Missing key ${key} in keychain, using default value.`);
return defaultValue;
}
const value = Keychain.get(key);
return JSON.parse(value);
}
function removeSecrets() {
Keychain.remove(KEYCHAIN_PASSWORD_KEY);
Keychain.remove(KEYCHAIN_TOKEN_KEY);
}
// BLUELINK API
const BASE_URL = "https://api.telematics.hyundaiusa.com";
const EXPIRY_MARGIN_MS = 60_000;
const CLIENT_HEADERS = {
"Content-Type": "application/json",
// Sniffed from the official app:
client_id: "m66129Bb-em93-SPAHYN-bZ91-am4540zp19920",
clientSecret: "v558o935-6nne-423i-baa8",
"User-Agent": "okhttp/3.14.9"
};
async function authHeaders() {
return { ...CLIENT_HEADERS, access_token: await token() };
}
async function api(path, { method = "GET", headers = CLIENT_HEADERS, body } = {}) {
const url = BASE_URL + path;
console.log(`Requesting ${url}...`);
const req = new Request(BASE_URL + path);
req.method = method;
req.headers = headers;
if (body) req.body = JSON.stringify(body);
const resp = await req.loadJSON();
if (resp?.errorCode) {
const msg = `API error: ${JSON.stringify(resp)}.`;
console.error(msg);
throw new Error(msg);
} else if (DEBUG) {
console.log(`API response: ${JSON.stringify(resp)}.`);
}
return resp;
}
// BLUELINK API - VEHICLE DETAILS
async function carHeaders(vin) {
const offset = new Date().getTimezoneOffset() / -60;
// Request will fail if offset isn't exactly 2 digits.
const offsetStr = (offset < 0 ? '-' : '') + String(Math.abs(offset)).padStart(2, '0');
return {
...await authHeaders(),
brandIndicator: "H",
from: "SPA",
language: '0',
offset: offsetStr,
username: auth.username,
vin,
};
}
async function getVehicles() {
const data = await api(`/ac/v2/enrollment/details/${auth.username}`, {
headers: await authHeaders()
});
return data.enrolledVehicleDetails.map((v) => v.vehicleDetails);
}
async function getVehicleStatus(vin, refresh = false) {
const ch = await carHeaders(vin);
const data = await api("/ac/v2/rcs/rvs/vehicleStatus", {
headers: { ...ch, refresh: String(refresh) }
});
return data.vehicleStatus;
}
// BLUELINK API - AUTH
var auth = null;
function saveToken(data) {
auth = {
...data,
expiresAt: Date.now() + (data.expires_in*1000),
}
console.log(`Received auth token that would expire at {auth.expiresAt}.`);
try {
Keychain.set(KEYCHAIN_TOKEN_KEY, JSON.stringify(auth));
} catch (e) {
console.warn(`Failed to save auth token to keychain (will be retained in memory): ${e}`);
}
return auth;
}
async function token() {
if (Date.now() >= auth.expiresAt - EXPIRY_MARGIN_MS) await refreshToken();
return auth.access_token;
}
async function refreshToken() {
if (!auth.refresh_token) return login();
try {
const data = await api("/v2/ac/oauth/token/refresh", {
method: "POST",
body: { refresh_token: auth.refresh_token },
});
saveToken(data);
return data;
} catch (e) {
console.warn(`Failed to refresh token, trying to log in instead: ${e}`);
return login();
}
throw new Error("Shouldn't reach here!");
}
async function login() {
const creds = await credentials();
console.log(`Logging in to Bluelink with username ${creds.username}...`);
const data = await api("/v2/ac/oauth/token", {
method: "POST",
body: creds,
});
saveToken(data);
return data;
}
// LOGIN DIALOG
async function credentials() {
const loaded = getFromKeychain(KEYCHAIN_PASSWORD_KEY);
if (loaded) return loaded;
// Don't show the login dialog unless running inside the app
if (!config.runsInApp) throw new Error(`Missing credentials. Run from the app to set`);
const creds = await loginDialog();
try {
Keychain.set(KEYCHAIN_PASSWORD_KEY, JSON.stringify(creds));
} catch (e) {
console.warn(`Failed to save username and password to keychain: ${e}`);
}
return creds;
}
async function loginDialog() {
const alert = new Alert();
alert.title = "Bluelink Log In";
// textField #0 - username
alert.addTextField("user@domain.com");
// textField #1 - password
alert.addSecureTextField();
alert.addCancelAction("Cancel");
alert.addDestructiveAction("Log In");
const result = await alert.present();
// User canceled
if (result == -1) return;
return {
username: alert.textFieldValue(0),
password: alert.textFieldValue(1)
};
}
// CACHE
function isVehicleStatusFresh(vehicleStatus, isCached) {
if (!vehicleStatus?.dateTime) return false;
const ageMs = Date.now() - Date.parse(vehicleStatus.dateTime);
const isFresh = (ageMs < STATUS_FRESHNESS_MS[!!isCached]) && !!vehicleStatus.evStatus;
console.log(`Freshness check: ageMs=${ageMs}, isFresh=${isFresh}`);
return isFresh;
}
async function cachedVehicleStatus(vin) {
const fm = FileManager.local();
const path = fm.joinPath(fm.cacheDirectory(), `${vin}-status.json`);
if (fm.fileExists(path)) {
try {
const cachedVehicleStatusJSON = fm.readString(path);
const status = JSON.parse(cachedVehicleStatusJSON);
if (isVehicleStatusFresh(status, true)) return status;
} catch (e) {
console.warn(`Failed to load cached vehicle status from ${path}: ${e}`);
}
}
let refresh = false;
do {
const status = await getVehicleStatus(vin, refresh);
if (isVehicleStatusFresh(status)) {
console.log(`Got fresh vehicle status (refresh=${refresh}).`);
try {
fm.writeString(path, JSON.stringify(status));
} catch (e) {
console.warn(`Failed to cache vehicle status to ${path}: ${e}`);
}
return status;
}
refresh = !refresh;
} while (refresh);
console.error("Unable to load a fresh vehicle status!");
}
const IMAGE_SIZE_SMALL = "dynamicBurgerMenu";
const IMAGE_SIZE_LARGE = "dynamicDashboard";
async function getVehicleImage(vehicleDetails, imageSizeKey) {
const fm = FileManager.local();
const path = fm.joinPath(fm.cacheDirectory(), `${vehicleDetails.vin}-${imageSizeKey}`);
try {
const cachedImage = fm.readImage(path);
// readImage() only throws on some errors, not all.
if (cachedImage) return cachedImage;
} catch (e) {
console.warn(`Unable to load cached image from ${path}: ${e}`);
}
const url = vehicleDetails[imageSizeKey];
console.log(`Downloading image from ${url}...`);
const req = new Request(url);
const image = await req.loadImage();
try {
fm.writeImage(path, image);
} catch (e) {
console.warn(`Unable to save image to cache at ${path}: ${e}`);
}
return image;
}
// CAR SELECTION
const SELECTED_VEHICLE_FILENAME = "HyundaiSelectedVehicle.json";
function selectedVehiclePath(fileManager) {
return fileManager.joinPath(fileManager.libraryDirectory(), SELECTED_VEHICLE_FILENAME);
}
// The following have no exception handling, as a failure here is considered
// catastrophic.
function saveSelectedVehicle(v) {
const fm = FileManager.local();
const path = selectedVehiclePath(fm);
console.log(`Saving vehicle data to ${path}...`);
fm.writeString(path, JSON.stringify(v));
}
function loadSelectedVehicle() {
const fm = FileManager.local();
const path = selectedVehiclePath(fm);
console.log(`Loading vehicle data from ${path}...`);
return JSON.parse(fm.readString(path));
}
async function selectVehicleScreen() {
const ui = new UITable();
const headerRow = new UITableRow();
headerRow.isHeader = true;
headerRow.addText("Choose a vehicle to show:");
ui.addRow(headerRow);
const vehicles = await getVehicles();
for (const v of vehicles) {
const row = new UITableRow();
const icon = row.addImage(await getVehicleImage(v, IMAGE_SIZE_SMALL));
icon.widthWeight = 1;
const text = row.addText(v.nickName, v.vin);
text.widthWeight = 4;
row.onSelect = () => { saveSelectedVehicle(v); };
ui.addRow(row);
}
const logoutRow = new UITableRow();
const logoutBtn = logoutRow.addButton("Log Out");
logoutBtn.centerAligned();
logoutBtn.dismissOnTap = true;
logoutBtn.onTap = removeSecrets;
ui.addRow(logoutRow);
return ui.present();
}
// UI - WIDGET
function messageWidget(msg) {
const iconSize = 40;
const fontSize = 15;
const listWidget = new ListWidget();
const icon = listWidget.addImage(SFSymbol.named("exclamationmark.triangle").image);
icon.imageSize = new Size(iconSize, iconSize);
listWidget.addSpacer(fontSize);
const text = listWidget.addText(msg);
text.font = Font.semiboldSystemFont(fontSize);
return listWidget;
}
function distanceUnit(unit) {
if (unit === 3) return "mi";
if (unit === 1) return "km";
console.warn(`Unrecognized distance unit: ${unit}`);
return "";
}
/*
function buildValueStack(stack, value, unit, desc, highlightColor) {
const bigFont = Font.semiboldSystemFont(25);
const smallFont = Font.semiboldSystemFont(10);
// Similar design to Hyundai's app
stack.layoutVertically();
stack.setPadding(0, 3, 0, 3);
const top = stack.addStack();
top.setPadding(0, 0, 0, 0);
top.bottomAlignContent();
const valueText = top.addText(String(value));
valueText.font = bigFont;
if (highlightColor) valueText.textColor = highlightColor;
const unitStack = top.addStack();
// Compensate for the difference in baseline between the fonts
unitStack.setPadding(0, 0, 3, 0);
unitStack.bottomAlignContent();
const unitText = unitStack.addText(unit);
unitText.font = smallFont;
if (highlightColor !== undefined) unitText.textColor = highlightColor;
const bottom = stack.addText(desc);
bottom.font = smallFont;
}
*/
const BACKGROUND = Color.black();
const FOREGROUND = Color.white();
function batterySOCColor(soc, isPluggedIn, isCharging) {
if (soc < 100 && isPluggedIn) return Color.blue();
if (soc > 20) return Color.green();
// if (soc > 20) return Color.orange();
return Color.red();
}
function drawGauge(p, fullColor, emptyColor, insideImage) {
const canvasSize = 175;
const radius = 75;
const stroke = 14;
const step = 0.03;
const canvas = new DrawContext();
canvas.size = new Size(canvasSize, canvasSize);
canvas.respectScreenScale = true;
canvas.opaque = false;
canvas.setFillColor(fullColor);
canvas.setStrokeColor(emptyColor);
canvas.setLineWidth(stroke);
const topLeft = canvasSize / 2 - radius;
const diameter = 2 * radius;
canvas.strokeEllipse(new Rect(topLeft, topLeft, diameter, diameter));
const o = (canvasSize - stroke) / 2;
const deg = 2*Math.PI*p;
for (let t = 0; t < deg; t += step) {
const rect = new Rect(
o + radius * Math.sin(t),
o - radius * Math.cos(t),
stroke,
stroke);
canvas.fillEllipse(rect);
}
if (insideImage) {
const doubleStroke = 2*stroke;
// assume width >= height
const imageSize = insideImage.size;
const insideImageHeight = (diameter-doubleStroke)/(imageSize.width/imageSize.height);
canvas.drawImageInRect(insideImage,
new Rect(doubleStroke, (canvasSize-insideImageHeight)/2, diameter-doubleStroke, insideImageHeight));
}
return canvas.getImage();
}
function drawGaugeLabel(image, text) {
const dc = new DrawContext();
const textSize = image.size.width/5;
dc.size = new Size(image.size.width, image.size.height+textSize);
dc.opaque = false;
dc.drawImageAtPoint(image, new Point(14, -28));
dc.setTextAlignedCenter();
dc.setFont(Font.boldSystemFont(
textSize));
dc.setTextColor(FOREGROUND);
dc.drawTextInRect(
text,
new Rect(0, image.size.height-28, image.size.width, textSize));
return dc.getImage();
}
function buildWidget({carImage, soc, range, isPluggedIn, isCharging, lastUpdated}) {
const listWidget = new ListWidget();
listWidget.backgroundColor = BACKGROUND;
listWidget.setPadding(0, 0, 0, 0);
const gaugeStack = listWidget.addStack();
gaugeStack.setPadding(0, 0, 0, 0);
const gaugeColor = batterySOCColor(soc, isPluggedIn, isCharging);
gaugeStack.addSpacer();
const gauge = gaugeStack.addImage(
drawGauge(soc/100, gaugeColor, new Color('ffffff', 0.1),
drawGaugeLabel(carImage, range.value+distanceUnit(range.unit))));
gauge.centerAlignImage();
gaugeStack.addSpacer();
/*
const carImageStack = listWidget.addStack();
// Hyundai's images are offset to the left, and Scriptable doesn't support masks.
carImageStack.setPadding(-16, 16, 0, 0);
carImageStack.topAlignContent();
carImageStack.addImage(carImage);
listWidget.addSpacer(12);
listWidget.useDefaultPadding();
const rangeStack = listWidget.addStack();
buildValueStack(rangeStack.addStack(), soc, "%", "Battery",
batterySOCColor(soc, isPluggedIn, isCharging));
rangeStack.addSpacer();
buildValueStack(rangeStack.addStack(), range.value, distanceUnit(range.unit), "Est. Range");
listWidget.addSpacer();
*/
const lastUpdatedStack = listWidget.addStack();
lastUpdatedStack.setPadding(0, 28, 0, 0);
// lastUpdatedStack.bottomAlignContent();
const updated = (new RelativeDateTimeFormatter()).string(lastUpdated, new Date());
const lastUpdatedText = lastUpdatedStack.addText(updated);
lastUpdatedText.font = Font.systemFont(8);
lastUpdatedText.textColor = FOREGROUND;
return listWidget;
}
async function widget() {
const vehicleDetails = loadSelectedVehicle();
if (!vehicleDetails) return messageWidget("Can't load selected vehicle!");
if (DEBUG) console.log("Vehicle details: " + JSON.stringify(vehicleDetails));
const vehicleStatus = await cachedVehicleStatus(vehicleDetails.vin);
if (!vehicleStatus) return messageWidget("Can't load vehicle details!")
if (DEBUG) console.log("Vehicle status: " + JSON.stringify(vehicleStatus));
const evStatus = vehicleStatus.evStatus;
try {
return buildWidget({
carImage: await getVehicleImage(vehicleDetails, IMAGE_SIZE_LARGE),
soc: evStatus.batteryStatus,
range: evStatus?.drvDistance?.[0]?.rangeByFuel?.totalAvailableRange,
isPluggedIn: evStatus.batteryPlugin > 0,
isCharging: evStatus.batteryCharge,
lastUpdated: new Date(vehicleStatus.dateTime),
});
} catch(e) {
console.error(`Error building widget: ${e}`);
if (DEBUG) throw e;
return messageWidget(String(e));
}
}
// MAIN
async function main() {
auth = getFromKeychain(KEYCHAIN_TOKEN_KEY,
{ expiresAt: 0, access_token: null, refresh_token: null, username: null });
if (config.runsInApp) {
await selectVehicleScreen();
// if (!DEBUG) return;
}
const theWidget = await widget();
Script.setWidget(theWidget);
if (!config.runsInWidget) {
theWidget.presentLarge();
}
}
await main();
Script.complete();
@shayelkin
Copy link
Copy Markdown
Author

Screenshot

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment