UNPKG

npm-vwconnectidapi

Version:

Provides an API for We Connect ID

1,423 lines (1,271 loc) 73.4 kB
"use strict"; // Cleaned VW WeConnect ID API (BFF-based, no legacy WeCharge / MBB / FS-Car flows) const EventEmitter = require("events").EventEmitter; const request = require("request"); const express = require("express"); const mysql = require("mysql2"); module.exports.idStatusEmitter = new EventEmitter(); module.exports.idLogEmitter = new EventEmitter(); class Log { constructor(logLevel, external = false) { this.logLevel = logLevel; this.logTargetExternal = external; if (this.logTargetExternal) { module.exports.idLogEmitter.emit(this.logLevel, "Start logging instance"); } else { this.debug("Start logging instance"); } } setLogLevel(pLogLevel, pExternal = false) { this.logLevel = pLogLevel; this.logTargetExternal = pExternal; } debug(pMessage) { if (this.logLevel === "DEBUG") { if (this.logTargetExternal) { module.exports.idLogEmitter.emit("DEBUG", pMessage); } else { console.log("DEBUG: " + pMessage); } } } error(pMessage) { if (this.logLevel !== "NONE") { if (this.logTargetExternal) { module.exports.idLogEmitter.emit("ERROR", pMessage); } else { console.log("ERROR: " + pMessage); } } } info(pMessage) { if (this.logLevel === "DEBUG" || this.logLevel === "INFO") { if (this.logTargetExternal) { module.exports.idLogEmitter.emit("INFO", pMessage); } else { console.log("INFO: " + pMessage); } } } warn(pMessage) { if ( this.logLevel === "DEBUG" || this.logLevel === "INFO" || this.logLevel === "WARN" ) { if (this.logTargetExternal) { module.exports.idLogEmitter.emit("WARN", pMessage); } else { console.log("WARN: " + pMessage); } } } } class VwWeConnect { config = { userid: 0, user: "testuser", password: "testpass", type: "id", interval: 10, forceinterval: 0, numberOfTrips: 1, logLevel: "ERROR", targetTempC: -1, targetSOC: -1, chargeCurrent: "maximum", autoUnlockPlug: false, climatizationAtUnlock: true, climatisationWindowHeating: true, climatisationFrontLeft: true, climatisationFrontRight: false, unSafe: false, noExternalPower: false, pendingRequests: 0, historyLimit: 100, chargerOnly: false, backendError: false, db: null, checkSafeStatusTimeout: 5 }; currSession = { vin: "n/a" }; constructor() { this.boolFinishIdData = false; this.boolFinishVehicles = false; this.log = new Log(this.config.logLevel); this.jar = request.jar(); this.userAgent = "Volkswagen/3.51.1-android/14"; // NEW: guarded login state this.isLoggingIn = false; this.loginPromise = null; this.updateInterval = null; this.vinArray = []; this.etags = {}; } finishedReading() { return ( (this.boolFinishIdData || this.config.chargerOnly) && this.boolFinishVehicles ); } setCredentials(pUser, pPass) { this.config.user = pUser; this.config.password = pPass; this.config.interval = 0.5; this.config.checkSafeStatusTimeout = 5; // minutes } setConfig(pType) { if (pType === "idCharger") { this.config.type = "id"; this.config.chargerOnly = true; } else { this.config.type = pType; } } setHistoryLimit(pLimit) { this.config.historyLimit = pLimit; } setActiveVin(pVin) { if (this.vinArray.includes(pVin)) { this.currSession.vin = pVin; this.log.info("Active VIN successfully set to <" + this.currSession.vin + ">."); } else { this.log.error( "VIN <" + pVin + "> is unknown. Active VIN is still <" + this.currSession.vin + ">." ); } } setDatabase(pHost) { this.config.db = mysql.createPool({ host: pHost, user: this.config.user, password: this.config.password, database: "weconnect", waitForConnections: true, connectionLimit: 10 }); } restart() { // hook for external restart logic if needed } stopCharging() { return new Promise((resolve, reject) => { this.log.debug("stopCharging >>"); this.setIdRemote(this.currSession.vin, "charging", "stop", "") .then(() => { this.log.debug("stopCharging successful"); resolve(); }) .catch((error) => { this.log.error("stopCharging failed"); reject(error); }); this.log.debug("stopCharging <<"); }); } setChargingSetting(setting, value) { return new Promise((resolve, reject) => { this.log.debug("setChargerSetting " + setting + " to " + value + " >>"); if (!this.finishedReading()) { this.log.info( "Reading necessary data not finished yet. Please try again." ); reject(new Error("Data not ready")); return; } if (!this.vinArray.includes(this.currSession.vin)) { this.log.error( "Unknown VIN, aborting. Use setActiveVin to set a valid VIN." ); reject(new Error("Unknown VIN")); return; } switch (setting) { case "targetSOC": if (value >= 50 && value <= 100) { this.config.pendingRequests++; this.config.targetSOC = value; } else { this.log.debug( "setChargerSetting " + setting + " value " + value + " out of bounds >" ); } break; case "chargeCurrent": if (value === "maximum" || value === "reduced") { this.config.pendingRequests++; this.config.chargeCurrent = value; } else { this.log.debug( "setChargerSetting " + setting + " incorrect value " + value + " >>" ); } break; case "autoUnlockPlug": this.config.pendingRequests++; this.config.autoUnlockPlug = value; break; default: this.log.debug( "setChargerSetting " + setting + " not implemented" + " >>" ); break; } this.setIdRemote(this.currSession.vin, "charging", "settings") .then(() => { this.log.info("setIdRemote succeeded"); this.config.pendingRequests = Math.max( this.config.pendingRequests - 1, 0 ); resolve(); }) .catch((error) => { this.log.error("setIdRemote failed"); this.config.pendingRequests = 0; reject(error); }); this.log.debug("setChargerSettings <<"); }); } setChargingSettings(pTargetSOC, pChargeCurrent) { return new Promise((resolve, reject) => { this.log.debug( "setChargerSettings TargetSOC to " + pTargetSOC + "%, chargeCurrent to " + pChargeCurrent + " >>" ); if (!this.finishedReading()) { this.log.info( "Reading necessary data not finished yet. Please try again." ); reject(new Error("Data not ready")); return; } if (!this.vinArray.includes(this.currSession.vin)) { this.log.error( "Unknown VIN, aborting. Use setActiveVin to set a valid VIN." ); reject(new Error("Unknown VIN")); return; } if (pTargetSOC >= 50 && pTargetSOC <= 100) { this.config.pendingRequests++; this.config.targetSOC = pTargetSOC; } if (pChargeCurrent === "maximum" || pChargeCurrent === "reduced") { this.config.pendingRequests++; this.config.chargeCurrent = pChargeCurrent; } this.setIdRemote(this.currSession.vin, "charging", "settings") .then(() => { this.log.info( "Target SOC set to " + this.config.targetSOC + "%." ); this.log.info( "Charging current set to " + this.config.chargeCurrent + "." ); this.config.pendingRequests = Math.max( this.config.pendingRequests - 1, 0 ); resolve(); }) .catch((error) => { this.log.error("setting SOC and charge current failed"); this.config.pendingRequests = 0; reject(error); }); this.log.debug("setChargerSettings <<"); }); } startCharging() { return new Promise((resolve, reject) => { this.log.debug("startCharging >>"); if (!this.finishedReading()) { this.log.info( "Reading necessary data not finished yet. Please try again." ); reject(new Error("Data not ready")); return; } if (!this.vinArray.includes(this.currSession.vin)) { this.log.error( "Unknown VIN, aborting. Use setActiveVin to set a valid VIN." ); reject(new Error("Unknown VIN")); return; } this.setIdRemote(this.currSession.vin, "charging", "start") .then(() => { this.log.debug("startCharging successful"); resolve(); }) .catch((error) => { this.log.error("startCharging failed"); reject(error); }); this.log.debug("startCharging <<"); }); } stopClimatisation() { return new Promise((resolve, reject) => { this.log.debug("stopClimatisation >>"); this.setIdRemote(this.currSession.vin, "climatisation", "stop", "") .then(() => { this.log.debug("stopClimatisation successful"); resolve(); }) .catch((error) => { this.log.error("stopClimatisation failed"); reject(error); }); this.log.debug("stopClimatisation <<"); }); } setDestination(destination) { return new Promise((resolve, reject) => { this.log.debug("setDestination >>"); this.setIdRemote(this.currSession.vin, "destinations", "", destination) .then(() => { this.log.debug("setDestination successful"); resolve(); }) .catch((error) => { this.log.error("setDestination failed"); reject(error); }); this.log.debug("setDestination <<"); }); } startClimatisation() { return new Promise((resolve, reject) => { this.log.debug( "startClimatisation with " + this.config.targetTempC + "°C >>" ); if (!this.finishedReading()) { this.log.info( "Reading necessary data not finished yet. Please try again." ); reject(new Error("Data not ready")); return; } if (!this.vinArray.includes(this.currSession.vin)) { this.log.error( "Unknown VIN, aborting. Use setActiveVin to set a valid VIN." ); reject(new Error("Unknown VIN")); return; } this.setIdRemote(this.currSession.vin, "climatisation", "start", "") .then(() => { this.log.debug("startClimatisation successful"); resolve(); }) .catch((error) => { this.log.error("startClimatisation failed"); reject(error); }); this.log.debug("startClimatisation <<"); }); } setClimatisationSetting(pSetting, pValue) { return new Promise((resolve, reject) => { this.log.debug( "setClimatisationSetting with " + pSetting + " " + pValue + " >>" ); if (!this.finishedReading()) { this.log.info( "Reading necessary data not finished yet. Please try again." ); reject(new Error("Data not ready")); return; } if (!this.vinArray.includes(this.currSession.vin)) { this.log.error( "Unknown VIN, aborting. Use setActiveVin to set a valid VIN." ); reject(new Error("Unknown VIN")); return; } this.config.pendingRequests++; switch (pSetting) { case "climatizationAtUnlock": this.config.climatizationAtUnlock = pValue; break; case "climatisationWindowHeating": this.config.climatisationWindowHeating = pValue; break; case "climatisationFrontLeft": this.config.climatisationFrontLeft = pValue; break; case "climatisationFrontRight": this.config.climatisationFrontRight = pValue; break; default: break; } this.setIdRemote(this.currSession.vin, "climatisation", "settings", "") .then(() => { this.log.debug("setClimatisationSetting successful"); this.config.pendingRequests = Math.max( this.config.pendingRequests - 1, 0 ); resolve(); }) .catch((error) => { this.log.error("setClimatisationSetting failed"); this.config.pendingRequests = 0; reject(error); }); this.log.debug("setClimatisationSetting <<"); }); } setClimatisation(pTempC) { return new Promise((resolve, reject) => { this.log.debug("setClimatisation with " + pTempC + "°C >>"); if (!this.finishedReading()) { this.log.info( "Reading necessary data not finished yet. Please try again." ); reject(new Error("Data not ready")); return; } if (!this.vinArray.includes(this.currSession.vin)) { this.log.error( "Unknown VIN, aborting. Use setActiveVin to set a valid VIN." ); reject(new Error("Unknown VIN")); return; } if (pTempC < 16 || pTempC > 27) { this.log.info( "Invalid temperature, setting 20°C as default" ); pTempC = 20; } this.config.pendingRequests++; this.config.targetTempC = pTempC; this.setIdRemote(this.currSession.vin, "climatisation", "settings", "") .then(() => { this.log.debug("setClimatisation successful"); this.config.pendingRequests = Math.max( this.config.pendingRequests - 1, 0 ); resolve(); }) .catch((error) => { this.log.error("setClimatisation failed"); this.config.pendingRequests = 0; reject(error); }); this.log.debug("setClimatisation <<"); }); } // logLevel: ERROR, INFO, DEBUG setLogLevel(pLogLevel, pExternal = false) { this.log.setLogLevel(pLogLevel, pExternal); } enableApi(port) { const app = express(); app.get("/status", (req, res) => { if (typeof this.idData !== "undefined") { res.json(this.idData); } else { res.status(200).end(); } }); app.listen(port, () => { this.log.info("API server is running on port " + port); }); } async getData() { this.boolFinishIdData = false; this.boolFinishVehicles = false; if (this.config.interval === 0) { this.log.info("Interval of 0 is not allowed, reset to 1"); this.config.interval = 1; } const donePromise = new Promise((resolve) => { const finishedReadingInterval = setInterval(() => { if (this.finishedReading()) { clearInterval(finishedReadingInterval); resolve("done"); } }, 1000); }); this.type = "Id"; this.country = "DE"; this.clientId = "a24fba63-34b3-4d43-b181-942111e6bda8@apps_vw-dilab_com"; this.scope = "openid profile badge cars dealers birthdate vin"; this.redirect = "weconnect://authenticated"; this.xrequest = "com.volkswagen.weconnect"; this.responseType = "code id_token token"; try { await this.login(); this.log.info("Login successful"); await this.getPersonalData(); await this.getVehicles(); this.vinArray.forEach((vin) => { this.getIdStatus(vin).catch(() => { this.log.debug("get id status Failed"); }); this.getIdParkingPosition(vin).catch(() => { this.log.debug("get id parking position Failed"); }); }); this.updateInterval = setInterval(() => { this.updateStatus(); }, this.config.interval * 60 * 1000); } catch (err) { this.log.error("Login or initial data fetch failed"); } await donePromise; this.log.debug("getData END"); } _req(opts) { return new Promise((resolve, reject) => { request(opts, (err, resp, body) => { if (err) return reject(err); resolve({ resp, body }); }); }); } async _handleConsentPage(url, jar) { const adapter = this; const ua = this.userAgent || "Volkswagen/3.51.1-android/14"; adapter.log.debug("[_handleConsentPage] GET " + url); // GET the consent page let { resp, body } = await this._req({ method: "GET", url, jar, followRedirect: false, gzip: true, headers: { "User-Agent": ua, "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "en-US,en;q=0.9", "Accept-Encoding": "gzip, deflate" } }); const status = resp.statusCode; if (status === 500) { throw new Error("Temporary server error during consent flow"); } if (status !== 200) { throw new Error("Unexpected status code during consent flow: " + status); } // Reuse your existing extractHidden() helper const hiddenFields = this.extractHidden(body || ""); adapter.log.debug("[_handleConsentPage] Hidden fields: " + JSON.stringify(hiddenFields)); // Strip query from URL, like Python does let postUrl = url; try { const u = new URL(url); u.search = ""; postUrl = u.toString(); } catch (e) { // fallback: strip by hand const qIdx = url.indexOf("?"); if (qIdx >= 0) { postUrl = url.substring(0, qIdx); } } // Build x-www-form-urlencoded body from hidden fields const formBody = Object.keys(hiddenFields) .map(k => encodeURIComponent(k) + "=" + encodeURIComponent(hiddenFields[k])) .join("&"); adapter.log.debug("[_handleConsentPage] POST consent to " + postUrl); ({ resp, body } = await this._req({ method: "POST", url: postUrl, jar, followRedirect: false, gzip: true, headers: { "User-Agent": ua, "Accept": "*/*", "Content-Type": "application/x-www-form-urlencoded", "Accept-Language": "en-US,en;q=0.9", "Accept-Encoding": "gzip, deflate" }, body: formBody })); const postStatus = resp.statusCode; if (postStatus === 500) { throw new Error("Temporary server error during consent POST"); } if (!(postStatus === 302 || postStatus === 303)) { throw new Error("Forwarding expected (302/303) after consent POST, got " + postStatus); } const loc = resp.headers.location; if (!loc) { throw new Error("Forwarding without Location in consent response"); } const nextUrl = new URL(loc, postUrl).toString(); adapter.log.debug("[_handleConsentPage] Next URL after consent: " + nextUrl); return nextUrl; } async _handleNewAuthFlow(startUrl, jar) { const adapter = this; const ua = this.userAgent || "Volkswagen/3.51.1-android/14"; let url = startUrl; let resp, body; // === Initial fetch of authorization page (max 5 redirects) === let maxInitialRedirects = 5; while (maxInitialRedirects > 0) { // If we already got a custom scheme, just return it if (url.startsWith("weconnect://")) { adapter.log.debug("[_handleNewAuthFlow] Found custom scheme during initial fetch: " + url); return url; } adapter.log.debug("[_handleNewAuthFlow] GET " + url); ({ resp, body } = await this._req({ method: "GET", url, jar, followRedirect: false, gzip: true, headers: { "User-Agent": ua, "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "en-US,en;q=0.9", "Accept-Encoding": "gzip, deflate" } })); const status = resp.statusCode; if (status === 200) { break; } if (status === 302 || status === 303) { const loc = resp.headers.location; if (!loc) { throw new Error("Forwarding without Location in headers (initial auth fetch)"); } url = new URL(loc, url).toString(); maxInitialRedirects--; continue; } throw new Error("Failed to fetch authorization page, status=" + status); } if (maxInitialRedirects === 0) { throw new Error("Too many redirects while fetching authorization page"); } // === Extract state token from HTML === const stateMatch = body && body.match(/<input[^>]*name="state"[^>]*value="([^"]*)"/); const state = stateMatch && stateMatch[1]; if (!state) { throw new Error("Could not find state token in authorization page"); } // === POST username/password/state to /u/login?state=... === const loginUrl = "https://identity.vwgroup.io/u/login?state=" + encodeURIComponent(state); const loginFormBody = "username=" + encodeURIComponent(this.config.user) + "&password=" + encodeURIComponent(this.config.password) + "&state=" + encodeURIComponent(state); adapter.log.debug("[_handleNewAuthFlow] POST credentials to " + loginUrl); ({ resp, body } = await this._req({ method: "POST", url: loginUrl, jar, followRedirect: false, gzip: true, headers: { "User-Agent": ua, "Accept": "*/*", "Content-Type": "application/x-www-form-urlencoded", "Accept-Language": "en-US,en;q=0.9", "Accept-Encoding": "gzip, deflate" }, body: loginFormBody })); const statusLogin = resp.statusCode; if (!(statusLogin === 302 || statusLogin === 303)) { throw new Error("Login failed with status code: " + statusLogin); } if (!resp.headers.location) { throw new Error("No Location header in login response"); } let redirectUrl = resp.headers.location; adapter.log.debug("[_handleNewAuthFlow] After-login redirect: " + redirectUrl); // === Follow redirects until weconnect://authenticated... (max 10 redirects) === let maxDepth = 10; while (maxDepth > 0) { adapter.log.debug("[_handleNewAuthFlow] Redirect loop depth=" + maxDepth + " url=" + redirectUrl); // Final callbacks if (redirectUrl.startsWith("weconnect://authenticated")) { adapter.log.debug("[_handleNewAuthFlow] Reached OAuth callback URL"); return redirectUrl; } if (redirectUrl.startsWith("weconnect://")) { adapter.log.info("[_handleNewAuthFlow] Found custom scheme URL: " + redirectUrl); return redirectUrl; } // Handle consent / terms-and-conditions pages (like Python) if (redirectUrl.indexOf("terms-and-conditions") !== -1) { adapter.log.info("[_handleNewAuthFlow] Detected terms-and-conditions page at: " + redirectUrl); redirectUrl = await this._handleConsentPage( redirectUrl.startsWith("http") ? redirectUrl : "https://identity.vwgroup.io" + redirectUrl, jar ); // After consent, continue the redirect loop with the new URL maxDepth--; continue; } const absUrl = redirectUrl.startsWith("http") ? redirectUrl : "https://identity.vwgroup.io" + redirectUrl; adapter.log.debug("[_handleNewAuthFlow] GET " + absUrl); ({ resp, body } = await this._req({ method: "GET", url: absUrl, jar, followRedirect: false, gzip: true, headers: { "User-Agent": ua, "Accept": "*/*", "Accept-Language": "en-US,en;q=0.9", "Accept-Encoding": "gzip, deflate" } })); const status = resp.statusCode; if (status === 500) { throw new Error("Temporary server error during new auth flow"); } if (!resp.headers.location) { // Might be a final page, check again if it contains a custom scheme if (absUrl.startsWith("weconnect://")) { adapter.log.info("[_handleNewAuthFlow] Reached custom scheme without Location"); return absUrl; } throw new Error("No Location header in redirect (new auth flow), status=" + status); } redirectUrl = resp.headers.location; adapter.log.debug("[_handleNewAuthFlow] Next redirect: " + redirectUrl); // Check again for final callbacks before next iteration if (redirectUrl.startsWith("weconnect://authenticated")) { adapter.log.debug("[_handleNewAuthFlow] Reached OAuth callback URL after redirect"); return redirectUrl; } if (redirectUrl.startsWith("weconnect://")) { adapter.log.info("[_handleNewAuthFlow] Found custom scheme URL after redirect: " + redirectUrl); return redirectUrl; } maxDepth--; } throw new Error("Too many redirects in new auth flow"); } async login() { this.config.disableRefresh = false; const jar = request.jar(); this._jar = jar; const adapter = this; // ============================= // STEP 0 — PKCE GENERATION // ============================= const crypto = require("crypto"); // Random 64-char verifier const codeVerifier = [...Array(64)] .map(() => Math.random().toString(36)[2]) .join(""); this.codeVerifier = codeVerifier; // Base64URL(SHA256(verifier)) const codeChallenge = crypto .createHash("sha256") .update(codeVerifier) .digest("base64") .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=+$/, ""); // ============================= // STEP 1 — BFF AUTHORIZE // ============================= const nonce = Math.random().toString(36).slice(2); const authorizeInit = "https://emea.bff.cariad.digital/user-login/v1/authorize" + "?nonce=" + encodeURIComponent(nonce) + "&redirect_uri=" + encodeURIComponent("weconnect://authenticated") + "&code_challenge=" + encodeURIComponent(codeChallenge) + "&code_challenge_method=S256"; this.log.debug("Step 1: GET authorize -> " + authorizeInit); let { resp } = await this._req({ method: "GET", url: authorizeInit, jar, followRedirect: false, gzip: true, headers: { "User-Agent": this.userAgent } }); if (!resp.headers.location) { throw new Error("Missing Location header from BFF authorize"); } const firstRedirect = resp.headers.location; this.log.debug("Step 2: First redirect -> " + firstRedirect); // ============================= // STEP 2 — FULL NEW AUTH FLOW // ============================= const callbackUrl = await this._handleNewAuthFlow(firstRedirect, jar); if (!callbackUrl || !callbackUrl.startsWith("weconnect://")) { this.log.error("Unexpected callback URL: " + callbackUrl); throw new Error("Unexpected callback URL"); } this.log.debug("Step 3: Final redirect reached -> extracting tokens"); // ============================= // STEP 3 — TRANSFORM CUSTOM SCHEME // ============================= function makeHttpFromCustomScheme(cbUrl) { const hashIdx = cbUrl.indexOf("#"); const qIdx = cbUrl.indexOf("?"); let qs = ""; if (hashIdx >= 0) qs = cbUrl.substring(hashIdx + 1); else if (qIdx >= 0) qs = cbUrl.substring(qIdx + 1); else return null; return "https://egal?" + qs; } const httpUrl = makeHttpFromCustomScheme(callbackUrl); if (!httpUrl) { adapter.log.error("Callback URL has no query or fragment: " + callbackUrl); throw new Error("Callback URL has no parameters"); } this.log.debug("Transformed callback URL -> " + httpUrl); let urlObj; try { urlObj = new URL(httpUrl); } catch (e) { this.log.error("Failed to parse transformed callback URL: " + httpUrl); throw e; } const params = urlObj.searchParams; const code = params.get("code"); const id_token = params.get("id_token"); const access_token = params.get("access_token"); const state = params.get("state"); if (!code || !id_token || !access_token || !state) { this.log.error("Missing tokens from callback"); this.log.error("Original: " + callbackUrl); this.log.error("Transformed: " + httpUrl); throw new Error("Missing required tokens from callback"); } // ============================= // STEP 4 — BFF TOKEN EXCHANGE // ============================= this.log.debug("Step 4: Token exchange via Cariad BFF"); const loginBody = JSON.stringify({ state, id_token, redirect_uri: "weconnect://authenticated", region: "emea", access_token, authorizationCode: code, code_verifier: this.codeVerifier }); const headers = { "accept": "application/json", "content-type": "application/json", "x-newrelic-id": "VgAEWV9QDRAEXFlRAAYPUA==", "user-agent": this.userAgent, "accept-language": "de-de", "cache-control": "no-cache", "pragma": "no-cache", "x-android-package-name": "com.volkswagen.weconnect" }; const { body: tokenResp } = await this._req({ method: "POST", url: "https://emea.bff.cariad.digital/user-login/login/v1", jar, followRedirect: false, gzip: true, headers, body: loginBody }); let tokenData; try { tokenData = JSON.parse(tokenResp); } catch (e) { this.log.error("Token JSON parse failed"); this.log.error(tokenResp); throw e; } const accessToken = tokenData.accessToken || tokenData.access_token; const refreshToken = tokenData.refreshToken || tokenData.refresh_token; const idToken = tokenData.idToken || tokenData.id_token; if (!accessToken || !refreshToken || !idToken) { this.log.error("Token response incomplete"); this.log.error(tokenResp); throw new Error("Token response incomplete"); } // ============================= // STEP 5 — STORE TOKENS // ============================= this._accessToken = accessToken; this._refreshToken = refreshToken; this._idToken = idToken; this.config.atoken = accessToken; this.config.rtoken = refreshToken; this.config.idtoken = idToken; this.log.debug("login successful"); return true; } /** * Zorgt dat er nooit meer dan één login tegelijk draait. * Andere callers wachten op dezelfde promise. */ async _loginOnceGuarded(reason) { if (this.isLoggingIn && this.loginPromise) { this.log.debug("[LoginGuard] Waiting for ongoing login (" + reason + ")"); return this.loginPromise; } this.isLoggingIn = true; this.loginPromise = (async () => { try { this.log.debug("[LoginGuard] Starting full login (" + reason + ")"); await this.login(); this.log.debug("(Re-)login completed (" + reason + ")"); } catch (e) { this.log.error("[LoginGuard] Login failed (" + reason + "): " + e); throw e; } finally { this.isLoggingIn = false; this.loginPromise = null; } })(); return this.loginPromise; } updateStatus() { this.vinArray.forEach((vin) => { if (vin === this.currSession.vin) { this.getIdStatus(vin).catch((err) => { this.log.debug("get id status Failed: " + err); }); this.getIdParkingPosition(vin).catch((err) => { this.log.debug("get id parking position Failed: " + err); }); } }); } getPersonalData() { return new Promise((resolve) => { this.log.debug("START getPersonalData()"); resolve(); }); } getVehicles() { return new Promise((resolve, reject) => { this.log.debug("START getVehicles"); const url = "https://emea.bff.cariad.digital/vehicle/v1/vehicles"; const headers = { accept: "*/*", "content-type": "application/json", "content-version": "1", "x-newrelic-id": "VgAEWV9QDRAEXFlRAAYPUA==", "user-agent": this.userAgent, "accept-language": "de-de", authorization: "Bearer " + this.config.atoken }; request.get( { url, headers, followAllRedirects: true, gzip: true, json: true }, (err, resp, body) => { if (err || (resp && resp.statusCode >= 400)) { err && this.log.error(err); resp && this.log.error(resp.statusCode); reject(err || new Error("Vehicle request failed")); return; } try { if (body.errorCode) { this.log.error(JSON.stringify(body)); reject(new Error("Vehicle response error")); return; } this.log.debug("getVehicles: " + JSON.stringify(body)); this.vehicles = body; this.boolFinishVehicles = true; const list = Array.isArray(body.data) ? body.data : []; if (list.length > 0) { list.forEach((element) => { const vin = element.vin; if (!vin) { this.log.info( "No vin found for:" + JSON.stringify(element) ); return; } if (!this.vinArray.includes(vin)) { this.vinArray.push(vin); } }); } else { this.log.error("No data in server response"); } resolve(); } catch (e) { this.log.error(e); this.log.error(e.stack); this.log.error( "Not able to find vehicle, did you choose the correct type?" ); reject(e); } } ); }); } async checkSafeFlag(timeout) { return new Promise((resolve, reject) => { let counter = 0; const checkInterval = setInterval(() => { if ( this.idData && this.idData.access && this.idData.access.accessStatus && this.idData.access.accessStatus.value && this.idData.access.accessStatus.value.overallStatus === "safe" ) { clearInterval(checkInterval); this.config.unSafe = false; module.exports.idStatusEmitter.emit( "statusNotSafe", false ); reject("safe"); return; } if (counter++ >= timeout / 2) { clearInterval(checkInterval); this.config.unSafe = true; module.exports.idStatusEmitter.emit( "statusNotSafe", true ); resolve(); return; } }, 30 * 1000); }).catch((err) => { this.log.debug(err); }); } async checkPlugAndPower(timeout) { return new Promise((resolve, reject) => { let counter = 0; const checkInterval = setInterval(() => { const charging = this.idData && this.idData.charging; const plugStatus = charging && charging.plugStatus && charging.plugStatus.value; if ( plugStatus && (plugStatus.externalPower === "ready" || plugStatus.externalPower === "active") ) { clearInterval(checkInterval); this.config.noExternalPower = false; module.exports.idStatusEmitter.emit( "noExternalPower", false ); reject("powerReady"); return; } if (counter++ >= timeout / 2) { clearInterval(checkInterval); this.config.noExternalPower = true; module.exports.idStatusEmitter.emit( "noExternalPower", true ); resolve(); return; } }, 30 * 1000); }).catch((err) => { this.log.debug(err); }); } async logToDb() { if (!this.currSession || !this.currSession.vin) { throw new Error("VIN missing"); } if (!this.config.db) { return null; } try { const odoRaw = this.idData?.measurements?.odometerStatus?.value?.odometer; const socRaw = this.idData?.charging?.batteryStatus?.value?.currentSOC_pct; const rngRaw = this.idData?.charging?.batteryStatus?.value ?.cruisingRangeElectric_km; const latRaw = this.idParkingPosition?.data?.lat; const lonRaw = this.idParkingPosition?.data?.lon; const toNum = (v) => { const n = Number(v); return Number.isFinite(n) ? n : null; }; const sql = "INSERT INTO travel (vin, odo, soc, rng, lat, lon) VALUES (?, ?, ?, ?, ?, ?)"; const params = [ this.currSession.vin || null, toNum(odoRaw), toNum(socRaw), toNum(rngRaw), toNum(latRaw), toNum(lonRaw) ]; const [result] = await this.config.db .promise() .execute(sql, params); return (result && result.insertId) || null; } catch (err) { console.error("DB error:", err); return null; } } async runEventEmitters() { module.exports.idStatusEmitter.emit("eventRunStarted"); if (typeof this.idDataOld === "undefined") { return; } try { // parking if ( this.idData.parking.data.carIsParked !== this.idDataOld.parking.data.carIsParked ) { if (this.config.db) { await this.logToDb(); } if (this.idData.parking.data.carIsParked) { module.exports.idStatusEmitter.emit( "positionUpdate", this.idData.parking.data ); } else { module.exports.idStatusEmitter.emit("positionUnknown"); } module.exports.idStatusEmitter.emit( "parked", this.idData.parking.data.carIsParked ); module.exports.idStatusEmitter.emit( "notParked", !this.idData.parking.data.carIsParked ); } if ( this.idData.parking.data.carIsParked && this.idData.access.accessStatus.value.overallStatus !== "safe" && (!this.idDataOld.parking.data.carIsParked || this.idDataOld.access.accessStatus.value .overallStatus === "safe") ) { this.checkSafeFlag(this.config.checkSafeStatusTimeout); } if ( this.idData.access.accessStatus.value.overallStatus === "safe" && this.idDataOld.access.accessStatus.value.overallStatus !== "safe" ) { module.exports.idStatusEmitter.emit( "statusNotSafe", false ); this.config.unSafe = false; } if ( this.idData.access.accessStatus.value.doorLockStatus !== "locked" && this.idDataOld.access.accessStatus.value.doorLockStatus === "locked" ) { module.exports.idStatusEmitter.emit("carLocked", false); } if ( this.idData.access.accessStatus.value.doorLockStatus === "locked" && this.idDataOld.access.accessStatus.value.doorLockStatus !== "locked" ) { module.exports.idStatusEmitter.emit("carLocked", true); } // charging if ( (this.idData.charging.chargingStatus.value.chargingState.includes( "chargePurposeReached" ) || (this.idData.charging.chargingStatus.value .chargingState !== "charging" && this.idData.charging.chargingSettings.value .targetSOC_pct === this.idData.charging.batteryStatus.value .currentSOC_pct)) && this.idDataOld.charging.chargingStatus.value .chargingState === "charging" ) { module.exports.idStatusEmitter.emit("chargePurposeReached"); } if ( this.idData.charging.chargingStatus.value .chargingState === "charging" && this.idDataOld.charging.chargingStatus.value .chargingState !== "charging" ) { module.exports.idStatusEmitter.emit("chargingStarted"); } if ( this.idData.charging.chargingStatus.value .chargingState !== "charging" && this.idDataOld.charging.chargingStatus.value .chargingState === "charging" ) { module.exports.idStatusEmitter.emit("chargingStopped"); } if ( this.idData.charging.batteryStatus.value.currentSOC_pct !== this.idDataOld.charging.batteryStatus.value.currentSOC_pct ) { module.exports.idStatusEmitter.emit( "currentSOC", this.idData.charging.batteryStatus.value .currentSOC_pct ); } if ( this.idData.charging.batteryStatus.value .cr