diff --git a/README.md b/README.md index 134ff0b..53af372 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # bluelinky -An unoffcial nodejs API wrapper for Hyundai BlueLink +An unofficial nodejs API wrapper for Hyundai BlueLink [![CI](https://img.shields.io/github/workflow/status/Hacksore/bluelinky/npm)](https://github.com/Hacksore/bluelinky/actions?query=workflow%3Anpm) [![npm](https://img.shields.io/npm/v/bluelinky.svg)](https://www.npmjs.com/package/bluelinky) @@ -18,6 +18,7 @@ const BlueLinky = require('bluelinky'); const client = new BlueLinky({ username: 'someguy@example.com', password: 'hunter1', + brand: 'hyundai', region: 'US', pin: '1234' }); @@ -45,7 +46,8 @@ Ensure you have a `config.json` that matches the structure of the following, wit { "username": "email", "password": "password", - "pin": "ping", + "pin": "pin", + "brand": "kia" or "hyundai", "vin": "vin" } ``` @@ -56,12 +58,23 @@ Now you can invoke the debug.ts script with `npm run debug` ## Documentation Checkout out the [bluelinky-docs](https://hacksore.github.io/bluelinky-docs/) for more info. +Important information for login problems: +- If you experience login problems, please logout from the app on your phone and login again. You might need to ' upgrade ' your account to a generic Kia/Hyundai account, or create a new password or PIN. +- After you migrated your Bluelink account to a generic Hyundai account, or your UVO account to a generic Kia account, make sure that both accounts have the same credentials (userid and password) to avoid confusion in logging in. + ## Supported Features - Lock - Unlock - Start (with climate control) - Stop -- Status +- Status (full, parsed, cached) +- odometer +- location +- startCharge +- monthlyReport +- tripInfo +- EV: getChargeTargets +- EV: setChargeLimits ## Supported Regions | [Regions](https://github.com/Hacksore/bluelinky/wiki/Regions) diff --git a/__tests__/bluelinky.spec.ts b/__tests__/bluelinky.spec.ts index 3e2d99b..c2aea1b 100644 --- a/__tests__/bluelinky.spec.ts +++ b/__tests__/bluelinky.spec.ts @@ -48,6 +48,7 @@ describe('BlueLinky', () => { username: 'someone@gmai.com', password: '123', pin: '1234', + brand: 'hyundai', region: 'US', }); @@ -79,6 +80,7 @@ describe('BlueLinky', () => { username: 'someone@gmail.com', password: 'hunter1', pin: '1234', + brand: 'hyundai', region: 'US', }); @@ -96,6 +98,7 @@ describe('BlueLinky', () => { username: 'someone@gmail.com', password: 'hunter1', pin: '1234', + brand: 'hyundai', region: 'KR', }); }).toThrowError('Your region is not supported yet.'); @@ -106,6 +109,7 @@ describe('BlueLinky', () => { username: 'someone@gmail.com', password: 'hunter1', pin: '1234', + brand: 'hyundai', region: 'US', }); @@ -119,6 +123,7 @@ describe('BlueLinky', () => { username: 'someone@gmail.com', password: 'hunter1', pin: '1234', + brand: 'hyundai', region: 'US', }); @@ -133,6 +138,7 @@ describe('BlueLinky', () => { username: 'someone@gmai.com', password: 'hunter1', pin: '1234', + brand: 'hyundai', region: 'US', }); diff --git a/__tests__/controller.spec.ts b/__tests__/controller.spec.ts index 7b987cd..a5bc7db 100644 --- a/__tests__/controller.spec.ts +++ b/__tests__/controller.spec.ts @@ -18,6 +18,7 @@ const getController = region => { username: 'testuser@gmail.com', password: 'test', region: 'US', + brand: 'hyundai', autoLogin: true, pin: '1234', vin: '4444444444444', diff --git a/__tests__/vehicle.spec.ts b/__tests__/vehicle.spec.ts index c5bcf3e..e3f09f0 100644 --- a/__tests__/vehicle.spec.ts +++ b/__tests__/vehicle.spec.ts @@ -41,6 +41,7 @@ const getVehicle = (region: string) => { password: 'test', region: region, autoLogin: true, + brand: 'hyundai', pin: '1234', vin: '4444444444444', vehicleId: undefined, diff --git a/debug.ts b/debug.ts index 648f696..0627b19 100644 --- a/debug.ts +++ b/debug.ts @@ -4,6 +4,7 @@ import config from './config.json'; import BlueLinky from './src'; import inquirer from 'inquirer'; +import { Vehicle } from './src/vehicles/vehicle'; const apiCalls = [ { name: 'exit', value: 'exit' }, @@ -18,12 +19,16 @@ const apiCalls = [ { name: 'lock', value: 'lock' }, { name: 'unlock', value: 'unlock' }, { name: 'locate', value: 'locate' }, + { name: 'monthly report', value: 'monthlyReport' }, + { name: 'trip informations', value: 'tripInfo' }, + { name: '[EV] get charge targets', value: 'getChargeTargets' }, + { name: '[EV] set charge targets', value: 'setChargeTargets' }, ]; let vehicle; const { username, password, vin, pin } = config; -const onReadyHandler = vehicles => { +const onReadyHandler = (vehicles: T[]) => { vehicle = vehicles[0]; askForCommandInput(); }; @@ -37,6 +42,12 @@ const askForRegionInput = () => { message: 'What Region are you in?', choices: ['US', 'EU', 'CA'], }, + { + type: 'list', + name: 'brand', + message: 'Which brand are you using?', + choices: ['hyundai', 'kia'], + } ]) .then(answers => { if (answers.command == 'exit') { @@ -44,16 +55,17 @@ const askForRegionInput = () => { } else { console.log(answers) console.log('Logging in...'); - createInstance(answers.region); + createInstance(answers.region, answers.brand); } }); }; -const createInstance = region => { +const createInstance = (region, brand) => { const client = new BlueLinky({ username, password, - region: region, + region, + brand, pin }); client.on('ready', onReadyHandler); @@ -99,6 +111,13 @@ async function performCommand(command) { }); console.log('status : ' + JSON.stringify(status, null, 2)); break; + case 'statusU': + const statusU = await vehicle.status({ + refresh: false, + parsed: false, + }); + console.log('status : ' + JSON.stringify(statusU, null, 2)); + break; case 'statusR': const statusR = await vehicle.status({ refresh: true, @@ -142,6 +161,37 @@ async function performCommand(command) { const unlockRes = await vehicle.unlock(); console.log('unlock : ' + JSON.stringify(unlockRes, null, 2)); break; + case 'monthlyReport': + const report = await vehicle.monthlyReport(); + console.log('monthyReport : ' + JSON.stringify(report, null, 2)); + break; + case 'tripInfo': + const trips = await vehicle.tripInfo(); + console.log('trips : ' + JSON.stringify(trips, null, 2)); + break; + case 'getChargeTargets': + const targets = await vehicle.getChargeTargets(); + console.log('targets : ' + JSON.stringify(targets, null, 2)); + break; + case 'setChargeTargets': + const { fast, slow } = await inquirer + .prompt([ + { + type: 'list', + name: 'fast', + message: 'What fast charge limit do you which to set?', + choices: [50, 60, 70, 80, 90, 100], + }, + { + type: 'list', + name: 'slow', + message: 'What slow charge limit do you which to set?', + choices: [50, 60, 70, 80, 90, 100], + } + ]); + await vehicle.setChargeTargets({ fast, slow }); + console.log('targets : OK'); + break; } askForCommandInput(); diff --git a/package-lock.json b/package-lock.json index 30fc40a..e065022 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5371,6 +5371,11 @@ "whatwg-url": "^8.0.0" } }, + "date-fns": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.19.0.tgz", + "integrity": "sha512-X3bf2iTPgCAQp9wvjOQytnf5vO5rESYRXlPIVcgSbtT5OTScPcsf9eZU+B/YIkKAtYr5WeCii58BgATrNitlWg==" + }, "debug": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", @@ -10696,9 +10701,9 @@ "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" }, "push-receiver": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/push-receiver/-/push-receiver-2.1.0.tgz", - "integrity": "sha512-l9e6ccSLZ3adyjDQGYMCVNdaEaLQQkGPl0OnWbh0wM3/u1gACHoBLx9IBaIaQpfXvMvVy+vQxN4JxBaL6PUfZA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/push-receiver/-/push-receiver-2.1.1.tgz", + "integrity": "sha512-2f+Rglq6B+J6x06JEypDkm48vGf0lPCSjqO/+1F7Pg/8gippvZRSb/vloWuQlWGJw8ulma7lgPei34HAnJba5w==", "requires": { "http_ece": "^1.0.5", "long": "^3.2.0", diff --git a/package.json b/package.json index 046a790..f4cf4e7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bluelinky", - "version": "6.0.1", + "version": "7.0.0", "description": "", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -18,8 +18,9 @@ "author": "Hacksore", "license": "MIT", "dependencies": { + "date-fns": "^2.19.0", "got": "^9.6.0", - "push-receiver": "^2.1.0", + "push-receiver": "^2.1.1", "tough-cookie": "^4.0.0", "url": "^0.11.0", "winston": "^3.3.3" diff --git a/src/constants.ts b/src/constants.ts index 9b4b2a8..c35e384 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,12 +1,12 @@ // moved all the US constants to its own file, we can use this file for shared constants -import { CA_ENDPOINTS } from './constants/canada'; -import { EU_ENDPOINTS } from './constants/europe'; +import { getBrandEnvironment as getCABrandEnvironment, CanadianBrandEnvironment } from './constants/canada'; +import { getBrandEnvironment as getEUBrandEnvironment, EuropeanBrandEnvironment } from './constants/europe'; -import { VehicleStatusOptions } from './interfaces/common.interfaces'; +import { Brand, VehicleStatusOptions } from './interfaces/common.interfaces'; export const ALL_ENDPOINTS = { - CA: CA_ENDPOINTS, - EU: EU_ENDPOINTS, + CA: (brand: Brand): CanadianBrandEnvironment['endpoints'] => getCABrandEnvironment(brand).endpoints, + EU: (brand: Brand): EuropeanBrandEnvironment['endpoints'] => getEUBrandEnvironment(brand).endpoints, }; export const GEN2 = 2; diff --git a/src/constants/america.ts b/src/constants/america.ts index 08da945..083866e 100644 --- a/src/constants/america.ts +++ b/src/constants/america.ts @@ -1,8 +1,30 @@ -//TODO: Someone needs to figure out the Kia endpoints -// we can then make a fork easier to maintain +import { Brand } from '../interfaces/common.interfaces'; -export const API_HOST = 'api.telematics.hyundaiusa.com'; +export interface AmericaBrandEnvironment { + brand: Brand; + host: string; + baseUrl: string; + clientId: string; + clientSecret: string; +} -export const BASE_URL = `https://${API_HOST}`; -export const CLIENT_ID = '815c046afaa4471aa578827ad546cc76'; -export const CLIENT_SECRET = 'GXZveJJAVTehh/OtakM3EQ=='; +const getHyundaiEnvironment = (): AmericaBrandEnvironment => { + const host = 'api.telematics.hyundaiusa.com'; + const baseUrl = `https://${host}`; + return { + brand: 'hyundai', + host, + baseUrl, + clientId: '815c046afaa4471aa578827ad546cc76', + clientSecret: 'GXZveJJAVTehh/OtakM3EQ==', + }; +}; + +export const getBrandEnvironment = (brand: Brand): AmericaBrandEnvironment => { + switch (brand) { + case 'hyundai': + return Object.freeze(getHyundaiEnvironment()); + default: + throw new Error(`Constructor ${brand} is not managed.`); + } +}; diff --git a/src/constants/canada.ts b/src/constants/canada.ts index eae5f9a..7e91f40 100644 --- a/src/constants/canada.ts +++ b/src/constants/canada.ts @@ -1,26 +1,81 @@ -// Kia seems to use myuvo.ca as mentioned by @wcomartin -// forks can modify some things to make this work -export const CA_API_HOST = 'mybluelink.ca'; -export const CA_BASE_URL = `https://${CA_API_HOST}`; -export const CLIENT_ORIGIN = 'SPA'; +import { Brand } from '../interfaces/common.interfaces'; -export const CA_ENDPOINTS = { - login: `${CA_BASE_URL}/tods/api/lgn`, - logout: `${CA_BASE_URL}/tods/api/lgout`, +export interface CanadianBrandEnvironment { + brand: Brand; + host: string; + baseUrl: string; + origin: 'SPA'; + endpoints: { + login: string; + logout: string; + vehicleList:string; + vehicleInfo: string; + status: string; + remoteStatus: string; + lock: string; + unlock: string; + start: string; + stop: string; + locate: string; + hornlight: string; + verifyAccountToken: string; + verifyPin: string; + verifyToken: string; + } +} + +const getEndpoints = (baseUrl: string) => ({ + login: `${baseUrl}/tods/api/lgn`, + logout: `${baseUrl}/tods/api/lgout`, // Vehicle - vehicleList: `${CA_BASE_URL}/tods/api/vhcllst`, - vehicleInfo: `${CA_BASE_URL}/tods/api/sltvhcl`, - status: `${CA_BASE_URL}/tods/api/lstvhclsts`, - remoteStatus: `${CA_BASE_URL}/tods/api/rltmvhclsts`, + vehicleList: `${baseUrl}/tods/api/vhcllst`, + vehicleInfo: `${baseUrl}/tods/api/sltvhcl`, + status: `${baseUrl}/tods/api/lstvhclsts`, + remoteStatus: `${baseUrl}/tods/api/rltmvhclsts`, // Car commands with preauth (PIN) - lock: `${CA_BASE_URL}/tods/api/drlck`, - unlock: `${CA_BASE_URL}/tods/api/drulck`, - start: `${CA_BASE_URL}/tods/api/evc/rfon`, - stop: `${CA_BASE_URL}/tods/api/evc/rfoff`, - locate: `${CA_BASE_URL}/tods/api/fndmcr`, - hornlight: `${CA_BASE_URL}/tods/api/hornlight`, + lock: `${baseUrl}/tods/api/drlck`, + unlock: `${baseUrl}/tods/api/drulck`, + start: `${baseUrl}/tods/api/evc/rfon`, + stop: `${baseUrl}/tods/api/evc/rfoff`, + locate: `${baseUrl}/tods/api/fndmcr`, + hornlight: `${baseUrl}/tods/api/hornlight`, // System - verifyAccountToken: `${CA_BASE_URL}/tods/api/vrfyacctkn`, - verifyPin: `${CA_BASE_URL}/tods/api/vrfypin`, - verifyToken: `${CA_BASE_URL}/tods/api/vrfytnc`, + verifyAccountToken: `${baseUrl}/tods/api/vrfyacctkn`, + verifyPin: `${baseUrl}/tods/api/vrfypin`, + verifyToken: `${baseUrl}/tods/api/vrfytnc`, +}); + +const getEnvironment = (host: string): Omit => { + const baseUrl = `https://${host}`; + return { + host, + baseUrl, + origin: 'SPA', + endpoints: Object.freeze(getEndpoints(baseUrl)), + }; +}; + +const getHyundaiEnvironment = (): CanadianBrandEnvironment => { + return { + brand: 'hyundai', + ...getEnvironment('mybluelink.ca') + }; }; + +const getKiaEnvironment = (): CanadianBrandEnvironment => { + return { + brand: 'hyundai', + ...getEnvironment('myuvo.ca') + }; +}; + +export const getBrandEnvironment = (brand: Brand): CanadianBrandEnvironment => { + switch (brand) { + case 'hyundai': + return Object.freeze(getHyundaiEnvironment()); + case 'kia': + return Object.freeze(getKiaEnvironment()); + default: + throw new Error(`Constructor ${brand} is not managed.`); + } +}; \ No newline at end of file diff --git a/src/constants/europe.ts b/src/constants/europe.ts index 56c2d9b..7979c14 100644 --- a/src/constants/europe.ts +++ b/src/constants/europe.ts @@ -1,20 +1,78 @@ +import { Brand } from '../interfaces/common.interfaces'; +import hyundaiStamps from '../tools/european.hyundai.token.collection'; +import kiaStamps from '../tools/european.kia.token.collection'; -export const EU_API_HOST = 'prd.eu-ccapi.hyundai.com:8080'; -export const EU_BASE_URL = `https://${EU_API_HOST}`; +export type EULanguages = 'cs'|'da'|'nl'|'en'|'fi'|'fr'|'de'|'it'|'pl'|'hu'|'no'|'sk'|'es'|'sv'; +export const EU_LANGUAGES: EULanguages[] = ['cs', 'da', 'nl', 'en', 'fi', 'fr', 'de', 'it', 'pl', 'hu', 'no', 'sk', 'es', 'sv']; +export const DEFAULT_LANGUAGE: EULanguages = 'en'; -export const EU_CLIENT_ID = '6d477c38-3ca4-4cf3-9557-2a1929a94654'; -export const EU_APP_ID = '99cfff84-f4e2-4be8-a5ed-e5b755eb6581'; +export interface EuropeanBrandEnvironment { + brand: Brand; + host: string; + baseUrl: string; + clientId: string; + appId: string; + endpoints: { + session: string; + login: string; + language: string; + redirectUri: string; + token: string; + }, + basicToken: string; + GCMSenderID: string; + stamp: () => string; +} -export const EU_ENDPOINTS = { - session: `${EU_BASE_URL}/api/v1/user/oauth2/authorize?response_type=code&state=test&client_id=${EU_CLIENT_ID}&redirect_uri=${EU_BASE_URL}/api/v1/user/oauth2/redirect`, - login: `${EU_BASE_URL}/api/v1/user/signin`, - language: `${EU_BASE_URL}/api/v1/user/language`, - redirectUri: `${EU_BASE_URL}/api/v1/user/oauth2/redirect`, - token: `${EU_BASE_URL}/api/v1/user/oauth2/token`, +const getEndpoints = (baseUrl: string, clientId: string) => ({ + session: `${baseUrl}/api/v1/user/oauth2/authorize?response_type=code&state=test&client_id=${clientId}&redirect_uri=${baseUrl}/api/v1/user/oauth2/redirect`, + login: `${baseUrl}/api/v1/user/signin`, + language: `${baseUrl}/api/v1/user/language`, + redirectUri: `${baseUrl}/api/v1/user/oauth2/redirect`, + token: `${baseUrl}/api/v1/user/oauth2/token`, +}); + +const getHyundaiEnvironment = (): EuropeanBrandEnvironment => { + const host = 'prd.eu-ccapi.hyundai.com:8080'; + const baseUrl = `https://${host}`; + const clientId = '6d477c38-3ca4-4cf3-9557-2a1929a94654'; + return { + brand: 'hyundai', + host, + baseUrl, + clientId, + appId: '99cfff84-f4e2-4be8-a5ed-e5b755eb6581', + endpoints: Object.freeze(getEndpoints(baseUrl, clientId)), + basicToken: 'Basic NmQ0NzdjMzgtM2NhNC00Y2YzLTk1NTctMmExOTI5YTk0NjU0OktVeTQ5WHhQekxwTHVvSzB4aEJDNzdXNlZYaG10UVI5aVFobUlGampvWTRJcHhzVg==', + GCMSenderID: '199360397125', + stamp: () => hyundaiStamps[Math.floor(Math.random() * hyundaiStamps.length)] + }; }; -export const EU_CONSTANTS = { - basicToken: - 'Basic NmQ0NzdjMzgtM2NhNC00Y2YzLTk1NTctMmExOTI5YTk0NjU0OktVeTQ5WHhQekxwTHVvSzB4aEJDNzdXNlZYaG10UVI5aVFobUlGampvWTRJcHhzVg==', - GCMSenderID: '199360397125', +const getKiaEnvironment = (): EuropeanBrandEnvironment => { + const host = 'prd.eu-ccapi.kia.com:8080'; + const baseUrl = `https://${host}`; + const clientId = 'fdc85c00-0a2f-4c64-bcb4-2cfb1500730a'; + return { + brand: 'kia', + host, + baseUrl, + clientId, + appId: '693a33fa-c117-43f2-ae3b-61a02d24f417', + endpoints: Object.freeze(getEndpoints(baseUrl, clientId)), + basicToken: 'Basic ZmRjODVjMDAtMGEyZi00YzY0LWJjYjQtMmNmYjE1MDA3MzBhOnNlY3JldA==', + GCMSenderID: '199360397125', + stamp: () => kiaStamps[Math.floor(Math.random() * kiaStamps.length)] + }; }; + +export const getBrandEnvironment = (brand: Brand): EuropeanBrandEnvironment => { + switch (brand) { + case 'hyundai': + return Object.freeze(getHyundaiEnvironment()); + case 'kia': + return Object.freeze(getKiaEnvironment()); + default: + throw new Error(`Constructor ${brand} is not managed.`); + } +}; \ No newline at end of file diff --git a/src/controllers/american.controller.ts b/src/controllers/american.controller.ts index 4c41c9e..549dacf 100644 --- a/src/controllers/american.controller.ts +++ b/src/controllers/american.controller.ts @@ -6,79 +6,101 @@ import AmericanVehicle from '../vehicles/american.vehicle'; import { SessionController } from './controller'; import logger from '../logger'; -import { BASE_URL, CLIENT_ID, CLIENT_SECRET, API_HOST } from '../constants/america'; +import { getBrandEnvironment, AmericaBrandEnvironment } from '../constants/america'; import { VehicleRegisterOptions } from '../interfaces/common.interfaces'; -export class AmericanController extends SessionController { - constructor(userConfig: BlueLinkyConfig) { +import { manageBluelinkyError } from '../tools/common.tools'; +import { REGIONS } from '../constants'; + +export interface AmericanBlueLinkyConfig extends BlueLinkyConfig { + region: REGIONS.US; +} + +export class AmericanController extends SessionController { + private _environment: AmericaBrandEnvironment; + + constructor(userConfig: AmericanBlueLinkyConfig) { super(userConfig); + this._environment = getBrandEnvironment(userConfig.brand); logger.debug('US Controller created'); } + public get environment(): AmericaBrandEnvironment { + return this._environment; + } + private vehicles: Array = []; public async refreshAccessToken(): Promise { const shouldRefreshToken = Math.floor(Date.now() / 1000 - this.session.tokenExpiresAt) >= -10; - if (this.session.refreshToken && shouldRefreshToken) { - logger.debug('refreshing token'); - const response = await got(`${BASE_URL}/v2/ac/oauth/token/refresh`, { + try { + if (this.session.refreshToken && shouldRefreshToken) { + logger.debug('refreshing token'); + const response = await got(`${this.environment.baseUrl}/v2/ac/oauth/token/refresh`, { + method: 'POST', + body: { + 'refresh_token': this.session.refreshToken, + }, + headers: { + 'client_secret': this.environment.clientSecret, + 'client_id': this.environment.clientId, + }, + json: true, + }); + + logger.debug(response.body); + this.session.accessToken = response.body.access_token; + this.session.refreshToken = response.body.refresh_token; + this.session.tokenExpiresAt = Math.floor( + +new Date() / 1000 + parseInt(response.body.expires_in) + ); + + logger.debug('Token refreshed'); + return 'Token refreshed'; + } + + logger.debug('Token not expired, no need to refresh'); + return 'Token not expired, no need to refresh'; + } catch (err) { + throw manageBluelinkyError(err, 'AmericanController.refreshAccessToken'); + } + } + + // TODO: come up with a better return value? + public async login(): Promise { + logger.debug('Logging in to the API'); + try { + const response = await got(`${this.environment.baseUrl}/v2/ac/oauth/token`, { method: 'POST', body: { - 'refresh_token': this.session.refreshToken, + username: this.userConfig.username, + password: this.userConfig.password, }, headers: { - 'client_secret': CLIENT_SECRET, - 'client_id': CLIENT_ID, + 'User-Agent': 'PostmanRuntime/7.26.10', + 'client_id': this.environment.clientId, + 'client_secret': this.environment.clientSecret, }, json: true, }); logger.debug(response.body); + + if (response.statusCode !== 200) { + return 'login bad'; + } + this.session.accessToken = response.body.access_token; this.session.refreshToken = response.body.refresh_token; this.session.tokenExpiresAt = Math.floor( +new Date() / 1000 + parseInt(response.body.expires_in) ); - logger.debug('Token refreshed'); - return 'Token refreshed'; - } - - logger.debug('Token not expired, no need to refresh'); - return 'Token not expired, no need to refresh'; - } - - // TODO: come up with a better return value? - public async login(): Promise { - logger.debug('Logging in to the API'); - - const response = await got(`${BASE_URL}/v2/ac/oauth/token`, { - method: 'POST', - body: { - username: this.userConfig.username, - password: this.userConfig.password, - }, - headers: { - 'client_secret': CLIENT_SECRET, - 'client_id': CLIENT_ID, - }, - json: true, - }); - - logger.debug(response.body); - - if (response.statusCode !== 200) { - return 'login bad'; + return 'login good'; + } catch (err) { + throw manageBluelinkyError(err, 'AmericanController.login'); } - - this.session.accessToken = response.body.access_token; - this.session.refreshToken = response.body.refresh_token; - this.session.tokenExpiresAt = Math.floor( - +new Date() / 1000 + parseInt(response.body.expires_in) - ); - - return 'login good'; } public async logout(): Promise { @@ -86,40 +108,43 @@ export class AmericanController extends SessionController { } async getVehicles(): Promise> { - const response = await got(`${BASE_URL}/ac/v2/enrollment/details/${this.userConfig.username}`, { - method: 'GET', - headers: { - 'access_token': this.session.accessToken, - 'client_id': CLIENT_ID, - 'Host': API_HOST, - 'User-Agent': 'okhttp/3.12.0', - 'payloadGenerated': '20200226171938', - 'includeNonConnectedVehicles': 'Y', - }, - }); - - const data = JSON.parse(response.body); - - if (data.enrolledVehicleDetails === undefined) { - this.vehicles = []; + try { + const response = await got(`${this.environment.baseUrl}/ac/v2/enrollment/details/${this.userConfig.username}`, { + method: 'GET', + headers: { + 'access_token': this.session.accessToken, + 'client_id': this.environment.clientId, + 'Host': this.environment.host, + 'User-Agent': 'okhttp/3.12.0', + 'payloadGenerated': '20200226171938', + 'includeNonConnectedVehicles': 'Y', + }, + }); + + const data = JSON.parse(response.body); + + if (data.enrolledVehicleDetails === undefined) { + this.vehicles = []; + return this.vehicles; + } + + this.vehicles = data.enrolledVehicleDetails.map(vehicle => { + const vehicleInfo = vehicle.vehicleDetails; + const vehicleConfig = { + nickname: vehicleInfo.nickName, + name: vehicleInfo.nickName, + vin: vehicleInfo.vin, + regDate: vehicleInfo.enrollmentDate, + brandIndicator: vehicleInfo.brandIndicator, + regId: vehicleInfo.regid, + generation: vehicleInfo.modelYear > 2016 ? '2' : '1', + } as VehicleRegisterOptions; + return new AmericanVehicle(vehicleConfig, this); + }); + return this.vehicles; + } catch (err) { + throw manageBluelinkyError(err, 'AmericanController.getVehicles'); } - - data.enrolledVehicleDetails.forEach(vehicle => { - const vehicleInfo = vehicle.vehicleDetails; - const vehicleConfig = { - nickname: vehicleInfo.nickName, - name: vehicleInfo.nickName, - vin: vehicleInfo.vin, - regDate: vehicleInfo.enrollmentDate, - brandIndicator: vehicleInfo.brandIndicator, - regId: vehicleInfo.regid, - generation: vehicleInfo.modelYear > 2016 ? '2' : '1', - } as VehicleRegisterOptions; - - this.vehicles.push(new AmericanVehicle(vehicleConfig, this)); - }); - - return this.vehicles; } } diff --git a/src/controllers/canadian.controller.ts b/src/controllers/canadian.controller.ts index d70d9b0..86fcdc5 100644 --- a/src/controllers/canadian.controller.ts +++ b/src/controllers/canadian.controller.ts @@ -1,18 +1,30 @@ import got from 'got'; import { BlueLinkyConfig } from '../interfaces/common.interfaces'; -import { CA_ENDPOINTS, CLIENT_ORIGIN } from '../constants/canada'; +import { CanadianBrandEnvironment, getBrandEnvironment } from '../constants/canada'; import { Vehicle } from '../vehicles/vehicle'; import CanadianVehicle from '../vehicles/canadian.vehicle'; import { SessionController } from './controller'; import logger from '../logger'; import { VehicleRegisterOptions } from '../interfaces/common.interfaces'; +import { manageBluelinkyError } from '../tools/common.tools'; +import { REGIONS } from '../constants'; -export class CanadianController extends SessionController { +export interface CanadianBlueLinkyConfig extends BlueLinkyConfig { + region: REGIONS.CA; +} + +export class CanadianController extends SessionController { + private _environment: CanadianBrandEnvironment; - constructor(userConfig: BlueLinkyConfig) { + constructor(userConfig: CanadianBlueLinkyConfig) { super(userConfig); logger.debug('CA Controller created'); + this._environment = getBrandEnvironment(userConfig.brand); + } + + public get environment() : CanadianBrandEnvironment { + return this._environment; } private vehicles: Array = []; @@ -39,7 +51,7 @@ export class CanadianController extends SessionController { public async login(): Promise { logger.info('Begin login request'); try { - const response = await this.request(CA_ENDPOINTS.login, { + const response = await this.request(this.environment.endpoints.login, { loginId: this.userConfig.username, password: this.userConfig.password, }); @@ -63,7 +75,7 @@ export class CanadianController extends SessionController { async getVehicles(): Promise> { logger.info('Begin getVehicleList request'); try { - const response = await this.request(CA_ENDPOINTS.vehicleList, {}); + const response = await this.request(this.environment.endpoints.vehicleList, {}); const data = response.result; if (data.vehicles === undefined) { @@ -106,7 +118,7 @@ export class CanadianController extends SessionController { method: 'POST', json: true, headers: { - from: CLIENT_ORIGIN, + from: this.environment.origin, language: 1, offset: this.timeOffset, accessToken: this.session.accessToken, @@ -123,7 +135,7 @@ export class CanadianController extends SessionController { return response.body; } catch (err) { - throw err.message; + throw manageBluelinkyError(err, 'CanadianController'); } } } diff --git a/src/controllers/controller.ts b/src/controllers/controller.ts index 7f43b0e..bcf1510 100644 --- a/src/controllers/controller.ts +++ b/src/controllers/controller.ts @@ -2,7 +2,7 @@ import { Vehicle } from '../vehicles/vehicle'; import { Session } from '../interfaces/common.interfaces'; import { BlueLinkyConfig } from '../interfaces/common.interfaces'; // changed this to interface so we can have option things? -export abstract class SessionController { +export abstract class SessionController { abstract login(): Promise; abstract logout(): Promise; abstract getVehicles(): Promise>; @@ -14,9 +14,6 @@ export abstract class SessionController { deviceId: '', tokenExpiresAt: 0, }; - public userConfig: BlueLinkyConfig; - constructor(userConfig: BlueLinkyConfig) { - this.userConfig = userConfig; - } + constructor(public readonly userConfig: T) { } } diff --git a/src/controllers/european.controller.ts b/src/controllers/european.controller.ts index 139020a..b6196bd 100644 --- a/src/controllers/european.controller.ts +++ b/src/controllers/european.controller.ts @@ -1,8 +1,8 @@ -import { EU_CONSTANTS, EU_BASE_URL, EU_API_HOST, EU_CLIENT_ID } from './../constants/europe'; +import { getBrandEnvironment, EuropeanBrandEnvironment, DEFAULT_LANGUAGE, EULanguages, EU_LANGUAGES } from './../constants/europe'; import { BlueLinkyConfig, Session } from './../interfaces/common.interfaces'; import * as pr from 'push-receiver'; import got from 'got'; -import { ALL_ENDPOINTS } from '../constants'; +import { REGIONS } from '../constants'; import { Vehicle } from '../vehicles/vehicle'; import EuropeanVehicle from '../vehicles/european.vehicle'; import { SessionController } from './controller'; @@ -12,35 +12,48 @@ import { URLSearchParams } from 'url'; import { CookieJar } from 'tough-cookie'; import { VehicleRegisterOptions } from '../interfaces/common.interfaces'; -import { getStamp } from '../tools/european.tools'; +import { asyncMap, manageBluelinkyError, uuidV4 } from '../tools/common.tools'; -export class EuropeanController extends SessionController { - constructor(userConfig: BlueLinkyConfig) { +export interface EuropeBlueLinkyConfig extends BlueLinkyConfig { + language?: EULanguages; + region: REGIONS.EU; +} + +interface EuropeanVehicleDescription { + nickname: string; + vehicleName: string; + regDate: string; + vehicleId: string; +} + +export class EuropeanController extends SessionController { + private _environment: EuropeanBrandEnvironment; + constructor(userConfig: EuropeBlueLinkyConfig) { super(userConfig); + this.userConfig.language = userConfig.language ?? DEFAULT_LANGUAGE; + if (!EU_LANGUAGES.includes(this.userConfig.language)) { + throw new Error(`The language code ${this.userConfig.language} is not managed. Only ${EU_LANGUAGES.join(', ')} are.`); + } + this.session.deviceId = uuidV4(); + this._environment = getBrandEnvironment(userConfig.brand); logger.debug('EU Controller created'); + } - this.session.deviceId = this.uuidv4(); + public get environment(): EuropeanBrandEnvironment { + return this._environment; } - session: Session = { + public session: Session = { accessToken: undefined, refreshToken: undefined, controlToken: undefined, - deviceId: this.uuidv4(), + deviceId: uuidV4(), tokenExpiresAt: 0, controlTokenExpiresAt: 0, }; private vehicles: Array = []; - private uuidv4(): string { - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { - const r = (Math.random() * 16) | 0, - v = c == 'x' ? r : (r & 0x3) | 0x8; - return v.toString(16); - }); - } - public async refreshAccessToken(): Promise { const shouldRefreshToken = Math.floor(Date.now() / 1000 - this.session.tokenExpiresAt) >= -10; @@ -59,28 +72,32 @@ export class EuropeanController extends SessionController { formData.append('redirect_uri', 'https://www.getpostman.com/oauth2/callback'); // Oversight from Hyundai developers formData.append('refresh_token', this.session.refreshToken); - const response = await got(ALL_ENDPOINTS.EU.token, { - method: 'POST', - headers: { - 'Authorization': EU_CONSTANTS.basicToken, - 'Content-Type': 'application/x-www-form-urlencoded', - 'Host': EU_API_HOST, - 'Connection': 'Keep-Alive', - 'Accept-Encoding': 'gzip', - 'User-Agent': 'okhttp/3.10.0', - }, - body: formData.toString(), - throwHttpErrors: false, - }); - - if (response.statusCode !== 200) { - logger.debug(`Refresh token failed: ${response.body}`); - return `Refresh token failed: ${response.body}`; - } + try { + const response = await got(this.environment.endpoints.token, { + method: 'POST', + headers: { + 'Authorization': this.environment.basicToken, + 'Content-Type': 'application/x-www-form-urlencoded', + 'Host': this.environment.host, + 'Connection': 'Keep-Alive', + 'Accept-Encoding': 'gzip', + 'User-Agent': 'okhttp/3.10.0', + }, + body: formData.toString(), + throwHttpErrors: false, + }); + + if (response.statusCode !== 200) { + logger.debug(`Refresh token failed: ${response.body}`); + return `Refresh token failed: ${response.body}`; + } - const responseBody = JSON.parse(response.body); - this.session.accessToken = 'Bearer ' + responseBody.access_token; - this.session.tokenExpiresAt = Math.floor(Date.now() / 1000 + responseBody.expires_in); + const responseBody = JSON.parse(response.body); + this.session.accessToken = 'Bearer ' + responseBody.access_token; + this.session.tokenExpiresAt = Math.floor(Date.now() / 1000 + responseBody.expires_in); + } catch (err) { + throw manageBluelinkyError(err, 'EuropeController.refreshAccessToken'); + } logger.debug('Token refreshed'); return 'Token refreshed'; @@ -91,34 +108,40 @@ export class EuropeanController extends SessionController { throw 'Token not set'; } - const response = await got(`${EU_BASE_URL}/api/v1/user/pin`, { - method: 'PUT', - headers: { - 'Authorization': this.session.accessToken, - 'Content-Type': 'application/json', - }, - body: { - deviceId: this.session.deviceId, - pin: this.userConfig.pin, - }, - json: true, - }); - - this.session.controlToken = 'Bearer ' + response.body.controlToken; - this.session.controlTokenExpiresAt = Math.floor(Date.now() / 1000 + response.body.expiresTime); - return 'PIN entered OK, The pin is valid for 10 minutes'; + try { + const response = await got(`${this.environment.baseUrl}/api/v1/user/pin`, { + method: 'PUT', + headers: { + 'Authorization': this.session.accessToken, + 'Content-Type': 'application/json', + }, + body: { + deviceId: this.session.deviceId, + pin: this.userConfig.pin, + }, + json: true, + }); + + this.session.controlToken = 'Bearer ' + response.body.controlToken; + this.session.controlTokenExpiresAt = Math.floor(Date.now() / 1000 + response.body.expiresTime); + return 'PIN entered OK, The pin is valid for 10 minutes'; + } catch (err) { + throw manageBluelinkyError(err, 'EuropeController.pin'); + } } public async login(): Promise { try { // request cookie via got and store it to the cookieJar const cookieJar = new CookieJar(); - await got(ALL_ENDPOINTS.EU.session, { cookieJar }); + await got(this.environment.endpoints.session, { cookieJar }); + logger.debug('@EuropeController.login: Initialized the auth session'); // required by the api to set lang - await got(ALL_ENDPOINTS.EU.language, { method: 'POST', body: '{"lang":"en"}', cookieJar }); + await got(this.environment.endpoints.language, { method: 'POST', body: `{"lang":"${this.userConfig.language}"}`, cookieJar }); + logger.debug(`@EuropeController.login: defined the language to ${this.userConfig.language}`); - const authCodeResponse = await got(ALL_ENDPOINTS.EU.login, { + const authCodeResponse = await got(this.environment.endpoints.login, { method: 'POST', json: true, body: { @@ -128,28 +151,28 @@ export class EuropeanController extends SessionController { cookieJar, }); - logger.debug(authCodeResponse.body); let authorizationCode; if (authCodeResponse) { const regexMatch = /code=([^&]*)/g.exec(authCodeResponse.body.redirectUrl); if (regexMatch !== null) { authorizationCode = regexMatch[1]; } else { - throw new Error('@EuropeControllerLogin: AuthCode was not found'); + throw new Error('@EuropeController.login: AuthCode was not found, you probably need to migrate your account.'); } } + logger.debug('@EuropeController.login: Authenticated properly with user and password'); - const credentials = await pr.register(EU_CONSTANTS.GCMSenderID); - const notificationReponse = await got(`${EU_BASE_URL}/api/v1/spa/notifications/register`, { + const credentials = await pr.register(this.environment.GCMSenderID); + const notificationReponse = await got(`${this.environment.baseUrl}/api/v1/spa/notifications/register`, { method: 'POST', headers: { - 'ccsp-service-id': EU_CLIENT_ID, + 'ccsp-service-id': this.environment.clientId, 'Content-Type': 'application/json;charset=UTF-8', - 'Host': EU_API_HOST, + 'Host': this.environment.host, 'Connection': 'Keep-Alive', 'Accept-Encoding': 'gzip', 'User-Agent': 'okhttp/3.10.0', - 'Stamp': await getStamp(), + 'Stamp': this.environment.stamp(), }, body: { pushRegId: credentials.gcm.token, @@ -162,42 +185,44 @@ export class EuropeanController extends SessionController { if (notificationReponse) { this.session.deviceId = notificationReponse.body.resMsg.deviceId; } + logger.debug('@EuropeController.login: Device registered'); const formData = new URLSearchParams(); formData.append('grant_type', 'authorization_code'); - formData.append('redirect_uri', ALL_ENDPOINTS.EU.redirectUri); + formData.append('redirect_uri', this.environment.endpoints.redirectUri); formData.append('code', authorizationCode); - const response = await got(ALL_ENDPOINTS.EU.token, { + const response = await got(this.environment.endpoints.token, { method: 'POST', headers: { - 'Authorization': EU_CONSTANTS.basicToken, + 'Authorization': this.environment.basicToken, 'Content-Type': 'application/x-www-form-urlencoded', - 'Host': EU_API_HOST, + 'Host': this.environment.host, 'Connection': 'Keep-Alive', 'Accept-Encoding': 'gzip', 'User-Agent': 'okhttp/3.10.0', 'grant_type': 'authorization_code', - 'Stamp': await getStamp(), + 'Stamp': this.environment.stamp(), }, body: formData.toString(), cookieJar, }); if (response.statusCode !== 200) { - throw `Get token failed: ${response.body}`; + throw new Error(`@EuropeController.login: Could not manage to get token: ${response.body}`); } if (response) { const responseBody = JSON.parse(response.body); - this.session.accessToken = 'Bearer ' + responseBody.access_token; + this.session.accessToken = `Bearer ${responseBody.access_token}`; this.session.refreshToken = responseBody.refresh_token; this.session.tokenExpiresAt = Math.floor(Date.now() / 1000 + responseBody.expires_in); } + logger.debug('@EuropeController.login: Session defined properly'); return 'Login success'; } catch (err) { - throw err.message; + throw manageBluelinkyError(err, 'EuropeController.login'); } } @@ -210,57 +235,50 @@ export class EuropeanController extends SessionController { throw 'Token not set'; } - const response = await got(`${EU_BASE_URL}/api/v1/spa/vehicles`, { - method: 'GET', - headers: { - 'Authorization': this.session.accessToken, - 'ccsp-device-id': this.session.deviceId, - 'Stamp': await getStamp(), - }, - json: true, - }); - - this.vehicles = []; - - await this.asyncForEach(response.body.resMsg.vehicles, async v => { - const vehicleProfileReponse = await got( - `${EU_BASE_URL}/api/v1/spa/vehicles/${v.vehicleId}/profile`, - { - method: 'GET', - headers: { - 'Authorization': this.session.accessToken, - 'ccsp-device-id': this.session.deviceId, - 'Stamp': await getStamp(), - }, - json: true, - } - ); - - const vehicleProfile = vehicleProfileReponse.body.resMsg; - - const vehicleConfig = { - nickname: v.nickname, - name: v.vehicleName, - regDate: v.regDate, - brandIndicator: 'H', - id: v.vehicleId, - vin: vehicleProfile.vinInfo[0].basic.vin, - generation: vehicleProfile.vinInfo[0].basic.modelYear, - } as VehicleRegisterOptions; - - this.vehicles.push(new EuropeanVehicle(vehicleConfig, this)); - logger.debug(`Added vehicle ${vehicleConfig.id}`); - }); + try { + const response = await got(`${this.environment.baseUrl}/api/v1/spa/vehicles`, { + method: 'GET', + headers: { + 'Authorization': this.session.accessToken, + 'ccsp-device-id': this.session.deviceId, + 'Stamp': this.environment.stamp(), + }, + json: true, + }); + + this.vehicles = await asyncMap(response.body.resMsg.vehicles, async v => { + const vehicleProfileReponse = await got( + `${this.environment.baseUrl}/api/v1/spa/vehicles/${v.vehicleId}/profile`, + { + method: 'GET', + headers: { + 'Authorization': this.session.accessToken, + 'ccsp-device-id': this.session.deviceId, + 'Stamp': this.environment.stamp(), + }, + json: true, + } + ); + + const vehicleProfile = vehicleProfileReponse.body.resMsg; + + const vehicleConfig = { + nickname: v.nickname, + name: v.vehicleName, + regDate: v.regDate, + brandIndicator: 'H', + id: v.vehicleId, + vin: vehicleProfile.vinInfo[0].basic.vin, + generation: vehicleProfile.vinInfo[0].basic.modelYear, + } as VehicleRegisterOptions; + + logger.debug(`@EuropeController.getVehicles: Added vehicle ${vehicleConfig.id}`); + return new EuropeanVehicle(vehicleConfig, this); + }); + } catch (err) { + throw manageBluelinkyError(err, 'EuropeController.getVehicles'); + } return this.vehicles; } - - // TODO: type this or replace it with a normal loop - /* eslint-disable @typescript-eslint/no-explicit-any */ - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types - async asyncForEach(array: any, callback: any): Promise { - for (let index = 0; index < array.length; index++) { - await callback(array[index], index, array); - } - } } diff --git a/src/index.ts b/src/index.ts index 2dab133..a06013f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,50 +1,62 @@ -import { AmericanController } from './controllers/american.controller'; -import { EuropeanController } from './controllers/european.controller'; -import { CanadianController } from './controllers/canadian.controller'; -import { SessionController } from './controllers/controller'; +import { AmericanBlueLinkyConfig, AmericanController } from './controllers/american.controller'; +import { EuropeanController, EuropeBlueLinkyConfig } from './controllers/european.controller'; +import { CanadianBlueLinkyConfig, CanadianController } from './controllers/canadian.controller'; import { EventEmitter } from 'events'; import logger from './logger'; -import { BlueLinkyConfig, Session } from './interfaces/common.interfaces'; +import { Session } from './interfaces/common.interfaces'; import { REGIONS } from './constants'; +import AmericanVehicle from './vehicles/american.vehicle'; +import EuropeanVehicle from './vehicles/european.vehicle'; +import CanadianVehicle from './vehicles/canadian.vehicle'; +import { SessionController } from './controllers/controller'; import { Vehicle } from './vehicles/vehicle'; -class BlueLinky extends EventEmitter { +type BluelinkyConfigRegions = AmericanBlueLinkyConfig|CanadianBlueLinkyConfig|EuropeBlueLinkyConfig; + +const DEFAULT_CONFIG = { + username: '', + password: '', + region: REGIONS.US, + brand: 'hyundai', + autoLogin: true, + pin: '1234', + vin: '', + vehicleId: undefined, +}; + +class BlueLinky< + T extends BluelinkyConfigRegions = AmericanBlueLinkyConfig, + REGION = T['region'], + VEHICLE_TYPE extends Vehicle = (REGION extends REGIONS.US ? AmericanVehicle : REGION extends REGIONS.CA ? CanadianVehicle : EuropeanVehicle) +> extends EventEmitter { private controller: SessionController; - private vehicles: Array = []; - - private config: BlueLinkyConfig = { - username: '', - password: '', - region: REGIONS.US, - autoLogin: true, - pin: '1234', - vin: '', - vehicleId: undefined, - }; - - constructor(config: BlueLinkyConfig) { + private vehicles: Array = []; + + private config: T; + + constructor(config: T) { super(); + // merge configs + this.config = { + ...DEFAULT_CONFIG, + ...config, + }; + switch (config.region) { case REGIONS.EU: - this.controller = new EuropeanController(config); + this.controller = new EuropeanController(this.config as EuropeBlueLinkyConfig); break; case REGIONS.US: - this.controller = new AmericanController(config); + this.controller = new AmericanController(this.config as AmericanBlueLinkyConfig); break; case REGIONS.CA: - this.controller = new CanadianController(config); + this.controller = new CanadianController(this.config as CanadianBlueLinkyConfig); break; default: throw new Error('Your region is not supported yet.'); } - // merge configs - this.config = { - ...this.config, - ...config, - }; - if (config.autoLogin === undefined) { this.config.autoLogin = true; } @@ -52,6 +64,16 @@ class BlueLinky extends EventEmitter { this.onInit(); } + on(event: 'ready', fnc: (vehicles: VEHICLE_TYPE[]) => void): this; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + on(event: 'error', fnc: (error: any) => void): this; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + on(event: string|symbol, listener: (...args: any[]) => void): this { + return super.on(event, listener); + } + private onInit(): void { if (this.config.autoLogin) { logger.debug('Bluelinky is logging in automatically, to disable use autoLogin: false'); @@ -65,7 +87,7 @@ class BlueLinky extends EventEmitter { const response = await this.controller.login(); // get all cars from the controller - this.vehicles = await this.controller.getVehicles(); + this.vehicles = await this.getVehicles(); logger.debug(`Found ${this.vehicles.length} on the account`); @@ -77,11 +99,11 @@ class BlueLinky extends EventEmitter { } } - async getVehicles(): Promise> { - return this.controller.getVehicles() || []; + async getVehicles(): Promise { + return (await this.controller.getVehicles() as unknown[]) as VEHICLE_TYPE[] || []; } - public getVehicle(input: string): Vehicle | undefined { + public getVehicle(input: string): VEHICLE_TYPE | undefined { try { const foundCar = this.vehicles.find(car => { return car.vin() === input || car.id() === input; @@ -108,6 +130,10 @@ class BlueLinky extends EventEmitter { public getSession(): Session | null { return this.controller.session; } + + public get cachedVehicles(): VEHICLE_TYPE[] { + return this.vehicles ?? []; + } } export default BlueLinky; diff --git a/src/interfaces/common.interfaces.ts b/src/interfaces/common.interfaces.ts index 3fb8bb0..60add2c 100644 --- a/src/interfaces/common.interfaces.ts +++ b/src/interfaces/common.interfaces.ts @@ -1,8 +1,13 @@ +import { REGIONS } from '../constants'; + +export type Brand = 'kia' | 'hyundai'; + // config export interface BlueLinkyConfig { username: string | undefined; password: string | undefined; - region: string | undefined; + region: REGIONS | undefined; + brand: Brand; autoLogin?: boolean; pin: string | undefined; vin?: string | undefined; @@ -31,6 +36,11 @@ export enum EVPlugTypes { STATION = 3 } +export enum EVChargeModeTypes { + FAST = 0, + SLOW = 1, +} + // Status remapped export interface VehicleStatus { engine: { @@ -48,7 +58,7 @@ export interface VehicleStatus { estimatedStationChargeDuration?: number; batteryCharge12v?: number; batteryChargeHV?: number; - adaptiveCruiseControl: boolean; + accessory: boolean; }; climate: { active: boolean; @@ -77,6 +87,7 @@ export interface VehicleStatus { all: boolean; }; }; + lastupdate: Date } // TODO: fix/update @@ -422,3 +433,64 @@ export interface VehicleRegisterOptions { id: string; generation: string; } + +export type DeepPartial = { + [P in keyof T]?: DeepPartial; +}; + +export interface VehicleMonthlyReport { + start: string; // format YYYYMMDD, eg: 20210210 + end: string; // format YYYYMMDD, eg: 20210312 + driving: { + distance: number; + startCount: number; + durations: { + drive: number; + idle: number; + } + }, + breakdown: { + ecuIdx: string; + ecuStatus: string; + }[], + vehicleStatus: { + tpms: boolean; + tirePressure: { + all: boolean; + } + } +} + +export interface VehicleTargetSOC { + type: EVChargeModeTypes; + distance: number; + targetLevel: number; +} + +export interface VehicleDayTrip { + dayRaw: string; + tripsCount: number; + distance: number; + durations: { + drive: number; + idle: number; + }; + speed: { + avg: number; + max: number; + }; + trips: { + timeRaw: string; + start: Date; + end: Date; + durations: { + drive: number; + idle: number; + }; + speed: { + avg: number; + max: number; + }; + distance: number; + }[]; +} \ No newline at end of file diff --git a/src/interfaces/european.interfaces.ts b/src/interfaces/european.interfaces.ts index 9ffd0ea..3349935 100644 --- a/src/interfaces/european.interfaces.ts +++ b/src/interfaces/european.interfaces.ts @@ -4,3 +4,20 @@ export interface EuropeanEndpoints { redirect_uri: string; token: string; } + +export interface EUPOIInformation { + phone: string; + waypointID: number; + lang: 1; + src: 'HERE'; + coords: { + lat: number; + alt: number; + long: number; + type: 0; + }, + addr: string; + zip: string; + placeid: string; + name: string; +} diff --git a/src/tools/common.tools.ts b/src/tools/common.tools.ts new file mode 100644 index 0000000..17fb6b9 --- /dev/null +++ b/src/tools/common.tools.ts @@ -0,0 +1,52 @@ +import { HTTPError, ParseError } from 'got'; +export class ManagedBluelinkyError extends Error { + static ErrorName = 'ManagedBluelinkyError'; + constructor(message: string, public readonly source?: Error) { + super(message); + this.name = ManagedBluelinkyError.ErrorName; + } +} + +export const manageBluelinkyError = ( + err: unknown, + context?: string +): unknown | Error | ManagedBluelinkyError => { + if (err instanceof HTTPError) { + return new ManagedBluelinkyError( + `${context ? `@${context}: ` : ''}[${err.statusCode}] ${err.statusMessage} on [${ + err.method + }] ${err.url} - ${JSON.stringify(err.body)}`, + err + ); + } + if (err instanceof ParseError) { + return new ManagedBluelinkyError( + `${context ? `@${context}: ` : ''} Parsing error on [${err.method}] ${ + err.url + } - ${JSON.stringify(err.response?.body)}`, + err + ); + } + if (err instanceof Error) { + return err; + } + return err; +}; + +export const asyncMap = async ( + array: T[], + callback: (item: T, i: number, items: T[]) => Promise +): Promise => { + const mapped: U[] = []; + for (let index = 0; index < array.length; index++) { + mapped.push(await callback(array[index], index, array)); + } + return mapped; +}; + +export const uuidV4 = (): string => + 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { + const r = (Math.random() * 16) | 0, + v = c == 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); diff --git a/src/tools/european.tools.ts b/src/tools/european.tools.ts deleted file mode 100644 index 344b4d6..0000000 --- a/src/tools/european.tools.ts +++ /dev/null @@ -1,5 +0,0 @@ -import stamps from './european.hyundai.token.collection'; - -export const getStamp = (): string => { - return stamps[Math.floor(Math.random() * stamps.length)]; -}; diff --git a/src/vehicles/american.vehicle.ts b/src/vehicles/american.vehicle.ts index f118bdd..0775c44 100644 --- a/src/vehicles/american.vehicle.ts +++ b/src/vehicles/american.vehicle.ts @@ -2,8 +2,6 @@ import got from 'got'; import logger from '../logger'; import { REGIONS, DEFAULT_VEHICLE_STATUS_OPTIONS } from '../constants'; -import { BASE_URL, CLIENT_ID, API_HOST } from '../constants/america'; -import { SessionController } from '../controllers/controller'; import { VehicleStartOptions, @@ -19,11 +17,12 @@ import { RequestHeaders } from '../interfaces/american.interfaces'; import { Vehicle } from './vehicle'; import { URLSearchParams } from 'url'; +import { AmericanController } from '../controllers/american.controller'; export default class AmericanVehicle extends Vehicle { public region = REGIONS.US; - constructor(public vehicleConfig: VehicleRegisterOptions, public controller: SessionController) { + constructor(public vehicleConfig: VehicleRegisterOptions, public controller: AmericanController) { super(vehicleConfig, controller); logger.debug(`US Vehicle ${this.vehicleConfig.regId} created`); } @@ -31,8 +30,8 @@ export default class AmericanVehicle extends Vehicle { private getDefaultHeaders(): RequestHeaders { return { 'access_token': this.controller.session.accessToken, - 'client_id': CLIENT_ID, - 'Host': API_HOST, + 'client_id': this.controller.environment.clientId, + 'Host': this.controller.environment.host, 'User-Agent': 'okhttp/3.12.0', 'registrationId': this.vehicleConfig.regId, 'gen': this.vehicleConfig.generation, @@ -178,7 +177,7 @@ export default class AmericanVehicle extends Vehicle { }); const { vehicleStatus } = JSON.parse(response.body); - const parsedStatus = { + const parsedStatus: VehicleStatus = { chassis: { hoodOpen: vehicleStatus?.hoodOpen, trunkOpen: vehicleStatus?.trunkOpen, @@ -208,13 +207,14 @@ export default class AmericanVehicle extends Vehicle { }, engine: { ignition: vehicleStatus?.engine, - adaptiveCruiseControl: vehicleStatus?.acc, + accessory: vehicleStatus?.acc, range: vehicleStatus?.dte?.value, charging: vehicleStatus?.evStatus?.batteryCharge, batteryCharge12v: vehicleStatus?.battery?.batSoc, batteryChargeHV: vehicleStatus?.evStatus?.batteryStatus, }, - } as VehicleStatus; + lastupdate: new Date(vehicleStatus?.dateTime), + }; this._status = statusConfig.parsed ? parsedStatus : vehicleStatus; @@ -289,14 +289,13 @@ export default class AmericanVehicle extends Vehicle { // TODO: not sure how to type a dynamic response /* eslint-disable @typescript-eslint/no-explicit-any */ private async _request(service: string, options): Promise> { - // add logic for token refresh if to ensure we don't use a stale token await this.controller.refreshAccessToken(); // if we refreshed token make sure to apply it to the request options.headers.access_token = this.controller.session.accessToken; - const response = await got(`${BASE_URL}/${service}`, { throwHttpErrors: false, ...options }); + const response = await got(`${this.controller.environment.baseUrl}/${service}`, { throwHttpErrors: false, ...options }); if (response?.body) { logger.debug(response.body); diff --git a/src/vehicles/canadian.vehicle.ts b/src/vehicles/canadian.vehicle.ts index 76ac114..5f9ed88 100644 --- a/src/vehicles/canadian.vehicle.ts +++ b/src/vehicles/canadian.vehicle.ts @@ -2,7 +2,6 @@ import got from 'got'; import logger from '../logger'; import { REGIONS, DEFAULT_VEHICLE_STATUS_OPTIONS } from '../constants'; -import { CA_ENDPOINTS, CLIENT_ORIGIN } from '../constants/canada'; import { VehicleStartOptions, @@ -15,15 +14,16 @@ import { FullVehicleStatus, } from '../interfaces/common.interfaces'; -import { SessionController } from '../controllers/controller'; import { Vehicle } from './vehicle'; import { celciusToTempCode } from '../util'; +import { parse as parseDate } from 'date-fns'; +import { CanadianController } from '../controllers/canadian.controller'; export default class CanadianVehicle extends Vehicle { public region = REGIONS.CA; private timeOffset = -(new Date().getTimezoneOffset() / 60); - constructor(public vehicleConfig: VehicleRegisterOptions, public controller: SessionController) { + constructor(public vehicleConfig: VehicleRegisterOptions, public controller: CanadianController) { super(vehicleConfig, controller); logger.debug(`CA Vehicle ${this.vehicleConfig.id} created`); } @@ -41,12 +41,16 @@ export default class CanadianVehicle extends Vehicle { }; logger.debug('Begin status request, polling car: ' + input.refresh); try { - const endpoint = statusConfig.refresh ? CA_ENDPOINTS.remoteStatus : CA_ENDPOINTS.status; + const endpoint = statusConfig.refresh ? this.controller.environment.endpoints.remoteStatus : this.controller.environment.endpoints.status; const response = await this.request(endpoint, {}); const vehicleStatus = response.result; + if (response?.error) { + throw response?.error?.errorDesc; + } + logger.debug(vehicleStatus); - const parsedStatus = { + const parsedStatus: VehicleStatus = { chassis: { hoodOpen: vehicleStatus?.hoodOpen, trunkOpen: vehicleStatus?.trunkOpen, @@ -81,13 +85,14 @@ export default class CanadianVehicle extends Vehicle { // example EV status is in lib/__mock__/canadianStatus.json engine: { ignition: vehicleStatus?.engine, - adaptiveCruiseControl: vehicleStatus?.acc, + accessory: vehicleStatus?.acc, range: vehicleStatus?.dte?.value, charging: vehicleStatus?.evStatus?.batteryCharge, batteryCharge12v: vehicleStatus?.battery?.batSoc, batteryChargeHV: vehicleStatus?.evStatus?.batteryStatus, }, - } as VehicleStatus; + lastupdate: parseDate(vehicleStatus?.time, 'yyyyMMddHHmmSS', new Date()) + }; this._status = statusConfig.parsed ? parsedStatus : vehicleStatus; return this._status; @@ -105,7 +110,7 @@ export default class CanadianVehicle extends Vehicle { try { const preAuth = await this.getPreAuth(); // assuming the API returns a bad status code for failed attempts - await this.request(CA_ENDPOINTS.lock, {}, { pAuth: preAuth }); + await this.request(this.controller.environment.endpoints.lock, {}, { pAuth: preAuth }); return 'Lock successful'; } catch (err) { throw err.message; @@ -116,7 +121,7 @@ export default class CanadianVehicle extends Vehicle { logger.debug('Begin unlock request'); try { const preAuth = await this.getPreAuth(); - await this.request(CA_ENDPOINTS.unlock, {}, { pAuth: preAuth }); + await this.request(this.controller.environment.endpoints.unlock, {}, { pAuth: preAuth }); return 'Unlock successful'; } catch (err) { throw err.message; @@ -150,7 +155,7 @@ export default class CanadianVehicle extends Vehicle { } const preAuth = await this.getPreAuth(); - const response = await this.request(CA_ENDPOINTS.start, body, { pAuth: preAuth }); + const response = await this.request(this.controller.environment.endpoints.start, body, { pAuth: preAuth }); logger.debug(response); @@ -168,7 +173,7 @@ export default class CanadianVehicle extends Vehicle { logger.debug('Begin stop request'); try { const preAuth = await this.getPreAuth(); - const response = await this.request(CA_ENDPOINTS.stop, { + const response = await this.request(this.controller.environment.endpoints.stop, { pAuth: preAuth, }); return response; @@ -183,7 +188,7 @@ export default class CanadianVehicle extends Vehicle { try { const preAuth = await this.getPreAuth(); const response = await this.request( - CA_ENDPOINTS.hornlight, + this.controller.environment.endpoints.hornlight, { horn: withHorn }, { pAuth: preAuth } ); @@ -202,7 +207,7 @@ export default class CanadianVehicle extends Vehicle { logger.debug('Begin locate request'); try { const preAuth = await this.getPreAuth(); - const response = await this.request(CA_ENDPOINTS.locate, {}, { pAuth: preAuth }); + const response = await this.request(this.controller.environment.endpoints.locate, {}, { pAuth: preAuth }); this._location = response.result as VehicleLocation; return this._location; } catch (err) { @@ -217,7 +222,7 @@ export default class CanadianVehicle extends Vehicle { private async getPreAuth(): Promise { logger.info('Begin pre-authentication'); try { - const response = await this.request(CA_ENDPOINTS.verifyPin, {}); + const response = await this.request(this.controller.environment.endpoints.verifyPin, {}); return response.result.pAuth; } catch (err) { throw 'error: ' + err; @@ -237,7 +242,7 @@ export default class CanadianVehicle extends Vehicle { json: true, throwHttpErrors: false, headers: { - from: CLIENT_ORIGIN, + from: this.controller.environment.origin, language: 1, offset: this.timeOffset, accessToken: this.controller.session.accessToken, diff --git a/src/vehicles/european.vehicle.ts b/src/vehicles/european.vehicle.ts index eb22307..f0a61c3 100644 --- a/src/vehicles/european.vehicle.ts +++ b/src/vehicles/european.vehicle.ts @@ -9,6 +9,11 @@ import { VehicleStatusOptions, RawVehicleStatus, EVPlugTypes, + VehicleMonthlyReport, + DeepPartial, + VehicleTargetSOC, + EVChargeModeTypes, + VehicleDayTrip, } from '../interfaces/common.interfaces'; import got from 'got'; @@ -16,8 +21,12 @@ import logger from '../logger'; import { Vehicle } from './vehicle'; import { EuropeanController } from '../controllers/european.controller'; import { celciusToTempCode, tempCodeToCelsius } from '../util'; -import { EU_BASE_URL } from '../constants/europe'; -import { getStamp } from '../tools/european.tools'; +import { manageBluelinkyError, ManagedBluelinkyError } from '../tools/common.tools'; +import { addMinutes, parse as parseDate } from 'date-fns'; +import { EUPOIInformation } from '../interfaces/european.interfaces'; + +type ChargeTarget = 50 | 60 | 70 | 80 | 90 | 100; +const POSSIBLE_CHARGE_LIMIT_VALUES = [50, 60, 70, 80, 90, 100]; export default class EuropeanVehicle extends Vehicle { public region = REGIONS.EU; @@ -41,125 +50,133 @@ export default class EuropeanVehicle extends Vehicle { public async start(config: VehicleClimateOptions): Promise { await this.checkControlToken(); - const response = await got( - `${EU_BASE_URL}/api/v2/spa/vehicles/${this.vehicleConfig.id}/control/temperature`, - { - method: 'POST', - body: { - action: 'start', - hvacType: 0, - options: { - defrost: config.defrost, - heating1: config.windscreenHeating ? 1 : 0, + try { + const response = await got( + `${this.controller.environment.baseUrl}/api/v2/spa/vehicles/${this.vehicleConfig.id}/control/temperature`, + { + method: 'POST', + body: { + action: 'start', + hvacType: 0, + options: { + defrost: config.defrost, + heating1: config.windscreenHeating ? 1 : 0, + }, + tempCode: celciusToTempCode(config.temperature), + unit: config.unit, }, - tempCode: celciusToTempCode(config.temperature), - unit: config.unit, - }, - headers: { - 'Authorization': this.controller.session.controlToken, - 'ccsp-device-id': this.controller.session.deviceId, - 'Content-Type': 'application/json', - 'Stamp': await getStamp(), - }, - json: true, - } - ); - - logger.info(`Climate started for vehicle ${this.vehicleConfig.id}`); - - return response.body; + headers: { + 'Authorization': this.controller.session.controlToken, + 'ccsp-device-id': this.controller.session.deviceId, + 'Content-Type': 'application/json', + 'Stamp': this.controller.environment.stamp(), + }, + json: true, + } + ); + logger.info(`Climate started for vehicle ${this.vehicleConfig.id}`); + return response.body; + } catch (err) { + throw manageBluelinkyError(err, 'EuropeVehicle.start'); + } } public async stop(): Promise { await this.checkControlToken(); - const response = await got( - `${EU_BASE_URL}/api/v2/spa/vehicles/${this.vehicleConfig.id}/control/temperature`, - { - method: 'POST', - body: { - action: 'stop', - hvacType: 0, - options: { - defrost: true, - heating1: 1, + try { + const response = await got( + `${this.controller.environment.baseUrl}/api/v2/spa/vehicles/${this.vehicleConfig.id}/control/temperature`, + { + method: 'POST', + body: { + action: 'stop', + hvacType: 0, + options: { + defrost: true, + heating1: 1, + }, + tempCode: '10H', + unit: 'C', }, - tempCode: '10H', - unit: 'C', - }, - headers: { - 'Authorization': this.controller.session.controlToken, - 'ccsp-device-id': this.controller.session.deviceId, - 'Content-Type': 'application/json', - 'Stamp': await getStamp(), - }, - json: true, - } - ); - - logger.info(`Climate stopped for vehicle ${this.vehicleConfig.id}`); - - return response.body; + headers: { + 'Authorization': this.controller.session.controlToken, + 'ccsp-device-id': this.controller.session.deviceId, + 'Content-Type': 'application/json', + 'Stamp': this.controller.environment.stamp(), + }, + json: true, + } + ); + logger.info(`Climate stopped for vehicle ${this.vehicleConfig.id}`); + return response.body; + } catch (err) { + throw manageBluelinkyError(err, 'EuropeVehicle.stop'); + } } public async lock(): Promise { await this.checkControlToken(); - const response = await got( - `${EU_BASE_URL}/api/v2/spa/vehicles/${this.vehicleConfig.id}/control/door`, - { - method: 'POST', - headers: { - 'Authorization': this.controller.session.controlToken, - 'ccsp-device-id': this.controller.session.deviceId, - 'Content-Type': 'application/json', - 'Stamp': await getStamp(), - }, - body: { - action: 'close', - deviceId: this.controller.session.deviceId, - }, - json: true, + try { + const response = await got( + `${this.controller.environment.baseUrl}/api/v2/spa/vehicles/${this.vehicleConfig.id}/control/door`, + { + method: 'POST', + headers: { + 'Authorization': this.controller.session.controlToken, + 'ccsp-device-id': this.controller.session.deviceId, + 'Content-Type': 'application/json', + 'Stamp': this.controller.environment.stamp(), + }, + body: { + action: 'close', + deviceId: this.controller.session.deviceId, + }, + json: true, + } + ); + if (response.statusCode === 200) { + logger.debug(`Vehicle ${this.vehicleConfig.id} locked`); + return 'Lock successful'; } - ); - - if (response.statusCode === 200) { - logger.debug(`Vehicle ${this.vehicleConfig.id} locked`); - return 'Lock successful'; + return 'Something went wrong!'; + } catch (err) { + throw manageBluelinkyError(err, 'EuropeVehicle.lock'); } - - return 'Something went wrong!'; } public async unlock(): Promise { await this.checkControlToken(); - const response = await got( - `${EU_BASE_URL}/api/v2/spa/vehicles/${this.vehicleConfig.id}/control/door`, - { - method: 'POST', - headers: { - 'Authorization': this.controller.session.controlToken, - 'ccsp-device-id': this.controller.session.deviceId, - 'Content-Type': 'application/json', - 'Stamp': await getStamp(), - }, - body: { - action: 'open', - deviceId: this.controller.session.deviceId, - }, - json: true, + try { + const response = await got( + `${this.controller.environment.baseUrl}/api/v2/spa/vehicles/${this.vehicleConfig.id}/control/door`, + { + method: 'POST', + headers: { + 'Authorization': this.controller.session.controlToken, + 'ccsp-device-id': this.controller.session.deviceId, + 'Content-Type': 'application/json', + 'Stamp': this.controller.environment.stamp(), + }, + body: { + action: 'open', + deviceId: this.controller.session.deviceId, + }, + json: true, + } + ); + + if (response.statusCode === 200) { + logger.debug(`Vehicle ${this.vehicleConfig.id} unlocked`); + return 'Unlock successful'; } - ); - if (response.statusCode === 200) { - logger.debug(`Vehicle ${this.vehicleConfig.id} unlocked`); - return 'Unlock successful'; + return 'Something went wrong!'; + } catch (err) { + throw manageBluelinkyError(err, 'EuropeVehicle.unlock'); } - - return 'Something went wrong!'; } - public async fullStatus( - input: VehicleStatusOptions - ): Promise { + public async fullStatus(input: VehicleStatusOptions): Promise { const statusConfig = { ...DEFAULT_VEHICLE_STATUS_OPTIONS, ...input, @@ -167,56 +184,60 @@ export default class EuropeanVehicle extends Vehicle { await this.checkControlToken(); - const cachedResponse = await got( - `${EU_BASE_URL}/api/v2/spa/vehicles/${this.vehicleConfig.id}/status/latest`, - { - method: 'GET', - headers: { - 'Authorization': this.controller.session.controlToken, - 'ccsp-device-id': this.controller.session.deviceId, - 'Content-Type': 'application/json', - 'Stamp': await getStamp(), - }, - json: true, - } - ); - - const fullStatus = cachedResponse.body.resMsg.vehicleStatusInfo; - - if(statusConfig.refresh) { - const statusResponse = await got( - `${EU_BASE_URL}/api/v2/spa/vehicles/${this.vehicleConfig.id}/status`, + try { + const cachedResponse = await got( + `${this.controller.environment.baseUrl}/api/v2/spa/vehicles/${this.vehicleConfig.id}/status/latest`, { method: 'GET', headers: { 'Authorization': this.controller.session.controlToken, 'ccsp-device-id': this.controller.session.deviceId, 'Content-Type': 'application/json', - 'Stamp': await getStamp(), + 'Stamp': this.controller.environment.stamp(), }, json: true, } ); - fullStatus.vehicleStatus = statusResponse.body.resMsg; - const locationResponse = await got( - `${EU_BASE_URL}/api/v2/spa/vehicles/${this.vehicleConfig.id}/location`, - { - method: 'GET', - headers: { - 'Authorization': this.controller.session.controlToken, - 'ccsp-device-id': this.controller.session.deviceId, - 'Content-Type': 'application/json', - 'Stamp': await getStamp(), - }, - json: true, - } - ); - fullStatus.vehicleLocation = locationResponse.body.resMsg.gpsDetail; - } + const fullStatus = cachedResponse.body.resMsg.vehicleStatusInfo; + + if (statusConfig.refresh) { + const statusResponse = await got( + `${this.controller.environment.baseUrl}/api/v2/spa/vehicles/${this.vehicleConfig.id}/status`, + { + method: 'GET', + headers: { + 'Authorization': this.controller.session.controlToken, + 'ccsp-device-id': this.controller.session.deviceId, + 'Content-Type': 'application/json', + 'Stamp': this.controller.environment.stamp(), + }, + json: true, + } + ); + fullStatus.vehicleStatus = statusResponse.body.resMsg; + + const locationResponse = await got( + `${this.controller.environment.baseUrl}/api/v2/spa/vehicles/${this.vehicleConfig.id}/location`, + { + method: 'GET', + headers: { + 'Authorization': this.controller.session.controlToken, + 'ccsp-device-id': this.controller.session.deviceId, + 'Content-Type': 'application/json', + 'Stamp': this.controller.environment.stamp(), + }, + json: true, + } + ); + fullStatus.vehicleLocation = locationResponse.body.resMsg.gpsDetail; + } - this._fullStatus = fullStatus; - return Promise.resolve(this._fullStatus); + this._fullStatus = fullStatus; + return this._fullStatus; + } catch (err) { + throw manageBluelinkyError(err, 'EuropeVehicle.fullStatus'); + } } public async status( @@ -229,188 +250,430 @@ export default class EuropeanVehicle extends Vehicle { await this.checkControlToken(); - const cacheString = statusConfig.refresh ? '' : '/latest'; - - const response = await got( - `${EU_BASE_URL}/api/v2/spa/vehicles/${this.vehicleConfig.id}/status${cacheString}`, - { - method: 'GET', - headers: { - 'Authorization': this.controller.session.controlToken, - 'ccsp-device-id': this.controller.session.deviceId, - 'Content-Type': 'application/json', - 'Stamp': await getStamp(), + try { + const cacheString = statusConfig.refresh ? '' : '/latest'; + + const response = await got( + `${this.controller.environment.baseUrl}/api/v2/spa/vehicles/${this.vehicleConfig.id}/status${cacheString}`, + { + method: 'GET', + headers: { + 'Authorization': this.controller.session.controlToken, + 'ccsp-device-id': this.controller.session.deviceId, + 'Content-Type': 'application/json', + 'Stamp': this.controller.environment.stamp(), + }, + json: true, + } + ); + + // handles refreshing data + const vehicleStatus = statusConfig.refresh + ? response.body.resMsg + : response.body.resMsg.vehicleStatusInfo.vehicleStatus; + + const parsedStatus: VehicleStatus = { + chassis: { + hoodOpen: vehicleStatus?.hoodOpen, + trunkOpen: vehicleStatus?.trunkOpen, + locked: vehicleStatus.doorLock, + openDoors: { + frontRight: !!vehicleStatus?.doorOpen?.frontRight, + frontLeft: !!vehicleStatus?.doorOpen?.frontLeft, + backLeft: !!vehicleStatus?.doorOpen?.backLeft, + backRight: !!vehicleStatus?.doorOpen?.backRight, + }, + tirePressureWarningLamp: { + rearLeft: !!vehicleStatus?.tirePressureLamp?.tirePressureLampRL, + frontLeft: !!vehicleStatus?.tirePressureLamp?.tirePressureLampFL, + frontRight: !!vehicleStatus?.tirePressureLamp?.tirePressureLampFR, + rearRight: !!vehicleStatus?.tirePressureLamp?.tirePressureLampRR, + all: !!vehicleStatus?.tirePressureLamp?.tirePressureWarningLampAll, + }, }, - json: true, - } - ); - - // handles refreshing data - const vehicleStatus = statusConfig.refresh - ? response.body.resMsg - : response.body.resMsg.vehicleStatusInfo.vehicleStatus; - - const parsedStatus: VehicleStatus = { - chassis: { - hoodOpen: vehicleStatus?.hoodOpen, - trunkOpen: vehicleStatus?.trunkOpen, - locked: vehicleStatus.doorLock, - openDoors: { - frontRight: !!vehicleStatus?.doorOpen?.frontRight, - frontLeft: !!vehicleStatus?.doorOpen?.frontLeft, - backLeft: !!vehicleStatus?.doorOpen?.backLeft, - backRight: !!vehicleStatus?.doorOpen?.backRight, + climate: { + active: vehicleStatus?.airCtrlOn, + steeringwheelHeat: !!vehicleStatus?.steerWheelHeat, + sideMirrorHeat: false, + rearWindowHeat: !!vehicleStatus?.sideBackWindowHeat, + defrost: vehicleStatus?.defrost, + temperatureSetpoint: tempCodeToCelsius(vehicleStatus?.airTemp?.value), + temperatureUnit: vehicleStatus?.airTemp?.unit, }, - tirePressureWarningLamp: { - rearLeft: !!vehicleStatus?.tirePressureLamp?.tirePressureLampRL, - frontLeft: !!vehicleStatus?.tirePressureLamp?.tirePressureLampFL, - frontRight: !!vehicleStatus?.tirePressureLamp?.tirePressureLampFR, - rearRight: !!vehicleStatus?.tirePressureLamp?.tirePressureLampRR, - all: !!vehicleStatus?.tirePressureLamp?.tirePressureWarningLampAll, + engine: { + ignition: vehicleStatus.engine, + accessory: vehicleStatus?.acc, + rangeGas: + vehicleStatus?.evStatus?.drvDistance[0]?.rangeByFuel?.gasModeRange?.value ?? + vehicleStatus?.dte?.value, + // EV + range: vehicleStatus?.evStatus?.drvDistance[0]?.rangeByFuel?.totalAvailableRange?.value, + rangeEV: vehicleStatus?.evStatus?.drvDistance[0]?.rangeByFuel?.evModeRange?.value, + plugedTo: vehicleStatus?.evStatus?.batteryPlugin ?? EVPlugTypes.UNPLUGED, + charging: vehicleStatus?.evStatus?.batteryCharge, + estimatedCurrentChargeDuration: vehicleStatus?.evStatus?.remainTime2?.atc?.value, + estimatedFastChargeDuration: vehicleStatus?.evStatus?.remainTime2?.etc1?.value, + estimatedPortableChargeDuration: vehicleStatus?.evStatus?.remainTime2?.etc2?.value, + estimatedStationChargeDuration: vehicleStatus?.evStatus?.remainTime2?.etc3?.value, + batteryCharge12v: vehicleStatus?.battery?.batSoc, + batteryChargeHV: vehicleStatus?.evStatus?.batteryStatus, }, - }, - climate: { - active: vehicleStatus?.airCtrlOn, - steeringwheelHeat: !!vehicleStatus?.steerWheelHeat, - sideMirrorHeat: false, - rearWindowHeat: !!vehicleStatus?.sideBackWindowHeat, - defrost: vehicleStatus?.defrost, - temperatureSetpoint: tempCodeToCelsius(vehicleStatus?.airTemp?.value), - temperatureUnit: vehicleStatus?.airTemp?.unit, - }, - engine: { - ignition: vehicleStatus.engine, - adaptiveCruiseControl: vehicleStatus?.acc, - rangeGas: vehicleStatus?.evStatus?.drvDistance[0]?.rangeByFuel?.gasModeRange?.value ?? vehicleStatus?.dte?.value, - // EV - range: vehicleStatus?.evStatus?.drvDistance[0]?.rangeByFuel?.totalAvailableRange?.value, - rangeEV: vehicleStatus?.evStatus?.drvDistance[0]?.rangeByFuel?.evModeRange?.value, - plugedTo: vehicleStatus?.evStatus?.batteryPlugin ?? EVPlugTypes.UNPLUGED, - charging: vehicleStatus?.evStatus?.batteryCharge, - estimatedCurrentChargeDuration: vehicleStatus?.evStatus?.remainTime2?.atc?.value, - estimatedFastChargeDuration: vehicleStatus?.evStatus?.remainTime2?.etc1?.value, - estimatedPortableChargeDuration: vehicleStatus?.evStatus?.remainTime2?.etc2?.value, - estimatedStationChargeDuration: vehicleStatus?.evStatus?.remainTime2?.etc3?.value, - batteryCharge12v: vehicleStatus?.battery?.batSoc, - batteryChargeHV: vehicleStatus?.evStatus?.batteryStatus, - }, - }; + lastupdate: parseDate(vehicleStatus?.time, 'yyyyMMddHHmmSS', new Date()) + }; - if(!parsedStatus.engine.range) { - if (parsedStatus.engine.rangeEV || parsedStatus.engine.rangeGas) { - parsedStatus.engine.range = (parsedStatus.engine.rangeEV ?? 0) + (parsedStatus.engine.rangeGas ?? 0); + if (!parsedStatus.engine.range) { + if (parsedStatus.engine.rangeEV || parsedStatus.engine.rangeGas) { + parsedStatus.engine.range = + (parsedStatus.engine.rangeEV ?? 0) + (parsedStatus.engine.rangeGas ?? 0); + } } - } - this._status = statusConfig.parsed ? parsedStatus : vehicleStatus; + this._status = statusConfig.parsed ? parsedStatus : vehicleStatus; - return this._status; + return this._status; + } catch (err) { + throw manageBluelinkyError(err, 'EuropeVehicle.status'); + } } public async odometer(): Promise { await this.checkControlToken(); - const response = await got( - `${EU_BASE_URL}/api/v2/spa/vehicles/${this.vehicleConfig.id}/status/latest`, - { - method: 'GET', - headers: { - 'Authorization': this.controller.session.controlToken, - 'ccsp-device-id': this.controller.session.deviceId, - 'Content-Type': 'application/json', - 'Stamp': await getStamp(), - }, - json: true, - } - ); - - this._odometer = response.body.resMsg.vehicleStatusInfo.odometer as VehicleOdometer; - return this._odometer; + try { + const response = await got( + `${this.controller.environment.baseUrl}/api/v2/spa/vehicles/${this.vehicleConfig.id}/status/latest`, + { + method: 'GET', + headers: { + 'Authorization': this.controller.session.controlToken, + 'ccsp-device-id': this.controller.session.deviceId, + 'Content-Type': 'application/json', + 'Stamp': this.controller.environment.stamp(), + }, + json: true, + } + ); + this._odometer = response.body.resMsg.vehicleStatusInfo.odometer as VehicleOdometer; + return this._odometer; + } catch (err) { + throw manageBluelinkyError(err, 'EuropeVehicle.odometer'); + } } public async location(): Promise { await this.checkControlToken(); - const response = await got( - `${EU_BASE_URL}/api/v2/spa/vehicles/${this.vehicleConfig.id}/location`, - { - method: 'GET', - headers: { - 'Authorization': this.controller.session.controlToken, - 'ccsp-device-id': this.controller.session.deviceId, - 'Content-Type': 'application/json', - 'Stamp': await getStamp(), + try { + const response = await got( + `${this.controller.environment.baseUrl}/api/v2/spa/vehicles/${this.vehicleConfig.id}/location`, + { + method: 'GET', + headers: { + 'Authorization': this.controller.session.controlToken, + 'ccsp-device-id': this.controller.session.deviceId, + 'Content-Type': 'application/json', + 'Stamp': this.controller.environment.stamp(), + }, + json: true, + } + ); + + const data = response.body.resMsg?.gpsDetail ?? response.body.resMsg; + this._location = { + latitude: data?.coord?.lat, + longitude: data?.coord?.lon, + altitude: data?.coord?.alt, + speed: { + unit: data?.speed?.unit, + value: data?.speed?.value, }, - json: true, - } - ); - - const data = response.body.resMsg.gpsDetail; - this._location = { - latitude: data.coord.lat, - longitude: data.coord.lon, - altitude: data.coord.alt, - speed: { - unit: data.speed.unit, - value: data.speed.value, - }, - heading: data.head, - }; + heading: data?.head, + }; - return this._location; + return this._location; + } catch (err) { + throw manageBluelinkyError(err, 'EuropeVehicle.location'); + } } public async startCharge(): Promise { await this.checkControlToken(); - const response = await got( - `${EU_BASE_URL}/api/v2/spa/vehicles/${this.vehicleConfig.id}/control/charge`, - { - method: 'POST', - headers: { - 'Authorization': this.controller.session.controlToken, - 'ccsp-device-id': this.controller.session.deviceId, - 'Content-Type': 'application/json', - 'Stamp': await getStamp(), - }, - body: { - action: 'start', - deviceId: this.controller.session.deviceId, - }, - json: true, + try { + const response = await got( + `${this.controller.environment.baseUrl}/api/v2/spa/vehicles/${this.vehicleConfig.id}/control/charge`, + { + method: 'POST', + headers: { + 'Authorization': this.controller.session.controlToken, + 'ccsp-device-id': this.controller.session.deviceId, + 'Content-Type': 'application/json', + 'Stamp': this.controller.environment.stamp(), + }, + body: { + action: 'start', + deviceId: this.controller.session.deviceId, + }, + json: true, + } + ); + + if (response.statusCode === 200) { + logger.debug(`Send start charge command to Vehicle ${this.vehicleConfig.id}`); + return 'Start charge successful'; } - ); - if (response.statusCode === 200) { - logger.debug(`Send start charge command to Vehicle ${this.vehicleConfig.id}`); - return 'Start charge successful'; + throw 'Something went wrong!'; + } catch (err) { + throw manageBluelinkyError(err, 'EuropeVehicle.startCharge'); } - - throw 'Something went wrong!'; } public async stopCharge(): Promise { await this.checkControlToken(); - const response = await got( - `${EU_BASE_URL}/api/v2/spa/vehicles/${this.vehicleConfig.id}/control/charge`, - { - method: 'POST', - headers: { - 'Authorization': this.controller.session.controlToken, - 'ccsp-device-id': this.controller.session.deviceId, - 'Content-Type': 'application/json', - 'Stamp': await getStamp(), - }, - body: { - action: 'stop', - deviceId: this.controller.session.deviceId, - }, - json: true, + try { + const response = await got( + `${this.controller.environment.baseUrl}/api/v2/spa/vehicles/${this.vehicleConfig.id}/control/charge`, + { + method: 'POST', + headers: { + 'Authorization': this.controller.session.controlToken, + 'ccsp-device-id': this.controller.session.deviceId, + 'Content-Type': 'application/json', + 'Stamp': this.controller.environment.stamp(), + }, + body: { + action: 'stop', + deviceId: this.controller.session.deviceId, + }, + json: true, + } + ); + + if (response.statusCode === 200) { + logger.debug(`Send stop charge command to Vehicle ${this.vehicleConfig.id}`); + return 'Stop charge successful'; } - ); - if (response.statusCode === 200) { - logger.debug(`Send stop charge command to Vehicle ${this.vehicleConfig.id}`); - return 'Stop charge successful'; + throw 'Something went wrong!'; + } catch (err) { + throw manageBluelinkyError(err, 'EuropeVehicle.stopCharge'); } + } - throw 'Something went wrong!'; + public async monthlyReport( + month: { year: number; month: number; } = { year: new Date().getFullYear(), month: new Date().getMonth() + 1 } + ): Promise | undefined> { + await this.checkControlToken(); + try { + const response = await got( + `${this.controller.environment.baseUrl}/api/v2/spa/vehicles/${this.vehicleConfig.id}/monthlyreport`, + { + method: 'POST', + headers: { + 'Authorization': this.controller.session.controlToken, + 'ccsp-device-id': this.controller.session.deviceId, + 'Content-Type': 'application/json', + 'Stamp': this.controller.environment.stamp(), + }, + body: { + setRptMonth: toMonthDate(month) + }, + json: true, + } + ); + const rawData = response.body.resMsg?.monthlyReport; + if (rawData) { + return { + start: rawData.ifo?.mvrMonthStart, + end: rawData.ifo?.mvrMonthEnd, + breakdown: rawData.breakdown, + driving: rawData.driving ? { + distance: rawData.driving?.runDistance, + startCount: rawData.driving?.engineStartCount, + durations: { + idle: rawData.driving?.engineIdleTime, + drive: rawData.driving?.engineOnTime, + } + } : undefined, + vehicleStatus: rawData.vehicleStatus ? { + tpms: rawData.vehicleStatus?.tpmsSupport ? Boolean(rawData.vehicleStatus?.tpmsSupport) : undefined, + tirePressure: { + all: rawData.vehicleStatus?.tirePressure?.tirePressureLampAll == '1', + } + } : undefined, + }; + } + return; + } catch (err) { + throw manageBluelinkyError(err, 'EuropeVehicle.monthyReports'); + } } + + public async tripInfo( + date: { year: number; month: number; day: number; } = { year: new Date().getFullYear(), month: new Date().getMonth() + 1, day: new Date().getDate() } + ): Promise[] | undefined> { + await this.checkControlToken(); + try { + const response = await got( + `${this.controller.environment.baseUrl}/api/v1/spa/vehicles/${this.vehicleConfig.id}/tripinfo`, + { + method: 'POST', + headers: { + 'Authorization': this.controller.session.accessToken, + 'ccsp-device-id': this.controller.session.deviceId, + 'Content-Type': 'application/json', + 'Stamp': this.controller.environment.stamp(), + }, + body: { + setTripMonth: !date.day ? toMonthDate(date) : undefined, + setTripLatest: 10, + setTripDay: date.day ? toDayDate(date) : undefined, + tripPeriodType: 1 + }, + json: true, + } + ); + + const rawData = response.body.resMsg.dayTripList; + if (rawData && Array.isArray(rawData)) { + return rawData.map(day => ({ + dayRaw: day.tripDay, + tripsCount: day.dayTripCnt, + distance: day.tripDist, + durations: { + drive: day.tripDrvTime, + idle: day.tripIdleTime + }, + speed: { + avg: day.tripAvgSpeed, + max: day.tripMaxSpeed + }, + trips: Array.isArray(day.tripList) ? + day.tripList.map(trip => { + const start = parseDate(`${day.tripDay}${trip.tripTime}`, 'yyyyMMddHHmmss', Date.now()); + return { + timeRaw: trip.tripTime, + start, + end: addMinutes(start, trip.tripDrvTime), + durations: { + drive: trip.tripDrvTime, + idle: trip.tripIdleTime, + }, + speed: { + avg: trip.tripAvgSpeed, + max: trip.tripMaxSpeed, + }, + distance: trip.tripDist, + }; + }) + : [], + })); + } + return; + } catch (err) { + throw manageBluelinkyError(err, 'EuropeVehicle.history'); + } + } + + /** + * Warning: Only works on EV + */ + public async getChargeTargets(): Promise[] | undefined> { + await this.checkControlToken(); + try { + const response = await got( + `${this.controller.environment.baseUrl}/api/v2/spa/vehicles/${this.vehicleConfig.id}/charge/target`, + { + method: 'GET', + headers: { + 'Authorization': this.controller.session.controlToken, + 'ccsp-device-id': this.controller.session.deviceId, + 'Content-Type': 'application/json', + 'Stamp': this.controller.environment.stamp(), + }, + json: true, + } + ); + const rawData = response.body.resMsg?.targetSOClist; + if (rawData && Array.isArray(rawData)) { + return rawData.map((rawSOC) => ({ + distance: rawSOC.drvDistance?.distanceType?.distanceValue, + targetLevel: rawSOC.targetSOClevel, + type: rawSOC.plugType + })); + } + return; + } catch (err) { + throw manageBluelinkyError(err, 'EuropeVehicle.getChargeTargets'); + } + } + + /** + * Warning: Only works on EV + */ + public async setChargeTargets(limits: { fast: ChargeTarget; slow: ChargeTarget; }): Promise { + await this.checkControlToken(); + if (!POSSIBLE_CHARGE_LIMIT_VALUES.includes(limits.fast) || !POSSIBLE_CHARGE_LIMIT_VALUES.includes(limits.slow)) { + throw new ManagedBluelinkyError(`Charge target values are limited to ${POSSIBLE_CHARGE_LIMIT_VALUES.join(', ')}`); + } + try { + await got( + `${this.controller.environment.baseUrl}/api/v2/spa/vehicles/${this.vehicleConfig.id}/charge/target`, + { + method: 'POST', + headers: { + 'Authorization': this.controller.session.controlToken, + 'ccsp-device-id': this.controller.session.deviceId, + 'Content-Type': 'application/json', + 'Stamp': this.controller.environment.stamp(), + }, + body: { + targetSOClist: [ + { plugType: EVChargeModeTypes.FAST, targetSOClevel: limits.fast }, + { plugType: EVChargeModeTypes.SLOW, targetSOClevel: limits.slow } + ] + }, + json: true, + } + ); + } catch (err) { + throw manageBluelinkyError(err, 'EuropeVehicle.setChargeTargets'); + } + } + + /** + * Define a navigation route + * @param poiInformations The list of POIs and waypoint to go through + */ + public async setNavigation(poiInformations: EUPOIInformation[]): Promise { + await this.checkControlToken(); + try { + await got( + `${this.controller.environment.baseUrl}/api/v2/spa/vehicles/${this.vehicleConfig.id}/location/routes`, + { + method: 'POST', + headers: { + 'Authorization': this.controller.session.controlToken, + 'ccsp-device-id': this.controller.session.deviceId, + 'Content-Type': 'application/json', + 'Stamp': this.controller.environment.stamp(), + }, + body: { + deviceID: this.controller.session.deviceId, + poiInfoList: poiInformations, + }, + json: true, + } + ); + } catch (err) { + throw manageBluelinkyError(err, 'EuropeVehicle.setNavigation'); + } + } +} + +function toMonthDate(month: { year: number; month: number; }) { + return `${month.year}${month.month.toString().padStart(2, '0')}`; } + +function toDayDate(date: { year: number; month: number; day: number; }) { + return `${toMonthDate(date)}${date.day.toString().padStart(2, '0')}`; +} + diff --git a/src/vehicles/vehicle.ts b/src/vehicles/vehicle.ts index c0e30e2..1f77a76 100644 --- a/src/vehicles/vehicle.ts +++ b/src/vehicles/vehicle.ts @@ -40,6 +40,7 @@ export abstract class Vehicle { username: undefined, password: undefined, region: REGIONS.EU, + brand: 'hyundai', autoLogin: true, pin: undefined, vin: undefined,