├── src ├── CarBrand.ts ├── LogLevel.ts ├── Regions.ts ├── ITokenStore.ts ├── ChargingStatus.ts ├── ConsoleLogger.ts ├── CarView.ts ├── LocalTokenStore.ts ├── ILogger.ts ├── RemoteServiceExecutionState.ts ├── Token.ts ├── RemoteServicesConstants.ts ├── RemoteServiceExecutionStateDetailed.ts ├── LoggerBase.ts ├── FileTokenStore.ts ├── DetailedServiceStatus.ts ├── Utils.ts ├── Constants.ts ├── index.ts ├── Account.ts ├── ConnectedDrive.ts └── Models.ts ├── .vscode ├── settings.json └── launch.json ├── tsconfig.json ├── .github └── workflows │ ├── npm-publish.yml │ └── codeql-analysis.yml ├── .gitignore ├── api-extractor.json ├── package.json └── readme.md /src/CarBrand.ts: -------------------------------------------------------------------------------- 1 | export enum CarBrand { 2 | Bmw = "BMW", 3 | Mini = "MINI" 4 | } -------------------------------------------------------------------------------- /src/LogLevel.ts: -------------------------------------------------------------------------------- 1 | export enum LogLevel { 2 | Trace, 3 | Debug, 4 | Information, 5 | Warning, 6 | Error, 7 | Critical 8 | } -------------------------------------------------------------------------------- /src/Regions.ts: -------------------------------------------------------------------------------- 1 | export enum Regions { 2 | NorthAmerica = "NorthAmerica", 3 | RestOfWorld = "RestOfWorld", 4 | China = "China" 5 | } -------------------------------------------------------------------------------- /src/ITokenStore.ts: -------------------------------------------------------------------------------- 1 | import { Token } from "./Token"; 2 | 3 | export interface ITokenStore { 4 | storeToken(token : Token) : void; 5 | retrieveToken() : Token | undefined; 6 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "bmwconnected", 4 | "bmwconnecteddrive", 5 | "connecteddrive", 6 | "Likhan", 7 | "Siddiquee" 8 | ] 9 | } -------------------------------------------------------------------------------- /src/ChargingStatus.ts: -------------------------------------------------------------------------------- 1 | export enum ChargingStatus { 2 | NOCHARGING = "NOCHARGING", 3 | CHARGINGPAUSED = "CHARGINGPAUSED", 4 | CHARGINGERROR = "CHARGINGERROR", 5 | CHARGINGACTIVE= "CHARGINGACTIVE", 6 | CHARGINGENDED = "CHARGINGENDED", 7 | UNKNOWN = "UNKNOWN" 8 | } -------------------------------------------------------------------------------- /src/ConsoleLogger.ts: -------------------------------------------------------------------------------- 1 | import {LogLevel} from "./LogLevel"; 2 | import {LoggerBase} from "./LoggerBase"; 3 | 4 | export class ConsoleLogger extends LoggerBase { 5 | Log(level: LogLevel, message: string): void { 6 | console.log(`${LogLevel[level]}: ${message}`); 7 | } 8 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "semicolon": "always", 3 | "extends": "@tsconfig/node12/tsconfig.json", 4 | "compilerOptions": { 5 | "declaration": true, 6 | "outDir": "dist", 7 | "sourceMap": true 8 | }, 9 | "include": ["src"], 10 | "exclude": ["node_modules", "dist"] 11 | } -------------------------------------------------------------------------------- /src/CarView.ts: -------------------------------------------------------------------------------- 1 | export enum CarView { 2 | Dashboard = "DASHBOARD", 3 | DriverDoor = "DRIVERDOOR", 4 | FrontLeft = "FRONTLEFT", 5 | FrontRight = "FRONTRIGHT", 6 | FrontView = "FRONTVIEW", 7 | RearLeft = "REARLEFT", 8 | RearRight = "REARRIGHT", 9 | RearView = "REARVIEW", 10 | SideViewLeft = "SIDEVIEWLEFT" 11 | } -------------------------------------------------------------------------------- /src/LocalTokenStore.ts: -------------------------------------------------------------------------------- 1 | import { ITokenStore } from "./ITokenStore"; 2 | import { Token } from "./Token"; 3 | 4 | export class LocalTokenStore implements ITokenStore { 5 | private token: Token | undefined; 6 | 7 | storeToken(token: Token): void { 8 | this.token = token; 9 | } 10 | retrieveToken(): Token | undefined { 11 | return this.token; 12 | } 13 | } -------------------------------------------------------------------------------- /src/ILogger.ts: -------------------------------------------------------------------------------- 1 | import { LogLevel } from "./LogLevel"; 2 | 3 | export interface ILogger { 4 | Log(level: LogLevel, message: string): void; 5 | 6 | LogTrace(message: string): void; 7 | 8 | LogDebug(message: string): void; 9 | 10 | LogInformation(message: string): void; 11 | 12 | LogWarning(message: string): void; 13 | 14 | LogError(message: string): void; 15 | 16 | LogCritical(message: string): void; 17 | } -------------------------------------------------------------------------------- /src/RemoteServiceExecutionState.ts: -------------------------------------------------------------------------------- 1 | export enum RemoteServiceExecutionState { 2 | INITIATED = "INITIATED", 3 | DELIVERED = "DELIVERED", 4 | STARTED = "STARTED", 5 | PENDING = "PENDING", 6 | RUNNING = "RUNNING", 7 | PROV_RUNNING = "PROV_RUNNING", 8 | EXECUTED = "EXECUTED", 9 | CANCELLED_WITH_ERROR = "CANCELLED_WITH_ERROR", 10 | ERROR = "ERROR", 11 | IGNORED = "IGNORED", 12 | UNKNOWN = "UNKNOWN" 13 | } -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | name: Node.js Package 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | publish-npm: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: actions/setup-node@v4 13 | with: 14 | node-version: 20 15 | registry-url: https://registry.npmjs.org/ 16 | - run: npm install 17 | - run: npm publish 18 | env: 19 | NODE_AUTH_TOKEN: ${{secrets.NPM_PAT}} -------------------------------------------------------------------------------- /src/Token.ts: -------------------------------------------------------------------------------- 1 | export class Token { 2 | response: string; 3 | accessToken: string; 4 | refreshToken: string; 5 | validUntil: Date; 6 | 7 | constructor({ response, accessToken, refreshToken, validUntil } 8 | : { response: string, accessToken: string, refreshToken: string, validUntil: Date }) { 9 | this.response = response; 10 | this.accessToken = accessToken; 11 | this.refreshToken = refreshToken; 12 | this.validUntil = validUntil; 13 | } 14 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug Current TypeScript File", 6 | "type": "node", 7 | "request": "launch", 8 | "runtimeExecutable": "npx", 9 | "runtimeArgs": ["ts-node"], 10 | "args": ["${file}"], 11 | "cwd": "${workspaceFolder}", 12 | "console": "integratedTerminal", 13 | "skipFiles": [ 14 | "/**" 15 | ], 16 | "env": { 17 | "NODE_ENV": "development" 18 | } 19 | } 20 | ] 21 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vs 2 | 3 | lib-cov 4 | *.seed 5 | *.log 6 | *.csv 7 | *.dat 8 | *.out 9 | *.pid 10 | *.gz 11 | *.swp 12 | 13 | pids 14 | logs 15 | results 16 | tmp 17 | 18 | # Build 19 | public/css/main.css 20 | 21 | # Coverage reports 22 | coverage 23 | 24 | # API keys and secrets 25 | .env 26 | 27 | # Dependency directory 28 | node_modules 29 | bower_components 30 | 31 | # Editors 32 | .idea 33 | *.iml 34 | 35 | # OS metadata 36 | .DS_Store 37 | Thumbs.db 38 | 39 | # Ignore built ts files 40 | dist/**/* 41 | 42 | # ignore yarn.lock 43 | yarn.lock 44 | 45 | build 46 | access_token 47 | sample_responses 48 | 49 | src/local_test.ts 50 | data/**/* -------------------------------------------------------------------------------- /src/RemoteServicesConstants.ts: -------------------------------------------------------------------------------- 1 | export enum RemoteServicesConstants { 2 | /** climate */ 3 | ClimateNow = "climate-now", 4 | /** lock */ 5 | LockDoors = "door-lock", 6 | /** unlock */ 7 | UnlockDoors = "door-unlock", 8 | /** horn */ 9 | BlowHorn = "horn-blow", 10 | /** light */ 11 | FlashLight = "light-flash", 12 | /** start charging */ 13 | ChargeStart = "start-charging", 14 | /** stop charging */ 15 | ChargeStop = "stop-charging", 16 | /** set charging settings **/ 17 | SetChargingSettings = "set-charging-settings", 18 | /** set charging profile **/ 19 | SetChargingProfile = "set-charging-profile" 20 | } -------------------------------------------------------------------------------- /src/RemoteServiceExecutionStateDetailed.ts: -------------------------------------------------------------------------------- 1 | export enum RemoteServiceExecutionStateDetailed { 2 | UNKNOWN = "UNKNOWN", 3 | OTHER_SERVICE_WITH_PROVISIONING_RUNNING = "OTHER_SERVICE_WITH_PROVISIONING_RUNNING", 4 | PROVISIONING_STARTED = "PROVISIONING_STARTED", 5 | SMS_DELIVERED_TO_GATEWAY = "SMS_DELIVERED_TO_GATEWAY", 6 | PROVISIONING_FINISHED = "PROVISIONING_FINISHED", 7 | SMS_DELIVERED_TO_VEHICLE = "SMS_DELIVERED_TO_VEHICLE", 8 | DLQ_MESSAGE_PROVIDED = "DLQ_MESSAGE_PROVIDED", 9 | DLQ_MESSAGE_FETCHED = "DLQ_MESSAGE_FETCHED", 10 | UPLINK_MESSAGE_ACK = "UPLINK_MESSAGE_ACK", 11 | DEPROVISIONING_STARTED = "DEPROVISIONING_STARTED", 12 | DEPROVISIONING_FINISHED = "DEPROVISIONING_FINISHED" 13 | } -------------------------------------------------------------------------------- /src/LoggerBase.ts: -------------------------------------------------------------------------------- 1 | import { ILogger } from "./ILogger"; 2 | import { LogLevel } from "./LogLevel"; 3 | 4 | export abstract class LoggerBase implements ILogger { 5 | abstract Log(level: LogLevel, message: string): void; 6 | 7 | LogTrace(message: string) { 8 | this.Log(LogLevel.Trace, message); 9 | } 10 | 11 | LogDebug(message: string) { 12 | this.Log(LogLevel.Debug, message); 13 | } 14 | 15 | LogInformation(message: string) { 16 | this.Log(LogLevel.Information, message); 17 | } 18 | 19 | LogWarning(message: string) { 20 | this.Log(LogLevel.Warning, message); 21 | } 22 | 23 | LogError(message: string) { 24 | this.Log(LogLevel.Error, message); 25 | } 26 | 27 | LogCritical(message: string) { 28 | this.Log(LogLevel.Critical, message); 29 | } 30 | } -------------------------------------------------------------------------------- /src/FileTokenStore.ts: -------------------------------------------------------------------------------- 1 | import { ITokenStore } from "./ITokenStore"; 2 | import { Token } from "./Token"; 3 | import { readFileSync, writeFileSync, existsSync } from 'fs'; 4 | 5 | // TODO: Cleanup to ensure that we are not using NodeJS only features. 6 | export class FileTokenStore implements ITokenStore { 7 | readonly fileName: string = "access_token"; 8 | 9 | storeToken(token: Token): void { 10 | writeFileSync(this.fileName, JSON.stringify(token), 'utf8'); 11 | } 12 | retrieveToken(): Token | undefined { 13 | if (existsSync(this.fileName)) { 14 | const fileContent: string = readFileSync(this.fileName, 'utf8'); 15 | 16 | const token = JSON.parse(fileContent) as Token; 17 | if (token) { 18 | token.validUntil = new Date(token.validUntil); 19 | } 20 | 21 | return token; 22 | } 23 | 24 | return undefined; 25 | } 26 | } -------------------------------------------------------------------------------- /api-extractor.json: -------------------------------------------------------------------------------- 1 | { 2 | "projectFolder": ".", 3 | "mainEntryPointFilePath": "/build/index.d.ts", 4 | "bundledPackages": [], 5 | "compiler": { 6 | "tsconfigFilePath": "/tsconfig.json", 7 | "overrideTsconfig": { 8 | "compilerOptions": { 9 | "outDir": "build" 10 | } 11 | } 12 | }, 13 | "dtsRollup": { 14 | "enabled": true, 15 | "untrimmedFilePath": "/dist/index.d.ts" 16 | }, 17 | "apiReport": { 18 | "enabled": false 19 | }, 20 | "docModel": { 21 | "enabled": false 22 | }, 23 | "tsdocMetadata": { 24 | "enabled": false 25 | }, 26 | "messages": { 27 | "compilerMessageReporting": { 28 | "default": { 29 | "logLevel": "none" 30 | } 31 | }, 32 | "extractorMessageReporting": { 33 | "default": { 34 | "logLevel": "none" 35 | } 36 | }, 37 | "tsdocMessageReporting": { 38 | "default": { 39 | "logLevel": "none" 40 | } 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /src/DetailedServiceStatus.ts: -------------------------------------------------------------------------------- 1 | import { RemoteServiceExecutionState } from "./RemoteServiceExecutionState"; 2 | import { RemoteServiceExecutionStateDetailed } from "./RemoteServiceExecutionStateDetailed"; 3 | 4 | export class DetailedServiceStatus { 5 | constructor(response : { 6 | "rsStatus": RemoteServiceExecutionState; 7 | "rsDetailedStatus": RemoteServiceExecutionStateDetailed; 8 | "initiationError": "NO_ERROR" | string; 9 | "rsError": "NO_ERROR" | string; 10 | "creationTime": string; 11 | "initStatus": true; 12 | }) { 13 | this.status = response.rsStatus; 14 | this.detailedStatus = response.rsDetailedStatus; 15 | this.initiationError = response.initiationError; 16 | this.error = response.rsError; 17 | this.creationTime = new Date(response.creationTime); 18 | this.initStatus = response.initStatus; 19 | } 20 | 21 | status! : RemoteServiceExecutionState; 22 | detailedStatus!: RemoteServiceExecutionStateDetailed; 23 | initiationError!: string; 24 | error!: string; 25 | creationTime! : Date; 26 | initStatus!: boolean; 27 | } -------------------------------------------------------------------------------- /src/Utils.ts: -------------------------------------------------------------------------------- 1 | import { ILogger } from "./ILogger"; 2 | import * as os from 'os'; 3 | import * as crypto from 'crypto'; 4 | 5 | export class Utils { 6 | public static async Delay(ms: number, logger?: ILogger): Promise { 7 | logger?.LogTrace("Sleeping for retry.") 8 | await new Promise(resolve => setTimeout(resolve, ms)); 9 | return true; 10 | } 11 | 12 | /** 13 | * Gets a deterministic build string based on the machine's hostname. 14 | * This will be consistent for the same machine across reboots. 15 | */ 16 | public static getXUserAgentBuildString(): string { 17 | const hostname = os.hostname(); 18 | 19 | // Create a deterministic hash from hostname 20 | const hash = crypto.createHash('sha256').update(hostname).digest('hex').toUpperCase(); 21 | 22 | // Extract digits from hash 23 | const digits = hash.replace(/[^0-9]/g, ''); 24 | 25 | // Get 6-digit numeric component 26 | let numeric = digits.slice(0, 6) || '000000'; 27 | numeric = numeric.padEnd(6, '0'); 28 | 29 | // Get 3-digit build number 30 | let buildNum = digits.slice(-3) || '000'; 31 | buildNum = buildNum.padStart(3, '0'); 32 | 33 | return `AP2A.${numeric}.${buildNum}`; 34 | } 35 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bmw-connected-drive", 3 | "version": "2.4.1-beta.0", 4 | "description": "This package can be used to access the BMW ConnectedDrive services.", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "files": [ 8 | "dist" 9 | ], 10 | "scripts": { 11 | "build": "tsc && npm run build:types", 12 | "prepare": "npm run build", 13 | "build:types": "tsc -p ./tsconfig.json --outDir build --declaration true && api-extractor run" 14 | }, 15 | "devDependencies": { 16 | "@microsoft/api-extractor": "^7.32.0", 17 | "@tsconfig/node12": "^1.0.9", 18 | "@types/node": "^16.0.0", 19 | "@types/uuid": "^8.3.4", 20 | "typescript": "^4.8.4" 21 | }, 22 | "dependencies": { 23 | "cross-fetch": "^3.1.5", 24 | "fetch-cookie": "^1.0.1", 25 | "uuid": "^8.3.2" 26 | }, 27 | "author": { 28 | "name": "Likhan Siddiquee", 29 | "email": "likhan.siddiquee@gmail.com" 30 | }, 31 | "license": "Apache-2.0", 32 | "repository": { 33 | "type": "git", 34 | "url": "git+https://github.com/lsiddiquee/bmw-connected-drive.git" 35 | }, 36 | "bugs": { 37 | "url": "https://github.com/lsiddiquee/bmw-connected-drive/issues" 38 | }, 39 | "homepage": "https://github.com/lsiddiquee/bmw-connected-drive#readme", 40 | "keywords": [ 41 | "bmwconnected", 42 | "bmw connected", 43 | "bmwconnecteddrive", 44 | "bmw connected drive", 45 | "connected drive", 46 | "connecteddrive", 47 | "bmw" 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # BMW Connected Drive 2 | 3 | This package can be used to access the BMW ConnectedDrive services. 4 | 5 | ## Installation 6 | 7 | ```bash 8 | npm i -S bmw-connected-drive 9 | ``` 10 | 11 | ## Usage 12 | 13 | ```javascript 14 | import { ConnectedDrive, CarBrand, Regions } from 'bmw-connected-drive'; 15 | 16 | // Setup the API client 17 | // captchaToken is required for first authentication you can get token from here: https://bimmer-connected.readthedocs.io/en/stable/captcha.html 18 | const api = new ConnectedDrive(username, password, Regions.RestOfWorld, captchaToken); 19 | 20 | // Fetch a list of vehicles associated with the credentials 21 | const vehicles = await api.getVehicles(); 22 | 23 | // Trigger the Remote Service for remotely heating/cooling the car. 24 | await api.startClimateControl(vehicleIdentificationNumber, CarBrand.Bmw); 25 | 26 | // Trigger the stopping Remote Service for remotely heating/cooling the car. 27 | await api.stopClimateControl(vehicleIdentificationNumber, CarBrand.Bmw); 28 | 29 | ``` 30 | 31 | ## Disclaimer 32 | 33 | This library is not an official integration from BMW Connected Drive. This library is neither endorsed nor supported by BMW Connected Drive. This implementation is based on reverse engineering REST calls used by the BMW Connected Drive web app, and may thus intermittently stop working if the underlying API is updated. 34 | 35 | Any utilization, consumption and application of this library is done at the user's own discretion. This library, its maintainers and BMW Connected Drive cannot guarantee the integrity of this library or any applications of this library. 36 | 37 | ## Acknowledgements 38 | 39 | Inspired by 40 | -------------------------------------------------------------------------------- /src/Constants.ts: -------------------------------------------------------------------------------- 1 | import { CarBrand } from "./CarBrand"; 2 | import { Regions } from "./Regions" 3 | 4 | type EndpointMap = { [P in Regions]: string } 5 | 6 | export class Constants { 7 | static readonly ServerEndpoints: EndpointMap = { 8 | NorthAmerica: "cocoapi.bmwgroup.us", 9 | RestOfWorld: "cocoapi.bmwgroup.com", 10 | China: "myprofile.bmw.com.cn" 11 | } 12 | 13 | static readonly ApimSubscriptionKey: EndpointMap = { 14 | NorthAmerica: "31e102f5-6f7e-7ef3-9044-ddce63891362", 15 | RestOfWorld: "4f1c85a3-758f-a37d-bbb6-f8704494acfa", 16 | China: "blF2NkNxdHhKdVhXUDc0eGYzQ0p3VUVQOjF6REh4NnVuNGNEanliTEVOTjNreWZ1bVgya0VZaWdXUGNRcGR2RFJwSUJrN3JPSg==" 17 | } 18 | 19 | static readonly AppVersions: EndpointMap = { 20 | NorthAmerica: "4.9.2(36892)", 21 | RestOfWorld: "4.9.2(36892)", 22 | China: "4.9.2(36892)", 23 | } 24 | 25 | static readonly RegionCodes: EndpointMap = { 26 | NorthAmerica: "na", 27 | RestOfWorld: "row", 28 | China: "cn", 29 | } 30 | 31 | static readonly User_Agent: string = "Dart/3.3 (dart:io)"; 32 | static X_User_Agent = (build_string: string, region: Regions, brand: CarBrand = CarBrand.Bmw) => `android(${build_string});${brand.toLowerCase()};${this.AppVersions[region]};${this.RegionCodes[region]}`; 33 | 34 | static readonly getVehicles: string = "/eadrax-vcs/v4/vehicles"; 35 | static readonly remoteServicesBaseUrl: string = "/eadrax-vrccs/v3/presentation/remote-commands"; 36 | static readonly executeRemoteServices: string = Constants.remoteServicesBaseUrl + "/{vehicleVin}/{serviceType}"; 37 | static readonly statusRemoteServices: string = Constants.remoteServicesBaseUrl + "/eventStatus?eventId={eventId}"; 38 | static readonly statusRemoteServicePostion: string = Constants.remoteServicesBaseUrl + "/eventPosition?eventId={eventId}"; 39 | static readonly getImages: string = "/eadrax-ics/v5/presentation/vehicles/images?carView={carView}&toCrop=true"; 40 | 41 | static readonly vehicleChargingDetailsUrl = "/eadrax-crccs/v2/vehicles"; 42 | static readonly vehicleChargingBaseUrl = "/eadrax-crccs/v1/vehicles/{vehicleVin}"; 43 | static readonly vehicleChargingSettingsSetUrl = Constants.vehicleChargingBaseUrl + "/charging-settings"; 44 | static readonly vehicleChargingProfileSetUrl = Constants.vehicleChargingBaseUrl + "/charging-profile"; 45 | static readonly vehicleChargingStartStopUrl = Constants.vehicleChargingBaseUrl + "/{serviceType}"; 46 | } 47 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '21 20 * * 5' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 37 | # Learn more: 38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@v2 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v1 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v1 72 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { LogLevel } from "./LogLevel" 2 | import { ILogger } from "./ILogger" 3 | import { LoggerBase } from "./LoggerBase" 4 | import { ITokenStore } from "./ITokenStore" 5 | import { Token } from "./Token" 6 | import { Regions } from "./Regions" 7 | import { Account } from "./Account" 8 | import { CarBrand } from "./CarBrand" 9 | import { CarView } from "./CarView" 10 | import { ConnectedDrive } from "./ConnectedDrive" 11 | import { RemoteServicesConstants } from "./RemoteServicesConstants" 12 | import { DetailedServiceStatus } from "./DetailedServiceStatus" 13 | import { RemoteServiceExecutionState } from "./RemoteServiceExecutionState" 14 | import { RemoteServiceExecutionStateDetailed } from "./RemoteServiceExecutionStateDetailed" 15 | import { Capabilities, ChargingProfile, ChargingSettings, ChargingDetailsResponse, ChargeAndClimateSettings, ChargeAndClimateTimer, ChargingFlap, ChargingSettingsLabels, ChargeAndClimateTimerDetail, ChargingModeDetail, DepartureTimerDetail, WeeklyTimer, ChargingFlapDetail, ChargingSettingsDetail, AcLimit, CurrentLimit, CheckControlMessage, Coordinates, Address, DepartureTime, DriverGuideInfo, ReductionOfChargeCurrent, RemoteServiceRequestResponse, RequiredService, TireStatus, TireStatusInfo, Vehicle, Attributes, SoftwareVersion, PuStep, MappingInfo, VehicleStatus, Time, ClimateControlState, ClimateTimer, CombustionFuelLevel, DoorsState, DriverPreferences, ElectricChargingState, TireStatuses, WindowsState, DigitalKey, RemoteChargingCommands, LocationInfo, IStep, LoggedInProfile, Battery, TireDetails, RemoteServices, PersonalPictureUpload, ThirdPartyAppStore, LocationBasedCommerceFeatures, ProfileInfo, TireSpeedClassification, RemoteService, RemoteServiceFunctions } from "./Models" 16 | import { Utils } from "./Utils"; 17 | 18 | export { 19 | LogLevel, 20 | ILogger, 21 | LoggerBase, 22 | ITokenStore, 23 | Token, 24 | Regions, 25 | Account, 26 | CarBrand, 27 | CarView, 28 | ConnectedDrive, 29 | RemoteServicesConstants, 30 | DetailedServiceStatus, 31 | RemoteServiceExecutionState, 32 | RemoteServiceExecutionStateDetailed, 33 | Vehicle, 34 | Attributes, 35 | DriverGuideInfo, 36 | SoftwareVersion, 37 | PuStep, 38 | MappingInfo, 39 | VehicleStatus, 40 | ChargingProfile, 41 | ChargingSettings, 42 | ChargingDetailsResponse, 43 | ChargeAndClimateSettings, 44 | ChargeAndClimateTimer, 45 | ChargingFlap, 46 | ChargingSettingsLabels, 47 | ChargeAndClimateTimerDetail, 48 | ChargingModeDetail, 49 | DepartureTimerDetail, 50 | WeeklyTimer, 51 | ChargingFlapDetail, 52 | ChargingSettingsDetail, 53 | AcLimit, 54 | CurrentLimit, 55 | DepartureTime, 56 | Time, 57 | ReductionOfChargeCurrent, 58 | CheckControlMessage, 59 | ClimateControlState, 60 | ClimateTimer, 61 | CombustionFuelLevel, 62 | DoorsState, 63 | DriverPreferences, 64 | ElectricChargingState, 65 | LocationInfo, 66 | Address, 67 | Coordinates, 68 | RequiredService, 69 | TireStatuses, 70 | TireStatusInfo, 71 | TireStatus, 72 | WindowsState, 73 | Capabilities, 74 | DigitalKey, 75 | RemoteChargingCommands, 76 | RemoteServiceRequestResponse, 77 | Utils, 78 | IStep, 79 | LoggedInProfile, 80 | Battery, 81 | TireDetails, 82 | RemoteServices, 83 | PersonalPictureUpload, 84 | ThirdPartyAppStore, 85 | LocationBasedCommerceFeatures, 86 | ProfileInfo, 87 | TireSpeedClassification, 88 | RemoteService, 89 | RemoteServiceFunctions 90 | } 91 | -------------------------------------------------------------------------------- /src/Account.ts: -------------------------------------------------------------------------------- 1 | import { Constants } from "./Constants"; 2 | import { Regions } from "./Regions"; 3 | import { Token } from "./Token" 4 | import { ITokenStore } from "./ITokenStore"; 5 | import { LocalTokenStore } from "./LocalTokenStore"; 6 | import { ILogger } from "./ILogger"; 7 | import { Utils } from "./Utils"; 8 | 9 | import { v4 as uuid } from 'uuid'; 10 | import crypto from "crypto"; 11 | import {URLSearchParams} from "url"; 12 | 13 | const crossFetch = require('cross-fetch') 14 | const fetch = require('fetch-cookie')(crossFetch) 15 | 16 | export class Account { 17 | 18 | username: string; 19 | password: string; 20 | region: Regions; 21 | token?: Token; 22 | tokenStore?: ITokenStore; 23 | logger?: ILogger; 24 | captchaToken?: string; 25 | 26 | constructor(username: string, password: string, region: Regions, tokenStore?: ITokenStore, logger?: ILogger, captchaToken?: string) { 27 | this.username = username; 28 | this.password = password; 29 | this.region = region; 30 | this.tokenStore = tokenStore ?? new LocalTokenStore(); 31 | this.logger = logger; 32 | this.captchaToken = captchaToken; 33 | } 34 | 35 | async getToken(): Promise { 36 | if (!this.token && this.tokenStore) { 37 | this.logger?.LogDebug("Attempting retrieving token from token store."); 38 | this.token = this.tokenStore.retrieveToken() as Token; 39 | } 40 | if (this.token && new Date() > this.token.validUntil) { 41 | this.logger?.LogDebug("Token expired."); 42 | if (this.token.refreshToken) { 43 | this.logger?.LogDebug("Attempting refreshing."); 44 | try { 45 | this.token = await this.refresh_token(this.token) 46 | } catch (_e) { 47 | this.token = undefined; 48 | this.logger?.LogError("Error occurred while refreshing token. Attempting normal token retrieval."); 49 | let e = _e as Error; 50 | if (e) { 51 | this.logger?.LogError(e.message); 52 | } 53 | } 54 | } else { 55 | this.token = undefined; 56 | } 57 | } 58 | if (!this.token || !this.token.accessToken) { 59 | if (this.captchaToken) { 60 | this.logger?.LogDebug("Getting token from token endpoint."); 61 | this.token = await this.login( 62 | this.username, 63 | this.password, 64 | this.captchaToken); 65 | this.captchaToken = undefined; // Delete because the captcha token is only valid for a short time and can only be used once 66 | } else { 67 | this.logger?.LogDebug("Missing captcha token for first authentication."); 68 | } 69 | } 70 | 71 | if (!this.token) { 72 | throw new Error("Error occurred while retrieving token."); 73 | } 74 | 75 | return this.token; 76 | } 77 | 78 | private async login(username: string, password: string, captchaToken: string): Promise { 79 | const oauthConfig = await this.retrieveOAuthConfig(); 80 | 81 | const code_verifier = Account.base64UrlEncode(crypto.randomBytes(64)); 82 | const hash = crypto.createHash('sha256'); 83 | const code_challenge = Account.base64UrlEncode(hash.update(code_verifier).digest()); 84 | const state = Account.base64UrlEncode(crypto.randomBytes(16)); 85 | 86 | const baseOAuthParams = { 87 | client_id: oauthConfig.clientId, 88 | response_type: "code", 89 | redirect_uri: oauthConfig.returnUrl, 90 | state: state, 91 | nonce: "login_nonce", 92 | scope: oauthConfig.scopes.join(" "), 93 | code_challenge: code_challenge, 94 | code_challenge_method: "S256" 95 | }; 96 | 97 | const authenticateUrl = oauthConfig.tokenEndpoint.replace("/token", "/authenticate"); 98 | const headers = { 99 | "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", 100 | "hcaptchatoken": captchaToken 101 | } as any; 102 | 103 | let serverResponse = await this.executeFetchWithRetry(authenticateUrl, { 104 | method: "POST", 105 | body: new URLSearchParams({ 106 | ...{ 107 | grant_type: "authorization_code", 108 | username: username, 109 | password: password 110 | }, 111 | ...baseOAuthParams 112 | }), 113 | headers: headers, 114 | credentials: "same-origin" 115 | }, response => response.ok); 116 | 117 | let data = await serverResponse.json(); 118 | const authorization = Account.getQueryStringValue(data.redirect_to, "authorization"); 119 | 120 | this.logger?.LogTrace(authorization); 121 | 122 | serverResponse = await this.executeFetchWithRetry(authenticateUrl, { 123 | method: "POST", 124 | body: new URLSearchParams({...baseOAuthParams, ...{authorization: authorization}}), 125 | headers: { 126 | "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8" 127 | }, 128 | redirect: "manual", 129 | credentials: "same-origin" 130 | }, response => response.status === 302); 131 | 132 | const nextUrl = serverResponse.headers.get("location") as string; 133 | this.logger?.LogTrace(nextUrl); 134 | const code = Account.getQueryStringValue(nextUrl, "code"); 135 | 136 | this.logger?.LogTrace(JSON.stringify(code)); 137 | 138 | const authHeaderValue = Buffer.from(`${oauthConfig.clientId}:${oauthConfig.clientSecret}`).toString('base64'); 139 | 140 | serverResponse = await this.executeFetchWithRetry(oauthConfig.tokenEndpoint, { 141 | method: "POST", 142 | body: new URLSearchParams({ 143 | code: code, 144 | code_verifier: code_verifier, 145 | redirect_uri: oauthConfig.returnUrl, 146 | grant_type: "authorization_code" 147 | }), 148 | headers: { 149 | "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", 150 | authorization: `Basic ${authHeaderValue}` 151 | }, 152 | credentials: "same-origin" 153 | }, response => response.ok); 154 | 155 | data = await serverResponse.json(); 156 | 157 | return this.buildTokenAndStore(data); 158 | } 159 | 160 | private async refresh_token(token: Token): Promise { 161 | const oauthConfig = await this.retrieveOAuthConfig(); 162 | 163 | const authHeaderValue: string = Buffer.from(`${oauthConfig.clientId}:${oauthConfig.clientSecret}`).toString('base64'); 164 | 165 | let serverResponse = await this.executeFetchWithRetry(oauthConfig.tokenEndpoint, { 166 | method: "POST", 167 | body: new URLSearchParams({ 168 | grant_type: "refresh_token", 169 | refresh_token: token.refreshToken, 170 | scope: oauthConfig.scopes.join(" "), 171 | redirect_uri: oauthConfig.returnUrl, 172 | }), 173 | headers: { 174 | "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", 175 | authorization: `Basic ${authHeaderValue}` 176 | }, 177 | credentials: "same-origin" 178 | }, response => response.ok); 179 | 180 | let data = await serverResponse.json(); 181 | 182 | return this.buildTokenAndStore(data); 183 | } 184 | 185 | private async retrieveOAuthConfig() { 186 | const authSettingsUrl: string = `https://${Constants.ServerEndpoints[this.region]}/eadrax-ucs/v1/presentation/oauth/config`; 187 | let serverResponse = await this.executeFetchWithRetry(authSettingsUrl, { 188 | method: "GET", 189 | headers: { 190 | "ocp-apim-subscription-key": Constants.ApimSubscriptionKey[this.region], 191 | "x-identity-provider": "gcdm", 192 | }, 193 | credentials: "same-origin" 194 | }, response => response.ok); 195 | 196 | return await serverResponse.json(); 197 | } 198 | 199 | private async executeFetchWithRetry(url: string, init: any, responseValidator: (response: Response) => boolean): Promise { 200 | const correlationId = uuid(); 201 | let response: Response; 202 | let retryCount = 0; 203 | init.headers["user-agent"] = Constants.User_Agent; 204 | init.headers["x-user-agent"] = Constants.X_User_Agent(Utils.getXUserAgentBuildString(), this.region); 205 | init.headers["x-identity-provider"] = "gcdm"; 206 | init.headers["bmw-session-id"] = correlationId; 207 | init.headers["x-correlation-id"] = correlationId; 208 | init.headers["bmw-correlation-id"] = correlationId; 209 | 210 | do { 211 | response = await fetch(url, init); 212 | retryCount++; 213 | } while (retryCount < 10 && !responseValidator(response) && (await Utils.Delay(1000, this.logger))); 214 | 215 | if (!responseValidator(response)) { 216 | this.logger?.LogError(`${response.status}: Error occurred while attempting to retrieve token. Server response: ${(await response.text())}`); 217 | throw new Error(`${response.status}: Error occurred while attempting to retrieve token.`); 218 | } 219 | 220 | return response; 221 | } 222 | 223 | private static getQueryStringValue(url: string, queryParamName: string): string { 224 | const splitUrl = url?.split("?"); 225 | const queryString = splitUrl.length > 1 ? splitUrl[1] : splitUrl[0]; 226 | const parsedQueryString = queryString?.split("&"); 227 | if (!parsedQueryString) { 228 | throw new Error(`Url: '${url}' does not contain query string.`); 229 | } 230 | for (const param of parsedQueryString) { 231 | const paramKeyValue = param.split("="); 232 | if (paramKeyValue[0].toLowerCase() === queryParamName) { 233 | return paramKeyValue[1]; 234 | } 235 | } 236 | 237 | throw new Error(`Url: '${url}' does not contain parameter '${queryParamName}'.`); 238 | } 239 | 240 | private static base64UrlEncode(buffer: Buffer):string{ 241 | return buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); 242 | } 243 | 244 | private buildTokenAndStore(data: any) : Token { 245 | let token: Token = new Token({ 246 | response: JSON.stringify(data), 247 | accessToken: data.access_token, 248 | refreshToken: data.refresh_token, 249 | validUntil: new Date(new Date().getTime() + ((data.expires_in - 5) * 1000)) 250 | }); 251 | 252 | if (this.tokenStore) { 253 | this.logger?.LogDebug("Storing token in token store."); 254 | this.tokenStore.storeToken(token); 255 | } 256 | 257 | return token; 258 | } 259 | } -------------------------------------------------------------------------------- /src/ConnectedDrive.ts: -------------------------------------------------------------------------------- 1 | import { Account } from "./Account"; 2 | import { fetch } from 'cross-fetch'; 3 | import { Constants } from "./Constants"; 4 | import { RemoteServicesConstants } from "./RemoteServicesConstants"; 5 | import { RemoteServiceExecutionState } from "./RemoteServiceExecutionState"; 6 | import { CarBrand } from "./CarBrand"; 7 | import { Regions } from "./Regions"; 8 | import { ITokenStore } from "./ITokenStore"; 9 | import { ILogger } from "./ILogger"; 10 | import { Capabilities, ChargingDetailsResponse, ChargingProfile, ChargingSettingsDetail, RemoteServiceRequestResponse, Vehicle, VehicleStatus } from "./Models"; 11 | import { v4 as uuid } from 'uuid'; 12 | import { Utils } from "./Utils"; 13 | import { CarView } from "./CarView"; 14 | 15 | export class ConnectedDrive { 16 | serviceExecutionStatusCheckInterval = 5000; 17 | account: Account; 18 | logger?: ILogger; 19 | 20 | constructor(username: string, password: string, region: Regions, tokenStore?: ITokenStore, logger?: ILogger, captchaToken?: string) { 21 | this.account = new Account(username, password, region, tokenStore, logger, captchaToken); 22 | this.logger = logger; 23 | } 24 | 25 | async getVehicles(): Promise { 26 | this.logger?.LogInformation("Getting all vehicles"); 27 | let result: Vehicle[] = []; 28 | for (let key in CarBrand) { 29 | const brand: CarBrand = CarBrand[key as keyof typeof CarBrand]; 30 | 31 | let vehicles = await this.getVehiclesByBrand(brand); 32 | result.push(...vehicles); 33 | } 34 | return (result); 35 | } 36 | 37 | async getVehiclesByBrand(brand: CarBrand): Promise { 38 | this.logger?.LogInformation(`Getting ${brand} vehicles`); 39 | 40 | const params = `apptimezone=${120}&appDateTime=${Date.now()}`; 41 | const url: string = `https://${Constants.ServerEndpoints[this.account.region]}${Constants.getVehicles}?${params}`; 42 | const vehicles = await this.getFromJson(url, brand); 43 | 44 | // Patching the brand to the vehicles as the brand returned might be "BMW_I" 45 | // https://github.com/lsiddiquee/com.rexwel.bmwconnected/issues/56 46 | vehicles.forEach((vehicle: Vehicle) => vehicle.attributes.brand = brand); 47 | 48 | return vehicles; 49 | } 50 | 51 | async getVehicleStatus(vin: string, brand: CarBrand = CarBrand.Bmw): Promise { 52 | this.logger?.LogInformation("Getting vehicle status."); 53 | 54 | const params = `apptimezone=${120}&appDateTime=${Date.now()}`; 55 | const url: string = `https://${Constants.ServerEndpoints[this.account.region]}${Constants.getVehicles}/state?${params}`; 56 | return (await this.getFromJson(url, brand, { "bmw-vin": vin })).state; 57 | } 58 | 59 | async getVehicleCapabilities(vin: string, brand: CarBrand = CarBrand.Bmw): Promise { 60 | this.logger?.LogInformation("Getting vehicle capabilities."); 61 | 62 | const params = `apptimezone=${120}&appDateTime=${Date.now()}`; 63 | const url: string = `https://${Constants.ServerEndpoints[this.account.region]}${Constants.getVehicles}/state?${params}`; 64 | return (await this.getFromJson(url, brand, { "bmw-vin": vin })).capabilities; 65 | } 66 | 67 | async getChargingDetails(vin: string, brand: CarBrand = CarBrand.Bmw, hasChargingSettingsCapabilities: boolean = false): Promise { 68 | this.logger?.LogInformation("Getting vehicle charging details."); 69 | 70 | const fetchedAt = new Date().toISOString(); 71 | const params = `fields=charging-profile&has_charging_settings_capabilities=${hasChargingSettingsCapabilities}`; 72 | const url: string = `https://${Constants.ServerEndpoints[this.account.region]}${Constants.vehicleChargingDetailsUrl}?${params}`; 73 | 74 | const headers = { 75 | "bmw-current-date": fetchedAt, 76 | "bmw-vin": vin 77 | }; 78 | 79 | return await this.getFromJson(url, brand, headers); 80 | } 81 | 82 | async lockDoors(vin: string, brand: CarBrand = CarBrand.Bmw, waitExecution: boolean = false): Promise { 83 | this.logger?.LogInformation("Locking doors"); 84 | return await this.executeService(vin, brand, RemoteServicesConstants.LockDoors, {}, {}, waitExecution); 85 | } 86 | 87 | async unlockDoors(vin: string, brand: CarBrand = CarBrand.Bmw, waitExecution: boolean = false): Promise { 88 | this.logger?.LogInformation("Unlocking doors"); 89 | return await this.executeService(vin, brand, RemoteServicesConstants.UnlockDoors, {}, {}, waitExecution); 90 | } 91 | 92 | async startClimateControl(vin: string, brand: CarBrand = CarBrand.Bmw, waitExecution: boolean = false): Promise { 93 | this.logger?.LogInformation("Start Climate Control"); 94 | return await this.executeService(vin, brand, RemoteServicesConstants.ClimateNow, { "action": "START" }, {}, waitExecution); 95 | } 96 | 97 | async stopClimateControl(vin: string, brand: CarBrand = CarBrand.Bmw, waitExecution: boolean = false): Promise { 98 | this.logger?.LogInformation("Stop Climate Control"); 99 | return await this.executeService(vin, brand, RemoteServicesConstants.ClimateNow, { "action": "STOP" }, {}, waitExecution); 100 | } 101 | 102 | async flashLights(vin: string, brand: CarBrand = CarBrand.Bmw, waitExecution: boolean = false): Promise { 103 | return await this.executeService(vin, brand, RemoteServicesConstants.FlashLight, {}, {}, waitExecution); 104 | } 105 | 106 | async blowHorn(vin: string, brand: CarBrand = CarBrand.Bmw, waitExecution: boolean = false): Promise { 107 | this.logger?.LogInformation("Blow Horn"); 108 | return await this.executeService(vin, brand, RemoteServicesConstants.BlowHorn, {}, {}, waitExecution); 109 | } 110 | 111 | async startCharging(vin: string, brand: CarBrand = CarBrand.Bmw, waitExecution: boolean = false): Promise { 112 | this.logger?.LogInformation("Start Charging"); 113 | return await this.executeService(vin, brand, RemoteServicesConstants.ChargeStart, {}, {}, waitExecution, Constants.vehicleChargingStartStopUrl); 114 | } 115 | 116 | async stopCharging(vin: string, brand: CarBrand = CarBrand.Bmw, waitExecution: boolean = false): Promise { 117 | this.logger?.LogInformation("Stop Charging"); 118 | return await this.executeService(vin, brand, RemoteServicesConstants.ChargeStop, {}, {}, waitExecution, Constants.vehicleChargingStartStopUrl); 119 | } 120 | 121 | async setChargingSettings(vin: string, brand: CarBrand = CarBrand.Bmw, chargingSettingsDetail: ChargingSettingsDetail, waitExecution: boolean = false): Promise { 122 | this.logger?.LogInformation("Set Charging Settings"); 123 | return await this.executeService(vin, brand, RemoteServicesConstants.SetChargingSettings, {}, chargingSettingsDetail, waitExecution, Constants.vehicleChargingSettingsSetUrl); 124 | } 125 | 126 | private async executeService(vin: string, brand: CarBrand, serviceType: RemoteServicesConstants, params: { [key: string]: string }, requestBody: any = {}, waitExecution: boolean, remoteServiceUrl: string = Constants.executeRemoteServices): Promise { 127 | let url: string = `https://${Constants.ServerEndpoints[this.account.region]}${remoteServiceUrl}`; 128 | url = url.replace("{vehicleVin}", vin); 129 | url = url.replace("{serviceType}", serviceType); 130 | 131 | const headers = { 132 | "bmw-vin": vin 133 | }; 134 | 135 | if (Object.keys(params).length > 0) { 136 | const queryString = Object.keys(params) 137 | .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`) 138 | .join('&'); 139 | 140 | url += `?${queryString}`; 141 | } 142 | 143 | const response: RemoteServiceRequestResponse = await this.postAsJson(url, brand, requestBody, headers); 144 | 145 | if (waitExecution) { 146 | let status: RemoteServiceExecutionState = RemoteServiceExecutionState.UNKNOWN; 147 | 148 | while (status !== RemoteServiceExecutionState.EXECUTED 149 | && status !== RemoteServiceExecutionState.CANCELLED_WITH_ERROR 150 | && status !== RemoteServiceExecutionState.ERROR) { 151 | status = await this.getServiceStatus(response.eventId, brand); 152 | await Utils.Delay(this.serviceExecutionStatusCheckInterval, this.logger); 153 | } 154 | } 155 | 156 | return response; 157 | } 158 | 159 | async getServiceStatus(eventId: string, brand: CarBrand = CarBrand.Bmw): Promise { 160 | let url: string = `https://${Constants.ServerEndpoints[this.account.region]}${Constants.statusRemoteServices}`; 161 | url = url.replace("{eventId}", eventId); 162 | 163 | return (await this.postAsJson(url, brand)).eventStatus; 164 | } 165 | 166 | async getImage(vin: string, brand: CarBrand = CarBrand.Bmw, view: CarView): Promise { 167 | let url: string = `https://${Constants.ServerEndpoints[this.account.region]}${Constants.getImages}`; 168 | url = url.replace("{carView}", view); 169 | 170 | const headers = { 171 | "accept": "image/png", 172 | "bmw-app-vehicle-type": "connected", 173 | "bmw-vin": vin 174 | }; 175 | 176 | const response = await this.get(url, brand, headers); 177 | return await response.arrayBuffer(); 178 | } 179 | 180 | async sendMessage(vin: string, brand: CarBrand = CarBrand.Bmw, subject: string, message: string): Promise { 181 | // TODO: Cleanup 182 | let url: string = `https://${Constants.ServerEndpoints[this.account.region]}`; 183 | const requestBody = { "vins": [vin], "message": message, "subject": subject }; 184 | 185 | return (await this.postAsJson(url, brand, requestBody))?.status === "OK"; 186 | } 187 | 188 | async get(url: string, brand: CarBrand = CarBrand.Bmw, headers: any = {}): Promise { 189 | return await this.request(url, brand, false, null, headers); 190 | } 191 | 192 | async getFromJson(url: string, brand: CarBrand = CarBrand.Bmw, headers: any = {}): Promise { 193 | return (await this.get(url, brand, headers)).json(); 194 | } 195 | 196 | async postAsJson(url: string, brand: CarBrand = CarBrand.Bmw, requestBody: any = {}, headers: any = {}): Promise { 197 | return (await this.request(url, brand, true, requestBody, headers)).json(); 198 | } 199 | 200 | async request(url: string, brand: CarBrand = CarBrand.Bmw, isPost: boolean = false, requestBody?: any, headers: any = {}): Promise { 201 | const correlationId = uuid(); 202 | const httpMethod = isPost ? "POST" : "GET"; 203 | const requestBodyContent = requestBody ? JSON.stringify(requestBody) : null; 204 | let retryCount = 0; 205 | 206 | const completeHeaders = { 207 | "accept": "application/json", 208 | "accept-language": "en", 209 | "content-type": "application/json;charset=UTF-8", 210 | "authorization": `Bearer ${(await this.account.getToken()).accessToken}`, 211 | "user-agent": Constants.User_Agent, 212 | "x-user-agent": Constants.X_User_Agent(Utils.getXUserAgentBuildString(), this.account.region, brand), 213 | "x-identity-provider": "gcdm", 214 | "bmw-session-id": correlationId, 215 | "x-correlation-id": correlationId, 216 | "bmw-correlation-id": correlationId, 217 | ...headers 218 | }; 219 | 220 | let response: Response; 221 | do { 222 | if (retryCount !== 0) { 223 | await Utils.Delay(2000, this.logger); 224 | } 225 | 226 | response = await fetch(url, { 227 | method: httpMethod, 228 | body: requestBodyContent, 229 | headers: completeHeaders, 230 | credentials: "same-origin" 231 | }); 232 | 233 | completeHeaders["authorization"] = "Bearer xxx"; // Mask the token in the logs 234 | this.logger?.LogTrace(`Request: ${url}, Method: ${httpMethod}, Headers: ${JSON.stringify(completeHeaders)}, Body: ${requestBodyContent}`); 235 | this.logger?.LogTrace(`Response: ${response.status}, Headers: ${JSON.stringify(response.headers)}`); 236 | } while (retryCount++ < 5 && (response.status === 429 || (response.status === 403 && response.statusText.includes("quota")))); 237 | 238 | if (!response.ok) { 239 | throw new Error(`Error occurred while attempting '${httpMethod}' at url '${url}' with ${response.status} body (${requestBodyContent})\n${await response.text()}`); 240 | } 241 | 242 | return response; 243 | } 244 | } -------------------------------------------------------------------------------- /src/Models.ts: -------------------------------------------------------------------------------- 1 | import { CarBrand } from "./CarBrand"; 2 | 3 | export interface Vehicle { 4 | vin: string; 5 | mappingInfo: MappingInfo; 6 | appVehicleType: string; 7 | attributes: Attributes; 8 | } 9 | 10 | export interface Attributes { 11 | lastFetched: Date; 12 | model: string; 13 | year: number; 14 | color: number; 15 | brand: CarBrand; 16 | driveTrain: string; 17 | headUnitType: string; 18 | headUnitRaw: string; 19 | hmiVersion: string; 20 | softwareVersionCurrent: SoftwareVersion; 21 | softwareVersionExFactory: SoftwareVersion; 22 | telematicsUnit: string; 23 | bodyType: string; 24 | countryOfOrigin: string; 25 | a4aType: string; 26 | driverGuideInfo: DriverGuideInfo; 27 | } 28 | 29 | export interface DriverGuideInfo { 30 | androidAppScheme: string; 31 | iosAppScheme: string; 32 | androidStoreUrl: string; 33 | iosStoreUrl: string; 34 | } 35 | 36 | export interface SoftwareVersion { 37 | puStep: PuStep; 38 | iStep: IStep | number; // Can be either detailed object or simple number 39 | seriesCluster: string; 40 | } 41 | 42 | export interface IStep { 43 | seriesCluster: string; 44 | year: number; 45 | month: number; 46 | iStep: number; 47 | } 48 | 49 | export interface PuStep { 50 | month: number; 51 | year: number; 52 | } 53 | 54 | export interface MappingInfo { 55 | isAssociated: boolean; 56 | isLmmEnabled: boolean; 57 | mappingStatus: string; 58 | isPrimaryUser: boolean; 59 | } 60 | 61 | export interface LoggedInProfile { 62 | driverFront: ProfileInfo; 63 | passengerFront: ProfileInfo; 64 | } 65 | 66 | export interface ProfileInfo { 67 | gcid?: string; 68 | } 69 | 70 | export interface VehicleStatus { 71 | isLeftSteering: boolean; 72 | lastFetched: Date; 73 | lastUpdatedAt: Date; 74 | isLscSupported: boolean; 75 | range: number; 76 | doorsState: DoorsState; 77 | windowsState: WindowsState; 78 | tireState: TireStatuses; 79 | location: LocationInfo; 80 | currentMileage: number; 81 | climateControlState: ClimateControlState; 82 | requiredServices: RequiredService[]; 83 | checkControlMessages: CheckControlMessage[]; 84 | chargingProfile: ChargingProfile; 85 | electricChargingState: ElectricChargingState; 86 | combustionFuelLevel: CombustionFuelLevel; 87 | driverPreferences: DriverPreferences; 88 | isDeepSleepModeActive: boolean; 89 | climateTimers: ClimateTimer[]; 90 | departurePlan: any; // Empty object in the JSON data 91 | securityOverviewMode: string | null; 92 | vehicleSoftwareVersion: SoftwareVersion; 93 | pwf: string; 94 | loggedInProfile: LoggedInProfile; 95 | isRemoteEngineStartDisclaimer: boolean; 96 | } 97 | 98 | export interface ChargingProfile { 99 | chargingControlType: string; 100 | reductionOfChargeCurrent: ReductionOfChargeCurrent; 101 | chargingMode: string; 102 | chargingPreference: string; 103 | departureTimes: DepartureTime[]; 104 | climatisationOn: boolean; 105 | chargingSettings: ChargingSettings; 106 | } 107 | 108 | export interface ChargingSettings { 109 | targetSoc: number; 110 | acCurrentLimit?: number; 111 | idcc: string; 112 | hospitality: string; 113 | isAcCurrentLimitActive?: boolean; 114 | } 115 | 116 | export interface DepartureTime { 117 | id: number; 118 | timeStamp: Time; 119 | action: string; 120 | timerWeekDays: string[]; 121 | } 122 | 123 | export interface Time { 124 | hour: number; 125 | minute: number; 126 | } 127 | 128 | export interface ReductionOfChargeCurrent { 129 | start: Time; 130 | end: Time; 131 | } 132 | 133 | export interface CheckControlMessage { 134 | type: string; 135 | severity: string; 136 | } 137 | 138 | export interface ClimateControlState { 139 | activity: string; 140 | } 141 | 142 | export interface ClimateTimer { 143 | isWeeklyTimer: boolean; 144 | timerAction: string; 145 | timerWeekDays: string[]; 146 | departureTime: Time; 147 | } 148 | 149 | export interface CombustionFuelLevel { 150 | remainingFuelPercent?: number; 151 | remainingFuelLiters?: number; 152 | range: number; 153 | } 154 | 155 | export interface DoorsState { 156 | combinedSecurityState: string; 157 | leftFront: string; 158 | leftRear: string; 159 | rightFront: string; 160 | rightRear: string; 161 | combinedState: string; 162 | hood: string; 163 | trunk: string; 164 | } 165 | 166 | export interface DriverPreferences { 167 | lscPrivacyMode: string; 168 | } 169 | 170 | export interface Battery { 171 | batteryPreconditionState: string; 172 | batteryPreconditionErrorState: string | null; 173 | } 174 | 175 | export interface ElectricChargingState { 176 | chargingLevelPercent: number; 177 | remainingChargingMinutes?: number; 178 | range: number; 179 | isChargerConnected: boolean; 180 | chargingConnectionType?: string; 181 | chargingStatus: string; 182 | chargingTarget: number; 183 | battery?: Battery; 184 | } 185 | 186 | export interface LocationInfo { 187 | coordinates: Coordinates; 188 | address: Address; 189 | heading: number; 190 | } 191 | 192 | export interface Address { 193 | formatted: string; 194 | } 195 | 196 | export interface Coordinates { 197 | latitude: number; 198 | longitude: number; 199 | } 200 | 201 | export interface RequiredService { 202 | dateTime: Date; 203 | type: string; 204 | status: string; 205 | description: string; 206 | mileage?: number; 207 | } 208 | 209 | export interface TireStatuses { 210 | frontLeft: TireStatusInfo; 211 | frontRight: TireStatusInfo; 212 | rearLeft: TireStatusInfo; 213 | rearRight: TireStatusInfo; 214 | } 215 | 216 | export interface TireDetails { 217 | dimension: string; 218 | treadDesign: string; 219 | manufacturer: string; 220 | manufacturingWeek: number; 221 | isOptimizedForOemBmw: boolean; 222 | partNumber: string; 223 | speedClassification: TireSpeedClassification; 224 | mountingDate: string; 225 | season: number; 226 | identificationInProgress: boolean; 227 | } 228 | 229 | export interface TireSpeedClassification { 230 | speedRating: number; 231 | atLeast: boolean; 232 | } 233 | 234 | export interface TireStatusInfo { 235 | details?: TireDetails; 236 | status: TireStatus; 237 | } 238 | 239 | export interface TireStatus { 240 | currentPressure?: number; 241 | targetPressure: number; 242 | } 243 | 244 | export interface WindowsState { 245 | leftFront: string; 246 | leftRear: string; 247 | rightFront: string; 248 | rightRear: string; 249 | combinedState: string; 250 | } 251 | 252 | export interface RemoteService { 253 | id: string; 254 | state: string; 255 | executionApi: string; 256 | functions?: RemoteServiceFunctions; // Optional - only present on camera-related services 257 | } 258 | 259 | export interface RemoteServiceFunctions { 260 | encryptionMethod?: string[]; // Present on all services that have functions 261 | inCarCameraFunction?: string[]; // Only present on inCarCamera service 262 | } 263 | 264 | export interface RemoteServices { 265 | departureTimerControl?: RemoteService; 266 | inCarCamera?: RemoteService; 267 | inCarCameraDwa?: RemoteService; 268 | remote360?: RemoteService; 269 | surroundViewRecorder?: RemoteService; 270 | windowControl?: RemoteService; 271 | centralLockControl?: RemoteService; 272 | batteryPreconditioningControl?: RemoteService; 273 | doorLock?: RemoteService; 274 | doorUnlock?: RemoteService; 275 | hornBlow?: RemoteService; 276 | lightFlash?: RemoteService; 277 | telematicsWakeup?: RemoteService; 278 | wakeup?: RemoteService; 279 | interiorPreconditioningControl?: RemoteService; 280 | } 281 | 282 | export interface PersonalPictureUpload { 283 | state: string; 284 | aspectRatio: string; 285 | } 286 | 287 | export interface ThirdPartyAppStore { 288 | state: string; 289 | } 290 | 291 | export interface LocationBasedCommerceFeatures { 292 | parking: boolean; 293 | fueling: boolean; 294 | reservations: boolean; 295 | } 296 | 297 | export interface Capabilities { 298 | remoteServices: RemoteServices; 299 | a4aType: string; 300 | climateNow: boolean; 301 | isClimateTimerWeeklyActive: boolean; 302 | climateFunction: string; 303 | horn: boolean; 304 | inCarCamera?: boolean; 305 | inCarCameraVideo?: boolean; 306 | inCarCameraDwa?: boolean; 307 | isBmwChargingSupported: boolean; 308 | isCarSharingSupported: boolean; 309 | isChargeNowForBusinessSupported: boolean; 310 | isChargingHistorySupported: boolean; 311 | isLocationBasedChargingSettingsSupported?: boolean; 312 | isChargingHospitalityEnabled: boolean; 313 | isChargingLoudnessEnabled: boolean; 314 | isChargingPlanSupported: boolean; 315 | isChargingPowerLimitEnabled: boolean; 316 | isChargingSettingsEnabled: boolean; 317 | isBatteryPreconditioningSupported?: boolean; 318 | isChargingTargetSocEnabled: boolean; 319 | isCustomerEsimSupported: boolean; 320 | isDataPrivacyEnabled: boolean; 321 | isDCSContractManagementSupported: boolean; 322 | isEasyChargeEnabled: boolean; 323 | isMiniChargingSupported: boolean; 324 | isEvGoChargingSupported: boolean; 325 | isRemoteHistoryDeletionSupported: boolean; 326 | isRemoteEngineStartSupported: boolean; 327 | isRemoteServicesActivationRequired: boolean; 328 | isRemoteServicesBookingRequired: boolean; 329 | isScanAndChargeSupported: boolean; 330 | lastStateCallState: string; 331 | lights: boolean; 332 | lock: boolean; 333 | remote360?: boolean; 334 | remoteSoftwareUpgrade?: boolean; 335 | sendPoi: boolean; 336 | surroundViewRecorder?: boolean; 337 | unlock: boolean; 338 | vehicleFinder: boolean; 339 | vehicleStateSource: string; 340 | isRemoteHistorySupported: boolean; 341 | isWifiHotspotServiceSupported: boolean; 342 | isNonLscFeatureEnabled: boolean; 343 | isSustainabilitySupported: boolean; 344 | isSustainabilityAccumulatedViewEnabled: boolean; 345 | checkSustainabilityDPP?: boolean; 346 | specialThemeSupport: any[]; 347 | isRemoteParkingSupported: boolean; 348 | isRemoteParkingEes25Active?: boolean; 349 | remoteChargingCommands: RemoteChargingCommands; 350 | digitalKey: DigitalKey; 351 | isPersonalPictureUploadSupported?: boolean; 352 | personalPictureUpload?: PersonalPictureUpload; 353 | isPlugAndChargeSupported?: boolean; 354 | isOptimizedChargingSupported?: boolean; 355 | alarmSystem?: boolean; 356 | isThirdPartyAppStoreSupported?: boolean; 357 | thirdPartyAppStore?: ThirdPartyAppStore; 358 | thirdPartyAppStoreCn?: ThirdPartyAppStore; 359 | locationBasedCommerceFeatures?: LocationBasedCommerceFeatures; 360 | } 361 | 362 | export interface DigitalKey { 363 | bookedServicePackage: string; 364 | state: string; 365 | readerGraphics?: string; 366 | vehicleSoftwareUpgradeRequired?: boolean; 367 | isDigitalKeyFirstSupported?: boolean; 368 | } 369 | 370 | export interface RemoteChargingCommands { 371 | chargingControl?: string[]; 372 | flapControl?: string[]; 373 | plugControl?: string[]; 374 | } 375 | 376 | export interface RemoteServiceRequestResponse { 377 | eventId: string; 378 | creationTime: string; 379 | } 380 | 381 | // Charging Details API Response Interfaces 382 | export interface ChargingDetailsResponse { 383 | chargeAndClimateSettings: ChargeAndClimateSettings; 384 | chargeAndClimateTimerDetail: ChargeAndClimateTimerDetail; 385 | chargingFlapDetail?: ChargingFlapDetail; 386 | chargingSettingsDetail?: ChargingSettingsDetail; 387 | servicePack: string; 388 | } 389 | 390 | export interface ChargeAndClimateSettings { 391 | chargeAndClimateTimer: ChargeAndClimateTimer; 392 | chargingFlap?: ChargingFlap; 393 | chargingSettings?: ChargingSettingsLabels; 394 | } 395 | 396 | export interface ChargeAndClimateTimer { 397 | chargingMode?: string; 398 | chargingModeSemantics?: string; 399 | departureTimer?: string[]; 400 | departureTimerSemantics?: string; 401 | preconditionForDeparture?: string; 402 | showDepartureTimers: boolean; 403 | } 404 | 405 | export interface ChargingFlap { 406 | permanentlyUnlockLabel: string; 407 | } 408 | 409 | export interface ChargingSettingsLabels { 410 | acCurrentLimitLabel?: string; 411 | acCurrentLimitLabelSemantics?: string; 412 | chargingTargetLabel?: string; 413 | dcLoudnessLabel?: string; 414 | unlockCableAutomaticallyLabel?: string; 415 | } 416 | 417 | export interface ChargeAndClimateTimerDetail { 418 | chargingMode: ChargingModeDetail; 419 | departureTimer: DepartureTimerDetail; 420 | isPreconditionForDepartureActive: boolean; 421 | } 422 | 423 | export interface ChargingModeDetail { 424 | chargingPreference: string; 425 | endTimeSlot: string; 426 | startTimeSlot: string; 427 | type: string; 428 | } 429 | 430 | export interface DepartureTimerDetail { 431 | type: string; 432 | weeklyTimers: WeeklyTimer[]; 433 | } 434 | 435 | export interface WeeklyTimer { 436 | daysOfTheWeek: string[]; 437 | id: number; 438 | time: string; 439 | timerAction: string; 440 | } 441 | 442 | export interface ChargingFlapDetail { 443 | isPermanentlyUnlock: boolean; 444 | } 445 | 446 | export interface ChargingSettingsDetail { 447 | acLimit?: AcLimit; 448 | chargingTarget?: number; 449 | dcLoudness?: string; 450 | isUnlockCableActive?: boolean; 451 | minChargingTargetToWarning?: number; 452 | acLimitValue?: number; 453 | } 454 | 455 | export interface AcLimit { 456 | current: CurrentLimit; 457 | isUnlimited: boolean; 458 | max: number; 459 | min: number; 460 | values: number[]; 461 | } 462 | 463 | export interface CurrentLimit { 464 | unit: string; 465 | value: number; 466 | } 467 | --------------------------------------------------------------------------------