From f23687b5bea2e8981680f5bed25cfc8dd935d3b5 Mon Sep 17 00:00:00 2001 From: Bruce Dun Date: Thu, 20 Apr 2023 20:39:32 +0800 Subject: [PATCH] =?UTF-8?q?Add=20support=20for=20Bluelink=20and=20Kia=20Co?= =?UTF-8?q?nnect=EF=BC=88only=20signin=20tested)=20China=20version.=20(#24?= =?UTF-8?q?3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- debug.ts | 4 +- src/constants.ts | 10 +- src/constants/china.ts | 90 +++ .../authStrategies/china.authStrategy.ts | 27 + .../chinese.legacyAuth.strategy.ts | 48 ++ src/controllers/chinese.controller.ts | 335 +++++++++ src/index.ts | 11 +- src/interfaces/chinese.interfaces.ts | 54 ++ src/util.ts | 5 + src/vehicles/chinese.vehicle.ts | 637 ++++++++++++++++++ 10 files changed, 1217 insertions(+), 4 deletions(-) create mode 100644 src/constants/china.ts create mode 100644 src/controllers/authStrategies/china.authStrategy.ts create mode 100644 src/controllers/authStrategies/chinese.legacyAuth.strategy.ts create mode 100644 src/controllers/chinese.controller.ts create mode 100644 src/interfaces/chinese.interfaces.ts create mode 100644 src/vehicles/chinese.vehicle.ts diff --git a/debug.ts b/debug.ts index daa9bd5..295ec24 100644 --- a/debug.ts +++ b/debug.ts @@ -45,7 +45,7 @@ const askForRegionInput = () => { type: 'list', name: 'region', message: 'What Region are you in?', - choices: ['US', 'EU', 'CA'], + choices: ['CN','US', 'EU', 'CA'], }, { type: 'list', @@ -157,7 +157,7 @@ async function performCommand(command) { const startRes = await vehicle.start({ airCtrl: false, igniOnDuration: 10, - airTempvalue: 70, + airTempvalue: 24, defrost: false, heating1: false, }); diff --git a/src/constants.ts b/src/constants.ts index 603b390..ced6dde 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -7,6 +7,11 @@ import { getBrandEnvironment as getEUBrandEnvironment, EuropeanBrandEnvironment, } from './constants/europe'; +import { + getBrandEnvironment as getCNBrandEnvironment, + ChineseBrandEnvironment, +} from './constants/china'; + import { Brand, VehicleStatusOptions } from './interfaces/common.interfaces'; export const ALL_ENDPOINTS = { @@ -14,15 +19,18 @@ export const ALL_ENDPOINTS = { getCABrandEnvironment(brand).endpoints, EU: (brand: Brand): EuropeanBrandEnvironment['endpoints'] => getEUBrandEnvironment({ brand }).endpoints, + CN: (brand: Brand): ChineseBrandEnvironment['endpoints'] => + getCNBrandEnvironment({ brand }).endpoints, }; export const GEN2 = 2; export const GEN1 = 1; -export type REGION = 'US' | 'CA' | 'EU'; +export type REGION = 'US' | 'CA' | 'EU' | 'CN'; export enum REGIONS { US = 'US', CA = 'CA', EU = 'EU', + CN = 'CN', } // ev stuffz diff --git a/src/constants/china.ts b/src/constants/china.ts new file mode 100644 index 0000000..17babe5 --- /dev/null +++ b/src/constants/china.ts @@ -0,0 +1,90 @@ +import { ChineseBlueLinkConfig } from '../controllers/chinese.controller'; +import { Brand } from '../interfaces/common.interfaces'; + +export interface ChineseBrandEnvironment { + 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; + GCMSenderID: string; + providerDeviceId: string; + pushRegId: string; +} + +const getEndpoints = ( + baseUrl: string, + clientId: string +): ChineseBrandEnvironment['endpoints'] => ({ + session: `${baseUrl}/api/v1/user/oauth2/authorize?response_type=code&state=test&client_id=${clientId}&redirect_uri=${baseUrl}:443/api/v1/user/oauth2/redirect`, + login: `${baseUrl}/api/v1/user/signin`, + language: `${baseUrl}/api/v1/user/language`, + redirectUri: `${baseUrl}:443/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 BrandEnvironmentConfig = Pick; + +const getHyundaiEnvironment = (): ChineseBrandEnvironment => { + const host = 'prd.cn-ccapi.hyundai.com'; + const baseUrl = `https://${host}`; + const clientId = '72b3d019-5bc7-443d-a437-08f307cf06e2'; + const appId = 'ed01581a-380f-48cd-83d4-ed1490c272d0'; + return { + brand: 'hyundai', + host, + baseUrl, + clientId, + appId, + endpoints: Object.freeze(getEndpoints(baseUrl, clientId)), + basicToken: + 'Basic NzJiM2QwMTktNWJjNy00NDNkLWE0MzctMDhmMzA3Y2YwNmUyOnNlY3JldA==', + GCMSenderID: '414998006775', + providerDeviceId: '59af09e554a9442ab8589c9500d04d2e', + pushRegId: '1', + }; +}; + +const getKiaEnvironment = (): ChineseBrandEnvironment => { + const host = 'prd.cn-ccapi.kia.com'; + const baseUrl = `https://${host}`; + const clientId = '9d5df92a-06ae-435f-b459-8304f2efcc67'; + const appId = 'eea8762c-adfc-4ee4-8d7a-6e2452ddf342'; + return { + brand: 'kia', + host, + baseUrl, + clientId, + appId, + endpoints: Object.freeze(getEndpoints(baseUrl, clientId)), + basicToken: 'Basic OWQ1ZGY5MmEtMDZhZS00MzVmLWI0NTktODMwNGYyZWZjYzY3OnRzWGRrVWcwOEF2MlpaelhPZ1d6Snl4VVQ2eWVTbk5OUWtYWFBSZEtXRUFOd2wxcA==', + GCMSenderID: '345127537656', + providerDeviceId: '32dedba78045415b92db816e805ed47b', + pushRegId: 'ogc+GB5gom7zDEQjPhb3lP+bjjM=DG2rQ9Zuq0otwOU7n9y08LKjYpo=', + }; +}; + +export const getBrandEnvironment = ({ + brand, +}: BrandEnvironmentConfig): ChineseBrandEnvironment => { + switch (brand) { + case 'hyundai': + return Object.freeze(getHyundaiEnvironment()); + case 'kia': + return Object.freeze(getKiaEnvironment()); + default: + throw new Error(`Constructor ${brand} is not managed.`); + } +}; diff --git a/src/controllers/authStrategies/china.authStrategy.ts b/src/controllers/authStrategies/china.authStrategy.ts new file mode 100644 index 0000000..0cba504 --- /dev/null +++ b/src/controllers/authStrategies/china.authStrategy.ts @@ -0,0 +1,27 @@ +import got from 'got'; +import { CookieJar } from 'tough-cookie'; +import {ChineseBrandEnvironment } from '../../constants/china'; + +export type Code = string; + +export interface AuthStrategy { + readonly name: string; + login( + user: { username: string; password: string }, + options?: { cookieJar?: CookieJar } + ): Promise<{ code: Code; cookies: CookieJar }>; +} + +export async function initSession( + environment: ChineseBrandEnvironment, + cookies?: CookieJar +): Promise { + const cookieJar = cookies ?? new CookieJar(); + await got(environment.endpoints.session, { cookieJar }); + await got(environment.endpoints.language, { + method: 'POST', + body: `{"lang":"zh"}`, + cookieJar, + }); + return cookieJar; +} diff --git a/src/controllers/authStrategies/chinese.legacyAuth.strategy.ts b/src/controllers/authStrategies/chinese.legacyAuth.strategy.ts new file mode 100644 index 0000000..f698822 --- /dev/null +++ b/src/controllers/authStrategies/chinese.legacyAuth.strategy.ts @@ -0,0 +1,48 @@ +import got from 'got'; +import { CookieJar } from 'tough-cookie'; +import { ChineseBrandEnvironment } from '../../constants/china'; +import { AuthStrategy, Code, initSession } from './china.authStrategy'; +import Url from 'url'; + +export class ChineseLegacyAuthStrategy implements AuthStrategy { + constructor( + private readonly environment: ChineseBrandEnvironment, + ) {} + + public get name(): string { + return 'ChineseLegacyAuthStrategy'; + } + + async login( + user: { username: string; password: string }, + options?: { cookieJar: CookieJar } + ): Promise<{ code: Code; cookies: CookieJar }> { + const cookieJar = await initSession(this.environment, options?.cookieJar); + const { body, statusCode } = await got(this.environment.endpoints.login, { + method: 'POST', + json: true, + body: { + 'email': user.username, + 'password': user.password, + }, + cookieJar, + }); + if (!body.redirectUrl) { + throw new Error( + `@ChineseLegacyAuthStrategy.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( + '@ChineseLegacyAuthStrategy.login: AuthCode was not found, you probably need to migrate your account.' + ); + } + return { + code: code as Code, + cookies: cookieJar, + }; + } +} diff --git a/src/controllers/chinese.controller.ts b/src/controllers/chinese.controller.ts new file mode 100644 index 0000000..ba45d2c --- /dev/null +++ b/src/controllers/chinese.controller.ts @@ -0,0 +1,335 @@ +import { + getBrandEnvironment, + ChineseBrandEnvironment, +} from '../constants/china'; +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 ChineseVehicle from '../vehicles/chinese.vehicle'; +import { SessionController } from './controller'; + +import logger from '../logger'; +import { URLSearchParams } from 'url'; + +import { CookieJar } from 'tough-cookie'; +import { VehicleRegisterOptions } from '../interfaces/common.interfaces'; +import { asyncMap, manageBluelinkyError, Stringifiable, uuidV4 } from '../tools/common.tools'; +import { AuthStrategy, Code } from './authStrategies/authStrategy'; +import { ChineseLegacyAuthStrategy } from './authStrategies/chinese.legacyAuth.strategy'; + +export interface ChineseBlueLinkConfig extends BlueLinkyConfig { + region: 'CN'; +} + +interface ChineseVehicleDescription { + nickname: string; + vehicleName: string; + regDate: string; + vehicleId: string; +} + +export class ChineseController extends SessionController { + private _environment: ChineseBrandEnvironment; + private authStrategies: { + main: AuthStrategy; + }; + constructor(userConfig: ChineseBlueLinkConfig) { + super(userConfig); + this.session.deviceId = uuidV4(); + this._environment = getBrandEnvironment(userConfig); + this.authStrategies = { + main: new ChineseLegacyAuthStrategy(this._environment), + }; + logger.debug('CN Controller created'); + } + + public get environment(): ChineseBrandEnvironment { + 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, 'ChinaController.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?token=`, { + 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; + logger.debug(`controlToken is : ${this.session.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, 'ChinaController.pin'); + } + } + + public async login(): Promise { + try { + if (!this.userConfig.password || !this.userConfig.username) { + throw new Error('@ChinaController.login: username and password must be defined.'); + } + let authResult: { code: Code; cookies: CookieJar } | null = null; + try { + logger.debug( + `@ChinaController.login: Trying to sign in with ${this.authStrategies.main.name}` + ); + authResult = await this.authStrategies.main.login({ + password: this.userConfig.password, + username: this.userConfig.username, + }); + } catch (e) { + logger.error( + `@ChinaController.login: sign in with ${ + this.authStrategies.main.name + } failed with error ${(e as Stringifiable).toString()}` + ); + logger.debug( + `@ChinaController.login: Trying to sign in with ${this.authStrategies.main.name}` + ); + authResult = await this.authStrategies.main.login({ + password: this.userConfig.password, + username: this.userConfig.username, + }); + } + logger.debug('@ChinaController.login: Authenticated properly with user and password'); + + 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, + }, + body: { + pushRegId: this.environment.pushRegId, //59af09e554a9442ab8589c9500d04d2e + providerDeviceId: this.environment.providerDeviceId, + pushType: 'GCM', + uuid: uuidV4(), + }, + json: true, + } + ); + + if (notificationReponse) { + this.session.deviceId = notificationReponse.body.resMsg.deviceId; + } + logger.debug('@ChinaController.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, + }, + body: formData.toString(), + cookieJar: authResult.cookies, + }); + + if (response.statusCode !== 200) { + throw new Error(`@ChinaController.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('@ChinaController.login: Session defined properly'); + logger.debug(`accessToken is ${this.session.accessToken}\n refreshToken is ${this.session.refreshToken}\n tokenExpiresAt : ${this.session.tokenExpiresAt}`) + return 'Login success'; + } catch (err) { + throw manageBluelinkyError(err, 'ChinaController.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, + }, + 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, + }, + 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(`@ChineseController.getVehicles: Added vehicle ${vehicleConfig.id}`); + return new ChineseVehicle(vehicleConfig, this); + } + ); + } catch (err) { + throw manageBluelinkyError(err, 'EuropeController.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, + 'AuthorizationCCSP': this.session.controlToken, + 'ccsp-device-id': '2e062595-28e0-4bcb-a75a-1b395cde337c', + }, + json: true, + }); + } + public async getApiHttpService(): Promise> { + await this.refreshAccessToken(); + return got.extend({ + baseUrl: this.environment.baseUrl, + headers: { + ...this.defaultHeaders, + }, + json: true, + }); + } + + private get defaultHeaders() { + return { + 'Authorization': this.session.accessToken, + 'offset': (new Date().getTimezoneOffset() / 60).toFixed(0), //fix service 503 error. + //'ccsp-device-id': this.session.deviceId, + 'ccsp-device-id': '2e062595-28e0-4bcb-a75a-1b395cde337c', + 'ccsp-application-id': this.environment.appId, + 'Content-Type': 'application/json', + 'User-Agent': 'okhttp/4.4.0', + }; + } + +} diff --git a/src/index.ts b/src/index.ts index 1cec962..7e80f67 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +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 { EventEmitter } from 'events'; import logger from './logger'; import { Session } from './interfaces/common.interfaces'; @@ -8,13 +9,15 @@ import { REGIONS } from './constants'; import AmericanVehicle from './vehicles/american.vehicle'; import EuropeanVehicle from './vehicles/european.vehicle'; import CanadianVehicle from './vehicles/canadian.vehicle'; +import ChineseVehicle from './vehicles/chinese.vehicle'; import { SessionController } from './controllers/controller'; import { Vehicle } from './vehicles/vehicle'; type BluelinkyConfigRegions = | AmericanBlueLinkyConfig | CanadianBlueLinkyConfig - | EuropeBlueLinkyConfig; + | EuropeBlueLinkyConfig + | ChineseBlueLinkConfig; const DEFAULT_CONFIG = { username: '', @@ -34,7 +37,10 @@ class BlueLinky< ? AmericanVehicle : REGION extends REGIONS.CA ? CanadianVehicle + : REGION extends REGIONS.CN + ? ChineseVehicle : EuropeanVehicle + > extends EventEmitter { private controller: SessionController; private vehicles: Array = []; @@ -60,6 +66,9 @@ class BlueLinky< case REGIONS.CA: this.controller = new CanadianController(this.config as CanadianBlueLinkyConfig); break; + case REGIONS.CN: + this.controller = new ChineseController(this.config as ChineseBlueLinkConfig); + break; default: throw new Error('Your region is not supported yet.'); } diff --git a/src/interfaces/chinese.interfaces.ts b/src/interfaces/chinese.interfaces.ts new file mode 100644 index 0000000..e2553d1 --- /dev/null +++ b/src/interfaces/chinese.interfaces.ts @@ -0,0 +1,54 @@ +export interface ChineseEndpoints { + session: string; + login: string; + redirect_uri: string; + token: string; +} + +export interface CNPOIInformation { + phone: string; + waypointID: number; + lang: 1; + src: 'HERE'; + coord: { + lat: number; + alt: number; + lon: number; + type: 0; + }; + addr: string; + zip: string; + placeid: string; + name: string; +} + +export enum historyDrivingPeriod { + DAY = 0, + MONTH = 1, + ALL = 2, +} + +export enum historyCumulatedTypes { + TOTAL = 0, + AVERAGE = 1, + TODAY = 2, +} + +export interface CNDriveHistory { + period: historyCumulatedTypes; + consumption: { + total: number; + engine: number; + climate: number; + devices: number; + battery: number; + }; + regen: number; + distance: number; +} + +export interface CNDatedDriveHistory extends Omit { + period: historyDrivingPeriod; + rawDate: string; + date: Date; +} diff --git a/src/util.ts b/src/util.ts index e86c248..cfa729f 100644 --- a/src/util.ts +++ b/src/util.ts @@ -21,6 +21,11 @@ const REGION_STEP_RANGES = { end: 32, step: 0.5, }, + CN: { + start: 14, + end: 30, + step:0.5 + } }; // Converts Kia's stupid temp codes to celsius diff --git a/src/vehicles/chinese.vehicle.ts b/src/vehicles/chinese.vehicle.ts new file mode 100644 index 0000000..b0c402d --- /dev/null +++ b/src/vehicles/chinese.vehicle.ts @@ -0,0 +1,637 @@ +import { + REGIONS, + DEFAULT_VEHICLE_STATUS_OPTIONS, + POSSIBLE_CHARGE_LIMIT_VALUES, + ChargeTarget, +} from '../constants'; +import { + VehicleStatus, + FullVehicleStatus, + VehicleOdometer, + VehicleLocation, + VehicleRegisterOptions, + VehicleStatusOptions, + RawVehicleStatus, + EVPlugTypes, + VehicleMonthlyReport, + DeepPartial, + VehicleTargetSOC, + EVChargeModeTypes, + VehicleDayTrip, + VehicleMonthTrip, + VehicleStartOptions, +} from '../interfaces/common.interfaces'; + +import logger from '../logger'; +import { Vehicle } from './vehicle'; +import { ChineseController } from '../controllers/chinese.controller'; +import { celciusToTempCode, tempCodeToCelsius, parseDate, addMinutes } from '../util'; +import { manageBluelinkyError, ManagedBluelinkyError } from '../tools/common.tools'; +import { + CNDatedDriveHistory, + CNDriveHistory, + CNPOIInformation, + historyDrivingPeriod, +} from '../interfaces/chinese.interfaces'; +import got from 'got'; + +export default class ChineseVehicle extends Vehicle { + public region = REGIONS.CN; + public serverRates: { + max: number; + current: number; + reset?: Date; + updatedAt?: Date; + } = { + max: -1, + current: -1, + }; + + constructor(public vehicleConfig: VehicleRegisterOptions, public controller: ChineseController) { + super(vehicleConfig, controller); + logger.debug(`CN 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: 1, + options: { + defrost: config.defrost, + heating1: config.heatedFeatures ? 1 : 0, + }, + tempCode: celciusToTempCode(REGIONS.CN, config.temperature), + unit: config.unit, + }, + }) + ); + logger.info(`Climate started for vehicle ${this.vehicleConfig.id}`); + return response.body; + } catch (err) { + throw manageBluelinkyError(err, 'ChinaVehicle.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, 'ChinaVehicle.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, 'ChinaVehicle.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, 'ChinaVehicle.unlock'); + } + } + + public async fullStatus(input: VehicleStatusOptions): Promise { + const statusConfig = { + ...DEFAULT_VEHICLE_STATUS_OPTIONS, + ...input, + }; + + const http = await this.controller.getVehicleHttpService(); + + try { + + const cachedResponse = this.updateRates( + await http.get(`/api/v2/spa/vehicles/${this.vehicleConfig.id}/status/latest`) + ); + + const fullStatus = cachedResponse.body.resMsg.status; + + if (statusConfig.refresh) { + const statusResponse = this.updateRates( + await http.get(`/api/v2/spa/vehicles/${this.vehicleConfig.id}/status`) + ); + fullStatus.vehicleStatus = statusResponse.body.resMsg.status; + + const locationResponse = this.updateRates( + await http.get(`/api/v2/spa/vehicles/${this.vehicleConfig.id}/location`) + ); + fullStatus.vehicleLocation = locationResponse.body.resMsg.coord; + } + + this._fullStatus = fullStatus; + return this._fullStatus; + } catch (err) { + throw manageBluelinkyError(err, 'ChinaVehicle.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}`) + ); + + // handles refreshing data + const vehicleStatus =response.body.resMsg.status; + + 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.EU, 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, 'ChinaVehicle.status'); + } + } + + public async odometer(): Promise { + const http = await this.controller.getVehicleHttpService(); + try { + const response = this.updateRates( + await http.get(`/api/v2/spa/vehicles/${this.vehicleConfig.id}/status/latest`) + ); + this._odometer = response.body.resMsg.status.odometer as VehicleOdometer; + return this._odometer; + } catch (err) { + throw manageBluelinkyError(err, 'ChinaVehicle.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`) + ); + + 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, + }, + heading: data?.head, + }; + + return this._location; + } catch (err) { + throw manageBluelinkyError(err, 'ChinaVehicle.location'); + } + } + + public async startCharge(): Promise { + 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, 'ChinaVehicle.startCharge'); + } + } + + public async stopCharge(): Promise { + 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, 'ChinaVehicle.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, 'ChinaVehicle.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, 'ChinaVehicle.history'); + } + } + + public async driveHistory(period: historyDrivingPeriod = historyDrivingPeriod.DAY): Promise< + DeepPartial<{ + cumulated: CNDriveHistory[]; + history: CNDatedDriveHistory[]; + }> + > { + 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, 'ChinaVehicle.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, 'ChinaVehicle.getChargeTargets'); + } + } + + /** + * Warning: Only works on EV + */ + public async setChargeTargets(limits: { fast: ChargeTarget; slow: ChargeTarget }): Promise { + 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, 'ChinaVehicle.setChargeTargets'); + } + } + + /** + * Define a navigation route + * @param poiInformations The list of POIs and waypoint to go through + */ + public async setNavigation(poiInformations: CNPOIInformation[]): Promise { + 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, 'ChinaVehicle.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); +}