SoC medium homescreen widget [iOS]

Für das Lockscreen Widget hab ich es schon eingebaut, Medium kommt auch noch, aber braucht noch bisschen.

Hab mal vorauseilend eine neue Version meines kleinen SoC Widgets für den Homescreen gebaut.
Dank neuer Polestar API hat es nun auch die Reichweite auf den Screen geschafft … :blush:
Vielen Dank an @salkin für die ganze Vorarbeit!

IMG_1250

6 „Gefällt mir“

Hallo, das sieht optimal aus!
kannst du dein Script zur Verfügung stellen?

lg Andreas

Hi,
Hier das Skript und die verwendeten Bilder. Die Bilder müssen in das scriptable Verzeichnis in der iCloud kopiert werden. Man sollte natürlich seine eigenen Bilder verwenden. Habe mal meine Bilder angehängt.


/**
 * This version has been adapted by demichve
 * Light background
 * Small home wigdet
 * Usage of Polestar API instead Tibber API
 * Load of local pictures using iCloud
 */

// Config
const POLESTAR_EMAIL = "<your email>";
const POLESTAR_PASSWORD = "<your password>";
const VIN = "<your VIN>";

const POLESTAR_ICON = "star.png";
const POLESTAR_PIC = "polestar2.jpg";

let fm = FileManager.iCloud();
const iconpath = await loadicloud(POLESTAR_ICON);
const picpath = await loadicloud(POLESTAR_PIC);

// 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");
}

// 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();

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.presentSmall();
}
Script.complete();

