Last active
May 8, 2026 17:38
-
-
Save shayelkin/cd80808017dcfb4abbe1cfe7b86ec57c to your computer and use it in GitHub Desktop.
Scriptable.app Battery Widget for Hyundai USA's EVs
This file contains hidden or 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
| /** | |
| * 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(); |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Screenshot