diff --git a/debug.ts b/debug.ts index b75cd21..0f5b72a 100644 --- a/debug.ts +++ b/debug.ts @@ -45,20 +45,20 @@ const askForRegionInput = () => { type: 'list', name: 'region', message: 'What Region are you in?', - choices: ['CN','US', 'EU', 'CA'], + choices: ['CN', 'US', 'EU', 'CA', 'AU'], }, { type: 'list', name: 'brand', message: 'Which brand are you using?', choices: ['hyundai', 'kia'], - } + }, ]) .then(answers => { if (answers.command == 'exit') { return; } else { - console.log(answers) + console.log(answers); console.log('Logging in...'); createInstance(answers.region, answers.brand); } @@ -72,7 +72,7 @@ const createInstance = (region, brand) => { password, region, brand, - pin + pin, }); client.on('ready', onReadyHandler); }; @@ -187,31 +187,30 @@ async function performCommand(command) { case 'drvInfo': const info = await vehicle.driveHistory(); console.log('drvInfo : '); - console.dir(info,{ depth: null }); + console.dir(info, { depth: null }); break; case 'tripInfo': const currentYear = new Date().getFullYear(); - const { year, month, day } = await inquirer - .prompt([ - { - type: 'list', - name: 'year', - message: 'Which year?', - choices: new Array(currentYear - 2015).fill(0).map((_, i) => currentYear - i), - }, - { - type: 'list', - name: 'month', - message: 'Which month?', - choices: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], - }, - { - type: 'list', - name: 'day', - message: 'Which day (0 to ignore the day)?', - choices: new Array(32).fill(0).map((_, i) => i), - } - ]); + const { year, month, day } = await inquirer.prompt([ + { + type: 'list', + name: 'year', + message: 'Which year?', + choices: new Array(currentYear - 2015).fill(0).map((_, i) => currentYear - i), + }, + { + type: 'list', + name: 'month', + message: 'Which month?', + choices: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + }, + { + type: 'list', + name: 'day', + message: 'Which day (0 to ignore the day)?', + choices: new Array(32).fill(0).map((_, i) => i), + }, + ]); const trips = await vehicle.tripInfo({ year, month, day: day === 0 ? undefined : day }); console.log('trips : ' + JSON.stringify(trips, null, 2)); break; @@ -226,21 +225,20 @@ async function performCommand(command) { await vehicle.stopCharge(); 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], - } - ]); + 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; diff --git a/package.json b/package.json index 6d505b7..e4231b1 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,9 @@ "format": "prettier --write './src/**/*.{js,jsx,ts,tsx}'", "prepublishOnly": "npm run build", "test": "jest --verbose", - "debug": "cross-env LOG_LEVEL=debug ts-node debug.ts", + "debug": "ts-node debug.ts", "eu:export:cfb": "cross-env LOG_LEVEL=debug ts-node ./scripts/export_eu_cfb.ts", + "au:export:cfb": "cross-env LOG_LEVEL=debug ts-node ./scripts/export_au_cfb.ts", "test-ca": "npm run build && node test-ca.js", "watch": "tsc -w" }, diff --git a/scripts/export_au_cfb.ts b/scripts/export_au_cfb.ts new file mode 100644 index 0000000..f29ec60 --- /dev/null +++ b/scripts/export_au_cfb.ts @@ -0,0 +1,6 @@ +import { getBrandEnvironment } from '../src/constants/australia'; +import { exportCfb } from './export_cfb'; + +exportCfb('hacksore/hks:native-au', 'australia.cfb.ts', getBrandEnvironment).catch(e => + console.error(e) +); diff --git a/scripts/export_cfb.ts b/scripts/export_cfb.ts new file mode 100644 index 0000000..45f93f1 --- /dev/null +++ b/scripts/export_cfb.ts @@ -0,0 +1,76 @@ +/* eslint-disable no-console */ +import { execSync, spawn } from 'child_process'; +import { writeFileSync } from 'fs'; +import { join } from 'path'; +import { AustraliaBrandEnvironment } from '../src/constants/australia'; +import { EuropeanBrandEnvironment } from '../src/constants/europe'; +import { Brand } from '../src/interfaces/common.interfaces'; + +const brands: Brand[] = ['kia', 'hyundai']; + +export const exportCfb = async ( + dockerImage: string, + outputFilename: string, + getBrandEnvironment: (config: { + brand: Brand; + }) => EuropeanBrandEnvironment | AustraliaBrandEnvironment +) => { + console.debug(`Pulling image ${dockerImage}`); + execSync(`docker pull ${dockerImage}`); + const brandCFB = { + kia: '', + hyundai: '', + }; + for (const brand of brands) { + try { + const { appId } = getBrandEnvironment({ brand }); + + const [first] = await new Promise((resolve, reject) => { + console.debug(`Starting image ${dockerImage} - ${brand}`); + const process = spawn('docker', [ + 'run', + '--rm', + dockerImage, + brand, + 'dumpCFB', + `${appId}:${Date.now()}`, + ]); + const list: Array = []; + let errors = ''; + + process.stdout.on('data', data => { + const chunk: Array = data + .toString() + .split('\n') + .map(s => s.trim()) + .filter(s => s != ''); + list.push(...chunk); + }); + + process.stderr.on('data', data => { + errors += data + '\n'; + }); + + process.on('close', code => { + console.debug(`Done with ${dockerImage} - ${brand}`); + if (code === 0) { + return resolve(list); + } + reject(errors); + }); + }); + brandCFB[brand] = first; + } catch (e) { + // Skip any unsupported brands in regions + continue; + } + } + + const cfbFile = `// Auto generated file on ${new Date().toISOString()} +// run \`npm run eu:export:cfb\` or \`npm run au:export:cfb\` respectively to update it + +export const kiaCFB = Buffer.from('${brandCFB.kia}', 'base64'); +export const hyundaiCFB = Buffer.from('${brandCFB.hyundai}', 'base64');`; + + writeFileSync(join(__dirname, '..', 'src', 'constants', outputFilename), cfbFile); +}; diff --git a/scripts/export_eu_cfb.ts b/scripts/export_eu_cfb.ts index 6fe5fdb..bcb4ccd 100644 --- a/scripts/export_eu_cfb.ts +++ b/scripts/export_eu_cfb.ts @@ -1,66 +1,4 @@ -/* eslint-disable no-console */ -import { execSync, spawn } from 'child_process'; -import { writeFileSync } from 'fs'; -import { join } from 'path'; import { getBrandEnvironment } from '../src/constants/europe'; -import { Brand } from '../src/interfaces/common.interfaces'; +import { exportCfb } from './export_cfb'; -const brands: Brand[] = ['kia', 'hyundai']; - -const main = async () => { - console.debug(`Pulling image hacksore/hks:native`); - execSync('docker pull hacksore/hks:native'); - const brandCFB = { - kia: '', - hyundai: '', - }; - for (const brand of brands) { - const { appId } = getBrandEnvironment({ brand }); - - const [first] = await new Promise((resolve, reject) => { - console.debug(`Starting image hacksore/hks:native - ${brand}`); - const process = spawn('docker', [ - 'run', - '--rm', - 'hacksore/hks:native', - brand, - 'dumpCFB', - `${appId}:${Date.now()}`, - ]); - const list: Array = []; - let errors = ''; - - process.stdout.on('data', data => { - const chunk: Array = data - .toString() - .split('\n') - .map(s => s.trim()) - .filter(s => s != ''); - list.push(...chunk); - }); - - process.stderr.on('data', data => { - errors += data + '\n'; - }); - - process.on('close', code => { - console.debug(`Done with hacksore/hks:native - ${brand}`); - if (code === 0) { - return resolve(list); - } - reject(errors); - }); - }); - brandCFB[brand] = first; - } - - const cfbFile = `// Auto generated file on ${new Date().toISOString()} -// run \`npm run eu:export:cfb\` to update it - -export const kiaCFB = Buffer.from('${brandCFB.kia}', 'base64'); -export const hyundaiCFB = Buffer.from('${brandCFB.hyundai}', 'base64');`; - - writeFileSync(join(__dirname, '..', 'src', 'constants', 'europe.cfb.ts'), cfbFile); -}; - -main().catch(e => console.error(e)); +exportCfb('hacksore/hks:native', 'europe.cfb.ts', getBrandEnvironment).catch(e => console.error(e)); diff --git a/src/constants.ts b/src/constants.ts index ced6dde..5b517ae 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -11,6 +11,10 @@ import { getBrandEnvironment as getCNBrandEnvironment, ChineseBrandEnvironment, } from './constants/china'; +import { + getBrandEnvironment as getAUBrandEnvironment, + AustraliaBrandEnvironment, +} from './constants/australia'; import { Brand, VehicleStatusOptions } from './interfaces/common.interfaces'; @@ -21,16 +25,19 @@ export const ALL_ENDPOINTS = { getEUBrandEnvironment({ brand }).endpoints, CN: (brand: Brand): ChineseBrandEnvironment['endpoints'] => getCNBrandEnvironment({ brand }).endpoints, + AU: (brand: Brand): AustraliaBrandEnvironment['endpoints'] => + getAUBrandEnvironment({ brand }).endpoints, }; export const GEN2 = 2; export const GEN1 = 1; -export type REGION = 'US' | 'CA' | 'EU' | 'CN'; +export type REGION = 'US' | 'CA' | 'EU' | 'CN' | 'AU'; export enum REGIONS { US = 'US', CA = 'CA', EU = 'EU', CN = 'CN', + AU = 'AU', } // ev stuffz diff --git a/src/constants/australia.cfb.ts b/src/constants/australia.cfb.ts new file mode 100644 index 0000000..a1e4966 --- /dev/null +++ b/src/constants/australia.cfb.ts @@ -0,0 +1,5 @@ +// Auto generated file on 2024-03-20T22:41:31.183Z +// run `npm run eu:export:cfb` or `npm run au:export:cfb` respectively to update it + +export const kiaCFB = Buffer.from('IDbMgWBXgic4MAyMgf5PFFRAdGX5O3IyC3uvN3scCs0gDpTFDuyvBorlAH9JMM2/hys=', 'base64'); +export const hyundaiCFB = Buffer.from('V60WkEmyRQaAfrBF1623/7QL62MjLVbCHdItGzQ1g5T/hkmKmMVTaMHv4cKGzgD3kfc=', 'base64'); \ No newline at end of file diff --git a/src/constants/australia.ts b/src/constants/australia.ts new file mode 100644 index 0000000..5f72f02 --- /dev/null +++ b/src/constants/australia.ts @@ -0,0 +1,115 @@ +import { REGIONS } from '../constants'; +import { AustraliaBlueLinkyConfig } from '../controllers/australia.controller'; +import { Brand } from '../interfaces/common.interfaces'; +import { StampMode, getStampGenerator } from './stamps'; + +export interface AustraliaBrandEnvironment { + brand: Brand; + host: string; + baseUrl: string; + clientId: string; + appId: string; + endpoints: { + integration: string; + silentSignIn: string; + session: string; + login: string; + language: string; + redirectUri: string; + token: string; + }; + basicToken: string; + stamp: () => Promise; +} + +const getEndpoints = ( + baseUrl: string, + clientId: string +): AustraliaBrandEnvironment['endpoints'] => ({ + session: `${baseUrl}/api/v1/user/oauth2/authorize?response_type=code&client_id=${clientId}&redirect_uri=${encodeURIComponent( + `${baseUrl}/api/v1/user/oauth2/redirect` + )}&lang=en`, + 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`, + integration: `${baseUrl}/api/v1/user/integrationinfo`, + silentSignIn: `${baseUrl}/api/v1/user/silentsignin`, +}); + +type EnvironmentConfig = { + stampMode: StampMode; + stampsFile?: string; +}; +type BrandEnvironmentConfig = Pick & Partial; + +const getHyundaiEnvironment = ({ + stampMode, + stampsFile, +}: EnvironmentConfig): AustraliaBrandEnvironment => { + const host = 'au-apigw.ccs.hyundai.com.au:8080'; + const baseUrl = `https://${host}`; + const clientId = '855c72df-dfd7-4230-ab03-67cbf902bb1c'; + const appId = 'f9ccfdac-a48d-4c57-bd32-9116963c24ed'; // Android app ID + return { + brand: 'hyundai', + host, + baseUrl, + clientId, + appId, + endpoints: Object.freeze(getEndpoints(baseUrl, clientId)), + basicToken: + 'Basic ODU1YzcyZGYtZGZkNy00MjMwLWFiMDMtNjdjYmY5MDJiYjFjOmU2ZmJ3SE0zMllOYmhRbDBwdmlhUHAzcmY0dDNTNms5MWVjZUEzTUpMZGJkVGhDTw==', + stamp: getStampGenerator({ + appId, + brand: 'hyundai', + mode: stampMode, + region: REGIONS.AU, + stampHost: 'https://raw.githubusercontent.com/neoPix/bluelinky-stamps/master/', + stampsFile: stampsFile, + }), + }; +}; + +const getKiaEnvironment = ({ + stampMode, + stampsFile, +}: EnvironmentConfig): AustraliaBrandEnvironment => { + const host = 'au-apigw.ccs.kia.com.au:8082'; + const baseUrl = `https://${host}`; + const clientId = '8acb778a-b918-4a8d-8624-73a0beb64289'; + const appId = '4ad4dcde-be23-48a8-bc1c-91b94f5c06f8'; // Android app ID + return { + brand: 'hyundai', + host, + baseUrl, + clientId, + appId, + endpoints: Object.freeze(getEndpoints(baseUrl, clientId)), + basicToken: + 'Basic OGFjYjc3OGEtYjkxOC00YThkLTg2MjQtNzNhMGJlYjY0Mjg5OjdTY01NbTZmRVlYZGlFUEN4YVBhUW1nZVlkbFVyZndvaDRBZlhHT3pZSVMyQ3U5VA==', + stamp: getStampGenerator({ + appId, + brand: 'kia', + mode: stampMode, + region: REGIONS.AU, + stampHost: 'https://raw.githubusercontent.com/neoPix/bluelinky-stamps/master/', + stampsFile: stampsFile, + }), + }; +}; + +export const getBrandEnvironment = ({ + brand, + stampMode = StampMode.LOCAL, + stampsFile, +}: BrandEnvironmentConfig): AustraliaBrandEnvironment => { + switch (brand) { + case 'hyundai': + return Object.freeze(getHyundaiEnvironment({ stampMode, stampsFile })); + case 'kia': + return Object.freeze(getKiaEnvironment({ stampMode, stampsFile })); + default: + throw new Error(`Constructor ${brand} is not managed.`); + } +}; diff --git a/src/constants/europe.ts b/src/constants/europe.ts index 7deec0b..fdcdc17 100644 --- a/src/constants/europe.ts +++ b/src/constants/europe.ts @@ -1,6 +1,7 @@ -import { EuropeBlueLinkyConfig, StampMode } from '../controllers/european.controller'; +import { REGIONS } from '../constants'; +import { EuropeBlueLinkyConfig } from '../controllers/european.controller'; import { Brand } from '../interfaces/common.interfaces'; -import { getStampGenerator } from './europe.stamps'; +import { StampMode, getStampGenerator } from './stamps'; export type EULanguages = | 'cs' @@ -91,7 +92,14 @@ const getHyundaiEnvironment = ({ basicToken: 'Basic NmQ0NzdjMzgtM2NhNC00Y2YzLTk1NTctMmExOTI5YTk0NjU0OktVeTQ5WHhQekxwTHVvSzB4aEJDNzdXNlZYaG10UVI5aVFobUlGampvWTRJcHhzVg==', GCMSenderID: '414998006775', - stamp: getStampGenerator({ appId, brand: 'hyundai', mode: stampMode, stampsFile: stampsFile }), + stamp: getStampGenerator({ + appId, + brand: 'hyundai', + mode: stampMode, + region: REGIONS.EU, + stampHost: 'https://raw.githubusercontent.com/neoPix/bluelinky-stamps/master/', + stampsFile: stampsFile, + }), brandAuthUrl({ language, serviceId, userId }) { const newAuthClientId = '64621b96-0f0d-11ec-82a8-0242ac130003'; return `https://eu-account.hyundai.com/auth/realms/euhyundaiidm/protocol/openid-connect/auth?client_id=${newAuthClientId}&scope=openid%20profile%20email%20phone&response_type=code&hkid_session_reset=true&redirect_uri=${baseUrl}/api/v1/user/integration/redirect/login&ui_locales=${language}&state=${serviceId}:${userId}`; @@ -116,7 +124,14 @@ const getKiaEnvironment = ({ endpoints: Object.freeze(getEndpoints(baseUrl, clientId)), basicToken: 'Basic ZmRjODVjMDAtMGEyZi00YzY0LWJjYjQtMmNmYjE1MDA3MzBhOnNlY3JldA==', GCMSenderID: '345127537656', - stamp: getStampGenerator({ appId, brand: 'kia', mode: stampMode, stampsFile: stampsFile }), + stamp: getStampGenerator({ + appId, + brand: 'kia', + mode: stampMode, + region: REGIONS.EU, + stampHost: 'https://raw.githubusercontent.com/neoPix/bluelinky-stamps/master/', + stampsFile: stampsFile, + }), brandAuthUrl({ language, serviceId, userId }) { const newAuthClientId = '572e0304-5f8d-4b4c-9dd5-41aa84eed160'; return `https://eu-account.kia.com/auth/realms/eukiaidm/protocol/openid-connect/auth?client_id=${newAuthClientId}&scope=openid%20profile%20email%20phone&response_type=code&hkid_session_reset=true&redirect_uri=${baseUrl}/api/v1/user/integration/redirect/login&ui_locales=${language}&state=${serviceId}:${userId}`; diff --git a/src/constants/europe.stamps.ts b/src/constants/stamps.ts similarity index 61% rename from src/constants/europe.stamps.ts rename to src/constants/stamps.ts index e328a7e..9ffc7f7 100644 --- a/src/constants/europe.stamps.ts +++ b/src/constants/stamps.ts @@ -2,8 +2,14 @@ import { readFile } from 'fs'; import { promisify } from 'util'; import got from 'got'; import { Brand } from '../interfaces/common.interfaces'; -import { hyundaiCFB, kiaCFB } from './europe.cfb'; -import { StampMode } from '../controllers/european.controller'; +import { hyundaiCFB as australiaHyundaiCFB, kiaCFB as australiaKiaCFB } from './australia.cfb'; +import { hyundaiCFB as europeHyundaiCFB, kiaCFB as europeKiaCFB } from './europe.cfb'; +import { REGIONS } from '../constants'; + +export enum StampMode { + LOCAL = 'LOCAL', + DISTANT = 'DISTANT', +} interface StampCollection { stamps: string[]; @@ -15,7 +21,8 @@ const cachedStamps = new Map(); const getAndCacheStampsFromFile = async ( file: string, - stampsFile = `https://raw.githubusercontent.com/neoPix/bluelinky-stamps/master/${file}.v2.json` + stampHost: string, + stampsFile = `${stampHost}${file}.v2.json` ): Promise => { if (stampsFile.startsWith('file://')) { const [, path] = stampsFile.split('file://'); @@ -28,9 +35,10 @@ const getAndCacheStampsFromFile = async ( }; export const getStampFromFile = - (stampFileKey: string, stampsFile?: string) => async (): Promise => { + (stampFileKey: string, stampHost: string, stampsFile?: string) => async (): Promise => { const { stamps, generated, frequency } = - cachedStamps.get(stampFileKey) ?? (await getAndCacheStampsFromFile(stampFileKey, stampsFile)); + cachedStamps.get(stampFileKey) ?? + (await getAndCacheStampsFromFile(stampFileKey, stampHost, stampsFile)); const generatedDate = new Date(generated); const millisecondsSinceStampsGeneration = Date.now() - generatedDate.getTime(); const position = Math.floor(millisecondsSinceStampsGeneration / frequency); @@ -51,17 +59,23 @@ const xorBuffers = (a: Buffer, b: Buffer) => { return outBuffer; }; -const getCFB = (brand: Brand) => { - switch (brand) { - case 'kia': - return kiaCFB; - case 'hyundai': - return hyundaiCFB; +const getCFB = (brand: Brand, region: REGIONS) => { + switch (region) { + case REGIONS.AU: + return brand === 'kia' ? australiaKiaCFB : australiaHyundaiCFB; + case REGIONS.EU: + return brand === 'kia' ? europeKiaCFB : europeHyundaiCFB; + default: + throw new Error('Local stamp generation is only supported in Australia and Europe'); } }; -export const getStampFromCFB = (appId: string, brand: Brand): (() => Promise) => { - const cfb = getCFB(brand); +export const getStampFromCFB = ( + appId: string, + brand: Brand, + region: REGIONS +): (() => Promise) => { + const cfb = getCFB(brand, region); return async (): Promise => { const rawData = Buffer.from(`${appId}:${Date.now()}`, 'utf-8'); return Promise.resolve(xorBuffers(cfb, rawData).toString('base64')); @@ -69,21 +83,25 @@ export const getStampFromCFB = (appId: string, brand: Brand): (() => Promise Promise) => { switch (mode) { case StampMode.LOCAL: - return getStampFromCFB(appId, brand); + return getStampFromCFB(appId, brand, region); case StampMode.DISTANT: default: - return getStampFromFile(`${brand}-${appId}`, stampsFile); + return getStampFromFile(`${brand}-${appId}`, stampHost, stampsFile); } }; diff --git a/src/controllers/australia.controller.ts b/src/controllers/australia.controller.ts new file mode 100644 index 0000000..361f71b --- /dev/null +++ b/src/controllers/australia.controller.ts @@ -0,0 +1,329 @@ +import got, { GotInstance, GotJSONFn } from 'got'; +import AustraliaVehicle from '../vehicles/australia.vehicle'; +import { Vehicle } from '../vehicles/vehicle'; +import { AustraliaBrandEnvironment, getBrandEnvironment } from './../constants/australia'; +import { BlueLinkyConfig, Session } from './../interfaces/common.interfaces'; +import { SessionController } from './controller'; + +import { URLSearchParams } from 'url'; +import logger from '../logger'; + +import { CookieJar } from 'tough-cookie'; +import { VehicleRegisterOptions } from '../interfaces/common.interfaces'; +import { asyncMap, manageBluelinkyError, Stringifiable, uuidV4 } from '../tools/common.tools'; +import { AustraliaAuthStrategy } from './authStrategies/australia.authStrategy'; +import { Code } from './authStrategies/authStrategy'; +import { StampMode } from '../constants/stamps'; + +export interface AustraliaBlueLinkyConfig extends BlueLinkyConfig { + region: 'AU'; + stampMode?: StampMode; + stampsFile?: string; +} + +interface AustraliaVehicleDescription { + nickname: string; + vehicleName: string; + regDate: string; + vehicleId: string; +} + +export class AustraliaController extends SessionController { + private _environment: AustraliaBrandEnvironment; + private authStrategy: AustraliaAuthStrategy; + constructor(userConfig: AustraliaBlueLinkyConfig) { + super(userConfig); + this.session.deviceId = uuidV4(); + this._environment = getBrandEnvironment(userConfig); + this.authStrategy = new AustraliaAuthStrategy(this._environment); + logger.debug('AU Controller created'); + } + + public get environment(): AustraliaBrandEnvironment { + return this._environment; + } + + public session: Session = { + accessToken: undefined, + refreshToken: undefined, + controlToken: undefined, + deviceId: uuidV4(), + tokenExpiresAt: 0, + controlTokenExpiresAt: 0, + }; + + private vehicles: Array = []; + + public async refreshAccessToken(): Promise { + const shouldRefreshToken = Math.floor(Date.now() / 1000 - this.session.tokenExpiresAt) >= -10; + + if (!this.session.refreshToken) { + logger.debug('Need refresh token to refresh access token. Use login()'); + return 'Need refresh token to refresh access token. Use login()'; + } + + if (!shouldRefreshToken) { + logger.debug('Token not expired, no need to refresh'); + return 'Token not expired, no need to refresh'; + } + + const formData = new URLSearchParams(); + formData.append('grant_type', 'refresh_token'); + formData.append('redirect_uri', 'https://www.getpostman.com/oauth2/callback'); // Oversight from Hyundai developers + formData.append('refresh_token', this.session.refreshToken); + + 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); + } catch (err) { + throw manageBluelinkyError(err, 'AustraliaController.refreshAccessToken'); + } + + logger.debug('Token refreshed'); + return 'Token refreshed'; + } + + public async enterPin(): Promise { + if (this.session.accessToken === '') { + throw 'Token not set'; + } + + 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, 'AustraliaController.pin'); + } + } + + public async login(): Promise { + try { + if (!this.userConfig.password || !this.userConfig.username) { + throw new Error('@AustraliaController.login: username and password must be defined.'); + } + let authResult: { code: Code; cookies: CookieJar } | null = null; + try { + logger.debug( + `@AustraliaController.login: Trying to sign in with ${this.authStrategy.name}` + ); + authResult = await this.authStrategy.login({ + password: this.userConfig.password, + username: this.userConfig.username, + }); + } catch (e) { + throw new Error( + `@AustraliaController.login: sign in with ${this.authStrategy.name} failed with error ${( + e as Stringifiable + ).toString()}` + ); + } + logger.debug('@AustraliaController.login: Authenticated properly with user and password'); + const genRanHex = size => + [...Array(size)].map(() => Math.floor(Math.random() * 16).toString(16)).join(''); + const notificationReponse = await got( + `${this.environment.baseUrl}/api/v1/spa/notifications/register`, + { + method: 'POST', + headers: { + 'ccsp-service-id': this.environment.clientId, + 'Content-Type': 'application/json;charset=UTF-8', + 'Host': this.environment.host, + 'Connection': 'Keep-Alive', + 'Accept-Encoding': 'gzip', + 'User-Agent': 'okhttp/3.10.0', + 'ccsp-application-id': this.environment.appId, + 'Stamp': await this.environment.stamp(), + }, + body: { + pushRegId: genRanHex(64), + pushType: 'GCM', + uuid: this.session.deviceId, + }, + json: true, + } + ); + + if (notificationReponse) { + this.session.deviceId = notificationReponse.body.resMsg.deviceId; + } + logger.debug('@AustraliaController.login: Device registered'); + + const formData = new URLSearchParams(); + formData.append('grant_type', 'authorization_code'); + formData.append('redirect_uri', this.environment.endpoints.redirectUri); + formData.append('code', authResult.code); + + 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', + 'grant_type': 'authorization_code', + 'ccsp-application-id': this.environment.appId, + 'Stamp': await this.environment.stamp(), + }, + body: formData.toString(), + cookieJar: authResult.cookies, + }); + + if (response.statusCode !== 200) { + throw new Error( + `@AustraliaController.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.refreshToken = responseBody.refresh_token; + this.session.tokenExpiresAt = Math.floor(Date.now() / 1000 + responseBody.expires_in); + } + logger.debug('@AustraliaController.login: Session defined properly'); + + return 'Login success'; + } catch (err) { + throw manageBluelinkyError(err, 'AustraliaController.login'); + } + } + + public async logout(): Promise { + return 'OK'; + } + + public async getVehicles(): Promise> { + if (this.session.accessToken === undefined) { + throw 'Token not set'; + } + + try { + const response = await got(`${this.environment.baseUrl}/api/v1/spa/vehicles`, { + method: 'GET', + headers: { + ...this.defaultHeaders, + 'Stamp': await 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: { + ...this.defaultHeaders, + 'Stamp': await 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(`@AustraliaController.getVehicles: Added vehicle ${vehicleConfig.id}`); + return new AustraliaVehicle(vehicleConfig, this); + } + ); + } catch (err) { + throw manageBluelinkyError(err, 'AustraliaController.getVehicles'); + } + + return this.vehicles; + } + + private async checkControlToken(): Promise { + await this.refreshAccessToken(); + if (this.session?.controlTokenExpiresAt !== undefined) { + if (!this.session.controlToken || Date.now() / 1000 > this.session.controlTokenExpiresAt) { + await this.enterPin(); + } + } + } + + public async getVehicleHttpService(): Promise> { + await this.checkControlToken(); + return got.extend({ + baseUrl: this.environment.baseUrl, + headers: { + ...this.defaultHeaders, + 'Authorization': this.session.controlToken, + 'Stamp': await this.environment.stamp(), + }, + json: true, + }); + } + + public async getApiHttpService(): Promise> { + await this.refreshAccessToken(); + return got.extend({ + baseUrl: this.environment.baseUrl, + headers: { + ...this.defaultHeaders, + 'Stamp': await this.environment.stamp(), + }, + json: true, + }); + } + + private get defaultHeaders() { + return { + 'Authorization': this.session.accessToken, + 'offset': (new Date().getTimezoneOffset() / 60).toFixed(2), + 'ccsp-device-id': this.session.deviceId, + 'ccsp-application-id': this.environment.appId, + 'Content-Type': 'application/json', + }; + } +} diff --git a/src/controllers/authStrategies/australia.authStrategy.ts b/src/controllers/authStrategies/australia.authStrategy.ts new file mode 100644 index 0000000..ba3415f --- /dev/null +++ b/src/controllers/authStrategies/australia.authStrategy.ts @@ -0,0 +1,51 @@ +import got from 'got'; +import { CookieJar } from 'tough-cookie'; +import { AuthStrategy, Code } from './authStrategy'; +import Url from 'url'; +import { AustraliaBrandEnvironment } from '../../constants/australia'; + +export class AustraliaAuthStrategy implements AuthStrategy { + constructor(private readonly environment: AustraliaBrandEnvironment) {} + + public get name(): string { + return 'AustraliaAuthStrategy'; + } + + async login( + user: { username: string; password: string }, + options?: { cookieJar: CookieJar } + ): Promise<{ code: Code; cookies: CookieJar }> { + const cookieJar = options?.cookieJar ?? new CookieJar(); + await got(this.environment.endpoints.session, { cookieJar }); + const { body: bodyStr, statusCode } = await got(this.environment.endpoints.login, { + method: 'POST', + headers: { + 'Content-Type': 'text/plain', + }, + body: JSON.stringify({ + 'email': user.username, + 'password': user.password, + 'mobileNum': '', + }), + cookieJar, + }); + const body = JSON.parse(bodyStr); + if (!body.redirectUrl) { + throw new Error( + `@AustraliaAuthStrategy.login: sign In didn't work, could not retrieve auth code. status: ${statusCode}, body: ${JSON.stringify( + body + )}` + ); + } + const { code } = Url.parse(body.redirectUrl, true).query; + if (!code) { + throw new Error( + '@AustraliaAuthStrategy.login: AuthCode was not found, you probably need to migrate your account.' + ); + } + return { + code: code as Code, + cookies: cookieJar, + }; + } +} diff --git a/src/controllers/european.controller.ts b/src/controllers/european.controller.ts index 7bcf7b5..d9515bd 100644 --- a/src/controllers/european.controller.ts +++ b/src/controllers/european.controller.ts @@ -6,7 +6,6 @@ import { EU_LANGUAGES, } from './../constants/europe'; import { BlueLinkyConfig, Session } from './../interfaces/common.interfaces'; -import * as pr from 'push-receiver'; import got, { GotInstance, GotJSONFn } from 'got'; import { Vehicle } from '../vehicles/vehicle'; import EuropeanVehicle from '../vehicles/european.vehicle'; @@ -21,11 +20,7 @@ import { asyncMap, manageBluelinkyError, Stringifiable, uuidV4 } from '../tools/ import { AuthStrategy, Code } from './authStrategies/authStrategy'; import { EuropeanBrandAuthStrategy } from './authStrategies/european.brandAuth.strategy'; import { EuropeanLegacyAuthStrategy } from './authStrategies/european.legacyAuth.strategy'; - -export enum StampMode { - LOCAL = 'LOCAL', - DISTANT = 'DISTANT', -} +import { StampMode } from '../constants/stamps'; export interface EuropeBlueLinkyConfig extends BlueLinkyConfig { language?: EULanguages; @@ -176,7 +171,7 @@ export class EuropeanController extends SessionController } catch (e) { logger.error( `@EuropeController.login: sign in with ${ - this.authStrategies.main.name + this.authStrategies.main.name } failed with error ${(e as Stringifiable).toString()}` ); logger.debug( @@ -188,7 +183,8 @@ export class EuropeanController extends SessionController }); } logger.debug('@EuropeController.login: Authenticated properly with user and password'); - const genRanHex = size => [...Array(size)].map(() => Math.floor(Math.random() * 16).toString(16)).join(''); + const genRanHex = size => + [...Array(size)].map(() => Math.floor(Math.random() * 16).toString(16)).join(''); const notificationReponse = await got( `${this.environment.baseUrl}/api/v1/spa/notifications/register`, { diff --git a/src/index.ts b/src/index.ts index 7e80f67..eb47599 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ import { AmericanBlueLinkyConfig, AmericanController } from './controllers/american.controller'; import { EuropeanController, EuropeBlueLinkyConfig } from './controllers/european.controller'; import { CanadianBlueLinkyConfig, CanadianController } from './controllers/canadian.controller'; -import { ChineseBlueLinkConfig, ChineseController} from './controllers/chinese.controller'; +import { ChineseBlueLinkConfig, ChineseController } from './controllers/chinese.controller'; import { EventEmitter } from 'events'; import logger from './logger'; import { Session } from './interfaces/common.interfaces'; @@ -12,12 +12,15 @@ import CanadianVehicle from './vehicles/canadian.vehicle'; import ChineseVehicle from './vehicles/chinese.vehicle'; import { SessionController } from './controllers/controller'; import { Vehicle } from './vehicles/vehicle'; +import { AustraliaBlueLinkyConfig, AustraliaController } from './controllers/australia.controller'; +import AustraliaVehicle from './vehicles/australia.vehicle'; type BluelinkyConfigRegions = | AmericanBlueLinkyConfig | CanadianBlueLinkyConfig | EuropeBlueLinkyConfig - | ChineseBlueLinkConfig; + | ChineseBlueLinkConfig + | AustraliaBlueLinkyConfig; const DEFAULT_CONFIG = { username: '', @@ -30,7 +33,7 @@ const DEFAULT_CONFIG = { vehicleId: undefined, }; -class BlueLinky< +export class BlueLinky< T extends BluelinkyConfigRegions = AmericanBlueLinkyConfig, REGION = T['region'], VEHICLE_TYPE extends Vehicle = REGION extends REGIONS.US @@ -39,8 +42,9 @@ class BlueLinky< ? CanadianVehicle : REGION extends REGIONS.CN ? ChineseVehicle + : REGION extends REGIONS.AU + ? AustraliaVehicle : EuropeanVehicle - > extends EventEmitter { private controller: SessionController; private vehicles: Array = []; @@ -69,6 +73,9 @@ class BlueLinky< case REGIONS.CN: this.controller = new ChineseController(this.config as ChineseBlueLinkConfig); break; + case REGIONS.AU: + this.controller = new AustraliaController(this.config as AustraliaBlueLinkyConfig); + break; default: throw new Error('Your region is not supported yet.'); } diff --git a/src/interfaces/common.interfaces.ts b/src/interfaces/common.interfaces.ts index c279e9d..5c3e55d 100644 --- a/src/interfaces/common.interfaces.ts +++ b/src/interfaces/common.interfaces.ts @@ -417,6 +417,19 @@ export interface VehicleStartOptions { unit?: 'C' | 'F'; } +export enum VehicleWindowState { + CLOSED = 0, + OPEN = 1, + VENTILATION = 2, +} + +export interface VehicleWindowsOptions { + backLeft: VehicleWindowState; + backRight: VehicleWindowState; + frontLeft: VehicleWindowState; + frontRight: VehicleWindowState; +} + export interface VehicleRegisterOptions { nickname: string; name: string; diff --git a/src/util.ts b/src/util.ts index cfa729f..e87d572 100644 --- a/src/util.ts +++ b/src/util.ts @@ -24,8 +24,14 @@ const REGION_STEP_RANGES = { CN: { start: 14, end: 30, - step:0.5 - } + step: 0.5, + }, + // TODO: verify the Australian temp code ranges + AU: { + start: 17, + end: 27, + step: 0.5, + }, }; // Converts Kia's stupid temp codes to celsius diff --git a/src/vehicles/australia.vehicle.ts b/src/vehicles/australia.vehicle.ts new file mode 100644 index 0000000..3cad400 --- /dev/null +++ b/src/vehicles/australia.vehicle.ts @@ -0,0 +1,667 @@ +import { + ChargeTarget, + DEFAULT_VEHICLE_STATUS_OPTIONS, + POSSIBLE_CHARGE_LIMIT_VALUES, + REGIONS, +} from '../constants'; +import { + DeepPartial, + EVChargeModeTypes, + EVPlugTypes, + FullVehicleStatus, + RawVehicleStatus, + VehicleDayTrip, + VehicleLocation, + VehicleMonthTrip, + VehicleMonthlyReport, + VehicleOdometer, + VehicleRegisterOptions, + VehicleStartOptions, + VehicleStatus, + VehicleStatusOptions, + VehicleTargetSOC, + VehicleWindowsOptions, +} from '../interfaces/common.interfaces'; + +import got from 'got'; +import { AustraliaController } from '../controllers/australia.controller'; +import { + EUDatedDriveHistory, + EUDriveHistory, + EUPOIInformation, + historyDrivingPeriod, +} from '../interfaces/european.interfaces'; +import logger from '../logger'; +import { ManagedBluelinkyError, manageBluelinkyError } from '../tools/common.tools'; +import { addMinutes, celciusToTempCode, parseDate, tempCodeToCelsius } from '../util'; +import { Vehicle } from './vehicle'; + +export default class AustraliaVehicle extends Vehicle { + public region = REGIONS.AU; + public serverRates: { + max: number; + current: number; + reset?: Date; + updatedAt?: Date; + } = { + max: -1, + current: -1, + }; + + constructor( + public vehicleConfig: VehicleRegisterOptions, + public controller: AustraliaController + ) { + super(vehicleConfig, controller); + logger.debug(`AU Vehicle ${this.vehicleConfig.id} created`); + } + + /** + * + * @param config - Vehicle start configuration for the request + * @returns Promise + * @remarks - not sure if this supports starting ICE vehicles + */ + public async start(config: VehicleStartOptions): Promise { + const http = await this.controller.getVehicleHttpService(); + try { + const response = this.updateRates( + await http.post(`/api/v2/spa/vehicles/${this.vehicleConfig.id}/control/engine`, { + body: { + action: 'start', + hvacType: 0, + options: { + defrost: config.defrost, + heating1: config.heatedFeatures ? 1 : 0, + }, + tempCode: celciusToTempCode(REGIONS.AU, config.temperature), + unit: config.unit, + }, + }) + ); + logger.info(`Climate started for vehicle ${this.vehicleConfig.id}`); + return response.body; + } catch (err) { + throw manageBluelinkyError(err, 'AustraliaVehicle.start'); + } + } + + public async stop(): Promise { + const http = await this.controller.getVehicleHttpService(); + try { + const response = this.updateRates( + await http.post(`/api/v2/spa/vehicles/${this.vehicleConfig.id}/control/engine`, { + body: { + action: 'stop', + hvacType: 0, + options: { + defrost: true, + heating1: 1, + }, + tempCode: '10H', + unit: 'C', + }, + }) + ); + logger.info(`Climate stopped for vehicle ${this.vehicleConfig.id}`); + return response.body; + } catch (err) { + throw manageBluelinkyError(err, 'AustraliaVehicle.stop'); + } + } + + public async lock(): Promise { + const http = await this.controller.getVehicleHttpService(); + try { + const response = this.updateRates( + await http.post(`/api/v2/spa/vehicles/${this.vehicleConfig.id}/control/door`, { + body: { + action: 'close', + deviceId: this.controller.session.deviceId, + }, + }) + ); + if (response.statusCode === 200) { + logger.debug(`Vehicle ${this.vehicleConfig.id} locked`); + return 'Lock successful'; + } + return 'Something went wrong!'; + } catch (err) { + throw manageBluelinkyError(err, 'AustraliaVehicle.lock'); + } + } + + public async unlock(): Promise { + const http = await this.controller.getVehicleHttpService(); + try { + const response = this.updateRates( + await http.post(`/api/v2/spa/vehicles/${this.vehicleConfig.id}/control/door`, { + body: { + action: 'open', + deviceId: this.controller.session.deviceId, + }, + }) + ); + + if (response.statusCode === 200) { + logger.debug(`Vehicle ${this.vehicleConfig.id} unlocked`); + return 'Unlock successful'; + } + + return 'Something went wrong!'; + } catch (err) { + throw manageBluelinkyError(err, 'AustraliaVehicle.unlock'); + } + } + + public async setWindows(config: VehicleWindowsOptions): Promise { + const http = await this.controller.getVehicleHttpService(); + try { + const response = this.updateRates( + await http.post(`/api/v2/spa/vehicles/${this.vehicleConfig.id}/control/windowcurtain`, { + body: config, + }) + ); + logger.info(`Climate started for vehicle ${this.vehicleConfig.id}`); + return response.body; + } catch (err) { + throw manageBluelinkyError(err, 'AustraliaVehicle.start'); + } + } + + public async fullStatus(input: VehicleStatusOptions): Promise { + const statusConfig = { + ...DEFAULT_VEHICLE_STATUS_OPTIONS, + ...input, + }; + + const http = await this.controller.getVehicleHttpService(); + + try { + const vehicleStatusResponse = this.updateRates( + statusConfig.refresh + ? await http.get(`/api/v2/spa/vehicles/${this.vehicleConfig.id}/status/latest`) + : await http.get(`/api/v2/spa/vehicles/${this.vehicleConfig.id}/status`) + ); + const locationResponse = this.updateRates( + await http.get(`/api/v2/spa/vehicles/${this.vehicleConfig.id}/location/park`) + ); + const odometer = await this.odometer(); + // TODO: make odometer in `FullVehicleStatus` nullable + if (!odometer) { + return null; + } + + this._fullStatus = { + vehicleLocation: locationResponse.body.resMsg.gpsDetail, + odometer, + vehicleStatus: vehicleStatusResponse.body.resMsg, + }; + return this._fullStatus; + } catch (err) { + throw manageBluelinkyError(err, 'AustraliaVehicle.fullStatus'); + } + } + + public async status( + input: VehicleStatusOptions + ): Promise { + const statusConfig = { + ...DEFAULT_VEHICLE_STATUS_OPTIONS, + ...input, + }; + + const http = await this.controller.getVehicleHttpService(); + + try { + const cacheString = statusConfig.refresh ? '' : '/latest'; + const response = this.updateRates( + await http.get(`/api/v2/spa/vehicles/${this.vehicleConfig.id}/status${cacheString}`) + ); + const vehicleStatus = response.body.resMsg; + + 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, + }, + }, + climate: { + active: vehicleStatus?.airCtrlOn, + steeringwheelHeat: !!vehicleStatus?.steerWheelHeat, + sideMirrorHeat: false, + rearWindowHeat: !!vehicleStatus?.sideBackWindowHeat, + defrost: vehicleStatus?.defrost, + temperatureSetpoint: tempCodeToCelsius(REGIONS.AU, vehicleStatus?.airTemp?.value), + temperatureUnit: vehicleStatus?.airTemp?.unit, + }, + 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, + }, + lastupdate: vehicleStatus?.time ? parseDate(vehicleStatus?.time) : null, + }; + + 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; + + return this._status; + } catch (err) { + throw manageBluelinkyError(err, 'AustraliaVehicle.status'); + } + } + + public async odometer(): Promise { + const http = await this.controller.getVehicleHttpService(); + try { + const response = this.updateRates( + await http.post(`/api/v2/spa/vehicles/${this.vehicleConfig.id}/monthlyreport`, { + body: { + setRptMonth: toMonthDate({ + year: new Date().getFullYear(), + month: new Date().getMonth() + 1, + }), + }, + }) + ); + this._odometer = { + // TODO: need to hardcode the unit here: what unit values exist? should this be an enum? + unit: 0, + value: response.body.resMsg.odometer, + }; + return this._odometer; + } catch (err) { + throw manageBluelinkyError(err, 'AustraliaVehicle.odometer'); + } + } + + public async location(): Promise { + const http = await this.controller.getVehicleHttpService(); + try { + const response = this.updateRates( + await http.get(`/api/v2/spa/vehicles/${this.vehicleConfig.id}/location/park`) + ); + + 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, + }; + + return this._location; + } catch (err) { + throw manageBluelinkyError(err, 'AustraliaVehicle.location'); + } + } + + public async startCharge(): Promise { + // TODO: test this + const http = await this.controller.getVehicleHttpService(); + try { + const response = this.updateRates( + await http.post(`/api/v2/spa/vehicles/${this.vehicleConfig.id}/control/charge`, { + body: { + action: 'start', + deviceId: this.controller.session.deviceId, + }, + }) + ); + + 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, 'AustraliaVehicle.startCharge'); + } + } + + public async stopCharge(): Promise { + // TODO: test this + const http = await this.controller.getVehicleHttpService(); + try { + const response = this.updateRates( + await http.post(`/api/v2/spa/vehicles/${this.vehicleConfig.id}/control/charge`, { + body: { + action: 'stop', + deviceId: this.controller.session.deviceId, + }, + }) + ); + + 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, 'AustraliaVehicle.stopCharge'); + } + } + + public async monthlyReport( + month: { year: number; month: number } = { + year: new Date().getFullYear(), + month: new Date().getMonth() + 1, + } + ): Promise | undefined> { + const http = await this.controller.getVehicleHttpService(); + try { + const response = this.updateRates( + await http.post(`/api/v2/spa/vehicles/${this.vehicleConfig.id}/monthlyreport`, { + body: { + setRptMonth: toMonthDate(month), + }, + }) + ); + 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, 'AustraliaVehicle.monthyReports'); + } + } + + public async tripInfo(date: { + year: number; + month: number; + day: number; + }): Promise[] | undefined>; + public async tripInfo(date?: { + year: number; + month: number; + }): Promise | undefined>; + + public async tripInfo( + date: { year: number; month: number; day?: number } = { + year: new Date().getFullYear(), + month: new Date().getMonth() + 1, + } + ): Promise[] | DeepPartial | undefined> { + const http = await this.controller.getApiHttpService(); + try { + const perDay = Boolean(date.day); + const response = this.updateRates( + await http.post(`/api/v1/spa/vehicles/${this.vehicleConfig.id}/tripinfo`, { + body: { + setTripLatest: 10, + setTripMonth: !perDay ? toMonthDate(date) : undefined, + setTripDay: perDay ? toDayDate(date) : undefined, + tripPeriodType: perDay ? 1 : 0, + }, + }) + ); + + if (!perDay) { + const rawData = response.body.resMsg; + return { + days: Array.isArray(rawData?.tripDayList) + ? rawData?.tripDayList.map(day => ({ + dayRaw: day.tripDayInMonth, + date: day.tripDayInMonth ? parseDate(day.tripDayInMonth) : undefined, + tripsCount: day.tripCntDay, + })) + : [], + durations: { + drive: rawData?.tripDrvTime, + idle: rawData?.tripIdleTime, + }, + distance: rawData?.tripDist, + speed: { + avg: rawData?.tripAvgSpeed, + max: rawData?.tripMaxSpeed, + }, + } as VehicleMonthTrip; + } else { + 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}`); + 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, 'AustraliaVehicle.history'); + } + } + + public async driveHistory(period: historyDrivingPeriod = historyDrivingPeriod.DAY): Promise< + DeepPartial<{ + cumulated: EUDriveHistory[]; + history: EUDatedDriveHistory[]; + }> + > { + const http = await this.controller.getApiHttpService(); + try { + const response = await http.post(`/api/v1/spa/vehicles/${this.vehicleConfig.id}/drvhistory`, { + body: { + periodTarget: period, + }, + }); + return { + cumulated: response.body.resMsg.drivingInfo?.map(line => ({ + period: line.drivingPeriod, + consumption: { + total: line.totalPwrCsp, + engine: line.motorPwrCsp, + climate: line.climatePwrCsp, + devices: line.eDPwrCsp, + battery: line.batteryMgPwrCsp, + }, + regen: line.regenPwr, + distance: line.calculativeOdo, + })), + history: response.body.resMsg.drivingInfoDetail?.map(line => ({ + period: line.drivingPeriod, + rawDate: line.drivingDate, + date: line.drivingDate ? parseDate(line.drivingDate) : undefined, + consumption: { + total: line.totalPwrCsp, + engine: line.motorPwrCsp, + climate: line.climatePwrCsp, + devices: line.eDPwrCsp, + battery: line.batteryMgPwrCsp, + }, + regen: line.regenPwr, + distance: line.calculativeOdo, + })), + }; + } catch (err) { + throw manageBluelinkyError(err, 'AustraliaVehicle.history'); + } + } + + /** + * Warning: Only works on EV + */ + public async getChargeTargets(): Promise[] | undefined> { + const http = await this.controller.getVehicleHttpService(); + try { + const response = this.updateRates( + await http.get(`/api/v2/spa/vehicles/${this.vehicleConfig.id}/charge/target`) + ); + 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, 'AustraliaVehicle.getChargeTargets'); + } + } + + /** + * Warning: Only works on EV + */ + public async setChargeTargets(limits: { fast: ChargeTarget; slow: ChargeTarget }): Promise { + // TODO: test this + const http = await this.controller.getVehicleHttpService(); + 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 { + this.updateRates( + await http.post(`/api/v2/spa/vehicles/${this.vehicleConfig.id}/charge/target`, { + body: { + targetSOClist: [ + { plugType: EVChargeModeTypes.FAST, targetSOClevel: limits.fast }, + { plugType: EVChargeModeTypes.SLOW, targetSOClevel: limits.slow }, + ], + }, + }) + ); + } catch (err) { + throw manageBluelinkyError(err, 'AustraliaVehicle.setChargeTargets'); + } + } + + /** + * Define a navigation route + * @param poiInformations The list of POIs and waypoint to go through + */ + public async setNavigation(poiInformations: EUPOIInformation[]): Promise { + // TODO: test this + const http = await this.controller.getVehicleHttpService(); + try { + this.updateRates( + await http.post(`/api/v2/spa/vehicles/${this.vehicleConfig.id}/location/routes`, { + body: { + deviceID: this.controller.session.deviceId, + poiInfoList: poiInformations, + }, + }) + ); + } catch (err) { + throw manageBluelinkyError(err, 'AustraliaVehicle.setNavigation'); + } + } + + private updateRates>(resp: got.Response): got.Response { + if (resp.headers?.['x-ratelimit-limit']) { + this.serverRates.max = Number(resp.headers?.['x-ratelimit-limit']); + this.serverRates.current = Number(resp.headers?.['x-ratelimit-remaining']); + if (resp.headers?.['x-ratelimit-reset']) { + this.serverRates.reset = new Date(Number(`${resp.headers?.['x-ratelimit-reset']}000`)); + } + this.serverRates.updatedAt = new Date(); + } + return resp; + } +} + +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 date.day + ? `${toMonthDate(date)}${date.day.toString().padStart(2, '0')}` + : toMonthDate(date); +}