UNPKG

bluelinky2

Version:

An unofficial nodejs API wrapper for Hyundai bluelink

327 lines (287 loc) 11.8 kB
import { getBrandEnvironment, EuropeanBrandEnvironment, DEFAULT_LANGUAGE, EULanguages, EU_LANGUAGES } from './../constants/europe'; import { BlueLinkyConfig, Session } from './../interfaces/common.interfaces'; import * as pr from 'push-receiver'; import got, { GotInstance, GotJSONFn } from 'got'; import { Vehicle } from '../vehicles/vehicle'; import EuropeanVehicle from '../vehicles/european.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, uuidV4 } from '../tools/common.tools'; import { AuthStrategy, Code } from './authStrategies/authStrategy'; import { EuropeanBrandAuthStrategy } from './authStrategies/european.brandAuth.strategy'; import { EuropeanLegacyAuthStrategy } from './authStrategies/european.legacyAuth.strategy'; export interface EuropeBlueLinkyConfig extends BlueLinkyConfig { language?: EULanguages; stampsFile?: string; region: 'EU'; } interface EuropeanVehicleDescription { nickname: string; vehicleName: string; regDate: string; vehicleId: string; } export class EuropeanController extends SessionController<EuropeBlueLinkyConfig> { private _environment: EuropeanBrandEnvironment; private authStrategies: { main: AuthStrategy; fallback: AuthStrategy; }; constructor(userConfig: EuropeBlueLinkyConfig) { super(userConfig); this.userConfig.language = userConfig.language ?? DEFAULT_LANGUAGE; if (!EU_LANGUAGES.includes(this.userConfig.language)) { throw new Error(`The language code ${this.userConfig.language} is not managed. Only ${EU_LANGUAGES.join(', ')} are.`); } this.session.deviceId = uuidV4(); this._environment = getBrandEnvironment(userConfig.brand); this.authStrategies = { main: new EuropeanBrandAuthStrategy(this._environment, this.userConfig.language), fallback: new EuropeanLegacyAuthStrategy(this._environment, this.userConfig.language), }; logger.debug('EU Controller created'); } public get environment(): EuropeanBrandEnvironment { return this._environment; } public session: Session = { accessToken: undefined, refreshToken: undefined, controlToken: undefined, deviceId: uuidV4(), tokenExpiresAt: 0, controlTokenExpiresAt: 0, }; private vehicles: Array<EuropeanVehicle> = []; public async refreshAccessToken(): Promise<string> { 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, 'EuropeController.refreshAccessToken'); } logger.debug('Token refreshed'); return 'Token refreshed'; } public async enterPin(): Promise<string> { 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, 'EuropeController.pin'); } } public async login(): Promise<string> { try { if (!this.userConfig.password || !this.userConfig.username) { throw new Error('@EuropeController.login: username and password must be defined.'); } let authResult: { code: Code, cookies: CookieJar }|null = null; try { logger.debug(`@EuropeController.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(`@EuropeController.login: sign in with ${this.authStrategies.main.name} failed with error ${e.toString()}`); logger.debug(`@EuropeController.login: Trying to sign in with ${this.authStrategies.fallback.name}`); authResult = await this.authStrategies.fallback.login({ password: this.userConfig.password, username: this.userConfig.username }); } logger.debug('@EuropeController.login: Authenticated properly with user and password'); const credentials = await pr.register(this.environment.GCMSenderID); const notificationReponse = await got(`${this.environment.baseUrl}/api/v1/spa/notifications/register`, { method: 'POST', headers: { 'ccsp-service-id': 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(this.userConfig.stampsFile), }, body: { pushRegId: credentials.gcm.token, pushType: 'GCM', uuid: this.session.deviceId, }, json: true, }); if (notificationReponse) { this.session.deviceId = notificationReponse.body.resMsg.deviceId; } logger.debug('@EuropeController.login: Device registered'); const formData = new URLSearchParams(); formData.append('grant_type', 'authorization_code'); formData.append('redirect_uri', 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(this.userConfig.stampsFile), }, body: formData.toString(), cookieJar: authResult.cookies, }); if (response.statusCode !== 200) { throw new Error(`@EuropeController.login: Could not manage to get token: ${response.body}`); } if (response) { const responseBody = JSON.parse(response.body); this.session.accessToken = `Bearer ${responseBody.access_token}`; this.session.refreshToken = responseBody.refresh_token; this.session.tokenExpiresAt = Math.floor(Date.now() / 1000 + responseBody.expires_in); } logger.debug('@EuropeController.login: Session defined properly'); return 'Login success'; } catch (err) { throw manageBluelinkyError(err, 'EuropeController.login'); } } public async logout(): Promise<string> { return 'OK'; } public async getVehicles(): Promise<Array<Vehicle>> { 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(this.userConfig.stampsFile), }, json: true, }); this.vehicles = await asyncMap<EuropeanVehicleDescription, EuropeanVehicle>(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(this.userConfig.stampsFile), }, json: true, } ); const vehicleProfile = vehicleProfileReponse.body.resMsg; const vehicleConfig = { nickname: v.nickname, name: v.vehicleName, regDate: v.regDate, brandIndicator: 'H', id: v.vehicleId, vin: vehicleProfile.vinInfo[0].basic.vin, generation: vehicleProfile.vinInfo[0].basic.modelYear, } as VehicleRegisterOptions; logger.debug(`@EuropeController.getVehicles: Added vehicle ${vehicleConfig.id}`); return new EuropeanVehicle(vehicleConfig, this); }); } catch (err) { throw manageBluelinkyError(err, 'EuropeController.getVehicles'); } return this.vehicles; } private async checkControlToken(): Promise<void> { 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<GotInstance<GotJSONFn>> { await this.checkControlToken(); return got.extend({ baseUrl: this.environment.baseUrl, headers: { ...this.defaultHeaders, 'Authorization': this.session.controlToken, 'Stamp': await this.environment.stamp(this.userConfig.stampsFile), }, json: true }); } public async getApiHttpService(): Promise<GotInstance<GotJSONFn>> { await this.refreshAccessToken(); return got.extend({ baseUrl: this.environment.baseUrl, headers: { ...this.defaultHeaders, 'Stamp': await this.environment.stamp(this.userConfig.stampsFile), }, 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', }; } }