bluelinky2
Version:
An unofficial nodejs API wrapper for Hyundai bluelink
327 lines (287 loc) • 11.8 kB
text/typescript
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(`.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(`.login: sign in with ${this.authStrategies.main.name} failed with error ${e.toString()}`);
logger.debug(`.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(`.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(`.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',
};
}
}