// Create Widget
async function createPolestarWidget() {
  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 lastSeenDate = parseISOString(batteryData.eventUpdatedTimestamp.iso);
  const distance = parseInt(batteryData.estimatedDistanceToEmptyKm);

  const widget = new ListWidget();
  widget.url = "polestar-explore://";
  widget.backgroundColor = Color.white();
  widget.addSpacer(1);

  // Show app icon and title
  const titleStack = widget.addStack();
  const titleElement = titleStack.addText("    Polestar 2");
  titleElement.textColor = Color.black();
  //titleElement.textOpacity = 0.7;
  titleElement.font = Font.mediumSystemFont(14);
  titleStack.addSpacer(16);
  const appIconElement = titleStack.addImage(fm.readImage(iconpath));
  appIconElement.imageSize = new Size(20, 20);
  appIconElement.cornerRadius = 4;
  widget.addSpacer(6);

  // Car Picture Stack
  const contentStack = widget.addStack();
  const carImageElement = contentStack.addImage(fm.readImage(picpath));
  carImageElement.imageSize = new Size(130, 60);
  //contentStack.addSpacer(1);

  // Battery Info Stack
  const batteryInfoStack = widget.addStack();
  //batteryInfoStack.layoutVertically();
  batteryInfoStack.centerAlignContent()
  //batteryInfoStack.addSpacer(1);

  // Battery Percent Value
  //const batteryPercent = tibberData.battery.percent;
  //const isCharging = tibberData.battery.isCharging;
  const batteryPercentStack = batteryInfoStack.addStack();
  //batteryPercentStack.addSpacer();
  batteryPercentStack.centerAlignContent();
  const batterySymbol = getBatteryPercentIcon(batteryPercent, isCharging);
  const batterySymbolElement = batteryPercentStack.addImage(
    batterySymbol.image
  );
  batterySymbolElement.imageSize = new Size(30, 30);
  batterySymbolElement.tintColor = getBatteryPercentColor(batteryPercent);
  //batteryPercentStack.addSpacer(2);

  const batteryPercentText = batteryPercentStack.addText(`${batteryPercent} %`);
  //textStack.addText(`${batteryPercent}%`);
  batteryPercentText.textColor = getBatteryPercentColor(batteryPercent);
  batteryPercentText.font = Font.boldSystemFont(14);
  
  // Distance
  //const distanceStack = widget.addStack();
  //batteryPercentStack.addSpacer(1);
  const distanceStackText = batteryPercentStack.addText(` ${distance} km`);
  distanceStackText.textColor = getBatteryPercentColor(batteryPercent);
  distanceStackText.font = Font.boldSystemFont(13);

  // Footer Stack
  const footerStack = widget.addStack();
  footerStack.addSpacer(8);

  // Add last seen indicator
  //const lastSeenDate = new Date(tibberData.lastSeen);
  const lastSeenText = lastSeenDate.toLocaleString();
  let lastSeenElement;
  //if (LAST_SEEN_RELATIVE_DATE) {
    //lastSeenElement = footerStack.addDate(lastSeenDate);
    //lastSeenElement.applyRelativeStyle();
  //} else {
    lastSeenElement = footerStack.addText(lastSeenText);
  //}
  lastSeenElement.textColor = Color.black();
  lastSeenElement.font = Font.mediumSystemFont(10);
  lastSeenElement.textOpacity = 0.5;
  //lastSeenElement.minimumScaleFactor = 0.5;
  lastSeenElement.centerAlignText();
  
  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`
  );
  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("https://pc-api.polestar.com/eu-north-1/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("https://pc-api.polestar.com/eu-north-1/mystar-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");
  }
  const data = response.data.getBatteryData;
  return data;
}


function getUrlEncodedParams(object) {
  return Object.keys(object)
    .map((key) => `${key}=${encodeURIComponent(object[key])}`)
    .join("&");
}

/************************
 * Load files from iCloud
 ************************/
async function loadicloud(filename) {
  let path = fm.joinPath(fm.documentsDirectory(),filename);
  await fm.downloadFileFromiCloud(path);
  return path;
}

/*************
 * Formatters
 *************/
function getBatteryPercentColor(percent) {
  if (percent > 60) {
    return Color.green();
  } else if (percent > 30) {
    return Color.orange();
  } else {
    return Color.red();
  }
}

function parseISOString(s) {
  var b = s.split(/\D+/);
  return new Date(Date.UTC(b[0], --b[1], b[2], b[3], b[4], b[5], b[6]));
}

function getBatteryPercentIcon(percent, isCharging) {
  if (isCharging) {
    return SFSymbol.named(`battery.100percent.bolt`);
  }
  let percentRounded = 0;

  if (percent > 90) {
    percentRounded = 100;
  } else if (percent > 60) {
    percentRounded = 75;
  } else if (percent > 40) {
    percentRounded = 50;
  } else if (percent > 10) {
    percentRounded = 25;
  }
  return SFSymbol.named(`battery.${percentRounded}`);
}

Viel Spaß!


2 „Gefällt mir“

Vielen Dank! Mal sehen ob es nach MFA auch noch geht.
IMG_1955

1 „Gefällt mir“

Hi zusammen,
Neue Version des medium-widget ist jetzt verfügbar.
Basiert auf der richtigen Polestar API und hat ein paar neue Konfigurationsoptionen und Support für Light Mode und unterschiedliche Bilderwinkel :slight_smile:

8 „Gefällt mir“

Sehr schön, vielen Dank!

Richtig gut geworden, vielen Dank!

Klasse, sogar mehr Daten als in der App (Tachostand)
Danke!!

Funktioniert einwandfrei!
Eine Frage hätte ich: wie und wo konfiguriere ich die unterschiedlichen Blickwinkel?

Für IMG_ANGLE einen Wert 0-5 eintragen :slight_smile:

2 „Gefällt mir“

Danke! Jetzt ist es für mich perfekt.

Etwas spät, aber : Hammergeil !!!

Und mit ein bisschen modding geht’s auch. :smiley:
Wobei das Bild vom Polestar jetzt per Hand bearbeitet und dann hardcoded wurde. Da das Original Sooooo viel padding bzw. einen ganz dezenten Schatten hatte.
(Und das, während der Arbeitszeit, Hahaha)

Frage: Wann ist denn das Blitz-Symbol links neben dem SoC rot und wann grün?
Bildschirmfoto 2024-02-05 um 16.28.05

Ich hatte das Auto die Tage in der Garage angesteckt, aber nicht geladen (weil PV-Überschuss nicht reichte). Da war das Symbol rot.

Jetzt ist das Symbol grün. Ich habe heute geladen (von 78-80%) auf Ende Ladeziel.

Steht ja im Quelltext:

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 };
}

iconColor wird

  • Lädt oder fertig: grün (1. if)
  • angesteckt, aber kein Strom: rot (2. if)
  • sonst: Batteriesymbol mit wechselnden Farben (Fehler im Code gefunden: 1. Zuweisung ˋiconColor = Color.red();ˋ wird weiter unten überschrieben)

:nerd_face::nerd_face::nerd_face:

Haha da hat aber jemand sehr genau hingeschaut, ist wohl noch ein Copy Paste Fehler :wink:

1 „Gefällt mir“
  • Batteriesymbol mit Farbe (je nach Threshold oben in der Konfiguration) = nicht angesteckt und lädt nicht
  • Roter Blitz mit :x: im Kreis = Kabel verbunden aber lädt nicht
  • Grüner Blitz = Kabel verbunden und lädt
  • Grüner Blitz und :heavy_check_mark:Kreis = Laden abgeschlossen (Ladelimit erreicht)

Leider wird das :x: und der :heavy_check_mark: im Kreis grade nicht angezeigt, in der Vorschau in Scriptable gehts, im Widget ist der Kreis immer ausgefüllt ohne Icon. Glaube das ist ein Bug in Scriptable, wird vielleicht mit einem Update behoben in der Zukunft.

Ich habe einen rekordverdächtigen Tachostand. Da ist aber nicht das Widget schuld, denn im Polestar Portal sehe ich den gleichen Fehler. Irgendwie scheint jedes neue Feature bei Polestar erstmal bugbehaftet zu sein.
IMG_4686

1 „Gefällt mir“

Cool!

2^31-1=2.147.483.647

Die Schnittstelle liefert also FF FF FF FF.

Angezeigt wird aber nur 2.147.483, also letzte 3 Stellen abgeschnitten, da Textfeld zu klein.

Der Praktikant hat wohl keinen Testfall für die Edge-Cases gebaut.

:rofl::rofl::rofl: