Skip to content

Commit

Permalink
Merge pull request #154 from Hacksore/develop
Browse files Browse the repository at this point in the history
v7.4.0
  • Loading branch information
Hacksore authored Jul 5, 2021
2 parents 6a8b03c + c76abd5 commit 654e6bc
Show file tree
Hide file tree
Showing 8 changed files with 122 additions and 71 deletions.
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ Important information for login problems:
- If you experience login problems, please logout from the app on your phone and login again. You might need to ' upgrade ' your account to a generic Kia/Hyundai account, or create a new password or PIN.
- After you migrated your Bluelink account to a generic Hyundai account, or your UVO account to a generic Kia account, make sure that both accounts have the same credentials (userid and password) to avoid confusion in logging in.

### Custom Stamps
In the EU region, stamps are used to sign every API queries. These stamps have a 1 week validity. Those stamps are using a tricky algorithm and cannot be replicated by Bluelinky and have to be generated by an external solution. An http call is performed to get the existing tokens. It is possible to specify an other path using the `stampFile` option. This path can be a local file prefixed by `file://` or from any webserver.

By default the case is 24H, but it can but customized at will. A nice trick is to run you own stamp generator http server and querying it regularly (with low cache timeout) for fresh stamps.

The JSON file must respect [this format](https://github.com/neoPix/bluelinky-stamps/blob/master/kia.json)

## Supported Features
- Lock
- Unlock
Expand All @@ -76,11 +83,6 @@ Important information for login problems:
- EV: getChargeTargets
- EV: setChargeLimits

## Custom Stamps
In the EU region, stamps are used to sign every API queries. These stamps have a 1 week validity. Those stamps are using a tricky algorithm and cannot be replicated by Bluelinky and have to be generated by an external solution. An http call is performed to get the existing tokens. It is possible to specify an other path using the `stampFile` option. This path can be a local file prefixed by `file://` or from any webserver.

The JSON file must respect [this format](https://github.com/neoPix/bluelinky-stamps/blob/master/kia.json)

## Supported Regions
| [Regions](https://github.com/Hacksore/bluelinky/wiki/Regions)
## Show your support
Expand Down
26 changes: 23 additions & 3 deletions __tests__/util.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,31 @@ import { REGIONS } from '../src/constants';

describe('Utility', () => {
it('converts temp code to celsius in CA', () => {
expect(tempCodeToCelsius(REGIONS.CA, '06H')).toEqual(17);
expect(tempCodeToCelsius(REGIONS.CA, '04H')).toEqual(18);
expect(tempCodeToCelsius(REGIONS.CA, '0BH')).toEqual(21.5);
expect(tempCodeToCelsius(REGIONS.CA, '10H')).toEqual(24);
expect(tempCodeToCelsius(REGIONS.CA, '18H')).toEqual(28);
expect(tempCodeToCelsius(REGIONS.CA, '20H')).toEqual(32);
});

it('converts celsius to temp code in CA', () => {
expect(celciusToTempCode(REGIONS.CA, 17)).toEqual('06H');
expect(celciusToTempCode(REGIONS.CA, 18)).toEqual('04H');
expect(celciusToTempCode(REGIONS.CA, 21.5)).toEqual('0BH');
expect(celciusToTempCode(REGIONS.CA, 24)).toEqual('10H');
expect(celciusToTempCode(REGIONS.CA, 28)).toEqual('18H');
expect(celciusToTempCode(REGIONS.CA, 32)).toEqual('20H');
});

it('converts temp code to celsius in EU', () => {
expect(tempCodeToCelsius(REGIONS.EU, '06H')).toEqual(17);
expect(tempCodeToCelsius(REGIONS.EU, '0CH')).toEqual(20);
expect(tempCodeToCelsius(REGIONS.EU, '1AH')).toEqual(27);
});

it('converts celcius to temp code in EU', () => {
expect(celciusToTempCode(REGIONS.EU, 17)).toEqual('06H');
expect(celciusToTempCode(REGIONS.EU, 20)).toEqual('0CH');
expect(celciusToTempCode(REGIONS.EU, 27)).toEqual('1AH');
});

it('parseDate converts string to date', () => {
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "bluelinky",
"version": "7.3.0",
"version": "7.4.0",
"description": "An unofficial nodejs API wrapper for Hyundai bluelink",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down
34 changes: 18 additions & 16 deletions src/constants/europe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,28 +34,28 @@ const cacheResult = <T>(fn: (...options: any[]) => Promise<T>, durationInMS = 60
if(cache && age && (age + durationInMS) > Date.now()) {
return cache;
}
cache = fn(...options).catch(e => { cache = null; return e; });
cache = fn(...options).catch(e => { cache = null; return Promise.reject(e); });
age = Date.now();
return cache;
};
};

const ONE_DAY = 60000 * 60 * 24;
const ONE_DAY_IN_MS = 60000 * 60 * 24;

const getStampList = cacheResult(async (brand: Brand, stampsFile = `https://raw.githubusercontent.com/neoPix/bluelinky-stamps/master/${brand}.json`): Promise<string[]> => {
const getStampList = async (file: string, stampsFile = `https://raw.githubusercontent.com/neoPix/bluelinky-stamps/master/${file}.json`): Promise<string[]> => {
if (stampsFile.startsWith(('file://'))) {
const [path] = stampsFile.split('file://');
const [,path] = stampsFile.split('file://');
const content = await promisify(readFile)(path);
return JSON.parse(content.toString('utf-8'));
}
const { body } = await got(stampsFile, { json: true });
return body;
}, ONE_DAY);
};

const getStamps = (brand: Brand) => async (stampsFile?: string) => {
const getStamp = (brand: string, stampsTimeout: number = ONE_DAY_IN_MS) => cacheResult(async (stampsFile?: string) => {
const list = await getStampList(brand, stampsFile);
return list[Math.floor(Math.random() * list.length)];
};
}, stampsTimeout);


const getEndpoints = (baseUrl: string, clientId: string): EuropeanBrandEnvironment['endpoints'] => ({
Expand All @@ -68,54 +68,56 @@ const getEndpoints = (baseUrl: string, clientId: string): EuropeanBrandEnvironme
silentSignIn: `${baseUrl}/api/v1/user/silentsignin`,
});

const getHyundaiEnvironment = (): EuropeanBrandEnvironment => {
const getHyundaiEnvironment = (stampsTimeout?: number): EuropeanBrandEnvironment => {
const host = 'prd.eu-ccapi.hyundai.com:8080';
const baseUrl = `https://${host}`;
const clientId = '6d477c38-3ca4-4cf3-9557-2a1929a94654';
const appId = '99cfff84-f4e2-4be8-a5ed-e5b755eb6581';
return {
brand: 'hyundai',
host,
baseUrl,
clientId,
appId: '99cfff84-f4e2-4be8-a5ed-e5b755eb6581',
appId,
endpoints: Object.freeze(getEndpoints(baseUrl, clientId)),
basicToken: 'Basic NmQ0NzdjMzgtM2NhNC00Y2YzLTk1NTctMmExOTI5YTk0NjU0OktVeTQ5WHhQekxwTHVvSzB4aEJDNzdXNlZYaG10UVI5aVFobUlGampvWTRJcHhzVg==',
GCMSenderID: '199360397125',
stamp: getStamps('hyundai'),
stamp: getStamp('hyundai', stampsTimeout),
brandAuthUrl({ language, serviceId, userId }) {
const newAuthClientId = '97516a3c-2060-48b4-98cd-8e7dcd3c47b2';
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}`;
}
};
};

const getKiaEnvironment = (): EuropeanBrandEnvironment => {
const getKiaEnvironment = (stampsTimeout?: number): EuropeanBrandEnvironment => {
const host = 'prd.eu-ccapi.kia.com:8080';
const baseUrl = `https://${host}`;
const clientId = 'fdc85c00-0a2f-4c64-bcb4-2cfb1500730a';
const appId = 'e7bcd186-a5fd-410d-92cb-6876a42288bd';
return {
brand: 'kia',
host,
baseUrl,
clientId,
appId: '693a33fa-c117-43f2-ae3b-61a02d24f417',
appId,
endpoints: Object.freeze(getEndpoints(baseUrl, clientId)),
basicToken: 'Basic ZmRjODVjMDAtMGEyZi00YzY0LWJjYjQtMmNmYjE1MDA3MzBhOnNlY3JldA==',
GCMSenderID: '199360397125',
stamp: getStamps('kia'),
stamp: getStamp(`kia-${appId}`, stampsTimeout),
brandAuthUrl({ language, serviceId, userId }) {
const newAuthClientId = 'f4d531c7-1043-444d-b09a-ad24bd913dd4';
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}`;
}
};
};

export const getBrandEnvironment = (brand: Brand): EuropeanBrandEnvironment => {
export const getBrandEnvironment = (brand: Brand, stampsTimeout?: number): EuropeanBrandEnvironment => {
switch (brand) {
case 'hyundai':
return Object.freeze(getHyundaiEnvironment());
return Object.freeze(getHyundaiEnvironment(stampsTimeout));
case 'kia':
return Object.freeze(getKiaEnvironment());
return Object.freeze(getKiaEnvironment(stampsTimeout));
default:
throw new Error(`Constructor ${brand} is not managed.`);
}
Expand Down
28 changes: 18 additions & 10 deletions src/controllers/european.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { EuropeanLegacyAuthStrategy } from './authStrategies/european.legacyAuth
export interface EuropeBlueLinkyConfig extends BlueLinkyConfig {
language?: EULanguages;
stampsFile?: string;
stampsFileCacheDurationInMs?: number;
region: 'EU';
}

Expand All @@ -42,7 +43,7 @@ export class EuropeanController extends SessionController<EuropeBlueLinkyConfig>
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._environment = getBrandEnvironment(userConfig.brand, userConfig.stampsFileCacheDurationInMs);
this.authStrategies = {
main: new EuropeanBrandAuthStrategy(this._environment, this.userConfig.language),
fallback: new EuropeanLegacyAuthStrategy(this._environment, this.userConfig.language),
Expand Down Expand Up @@ -167,6 +168,7 @@ export class EuropeanController extends SessionController<EuropeBlueLinkyConfig>
'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: {
Expand Down Expand Up @@ -197,6 +199,7 @@ export class EuropeanController extends SessionController<EuropeBlueLinkyConfig>
'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(),
Expand Down Expand Up @@ -234,8 +237,7 @@ export class EuropeanController extends SessionController<EuropeBlueLinkyConfig>
const response = await got(`${this.environment.baseUrl}/api/v1/spa/vehicles`, {
method: 'GET',
headers: {
'Authorization': this.session.accessToken,
'ccsp-device-id': this.session.deviceId,
...this.defaultHeaders,
'Stamp': await this.environment.stamp(this.userConfig.stampsFile),
},
json: true,
Expand All @@ -247,8 +249,7 @@ export class EuropeanController extends SessionController<EuropeBlueLinkyConfig>
{
method: 'GET',
headers: {
'Authorization': this.session.accessToken,
'ccsp-device-id': this.session.deviceId,
...this.defaultHeaders,
'Stamp': await this.environment.stamp(this.userConfig.stampsFile),
},
json: true,
Expand Down Expand Up @@ -294,9 +295,8 @@ export class EuropeanController extends SessionController<EuropeBlueLinkyConfig>
return got.extend({
baseUrl: this.environment.baseUrl,
headers: {
...this.defaultHeaders,
'Authorization': this.session.controlToken,
'ccsp-device-id': this.session.deviceId,
'Content-Type': 'application/json',
'Stamp': await this.environment.stamp(this.userConfig.stampsFile),
},
json: true
Expand All @@ -308,12 +308,20 @@ export class EuropeanController extends SessionController<EuropeBlueLinkyConfig>
return got.extend({
baseUrl: this.environment.baseUrl,
headers: {
'Authorization': this.session.accessToken,
'ccsp-device-id': this.session.deviceId,
'Content-Type': 'application/json',
...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',
};
}
}
11 changes: 4 additions & 7 deletions src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ const REGION_STEP_RANGES = {
step: 0.5,
},
CA: {
start: 17,
end: 27,
start: 16,
end: 32,
step: 0.5,
}
};
Expand All @@ -39,19 +39,16 @@ export const celciusToTempCode = (region: REGION, temperature: number): string =

// get the second param and stick an H on the end?
// this needs more testing I guess :P
return hexCode.split('x')[1].toUpperCase() + 'H';
return `${hexCode.split('x')[1].toUpperCase()}H`.padStart(3, '0');
};

export const tempCodeToCelsius = (region: REGION, code: string): number => {
// get first part
const hexTempIndex = code[0];

// create a range
const { start, end, step } = REGION_STEP_RANGES[region];
const tempRange = floatRange(start, end, step);

// get the index
const tempIndex = parseInt(hexTempIndex, 16);
const tempIndex = parseInt(code, 16);

// return the relevant celsius temp
return tempRange[tempIndex];
Expand Down
Loading

0 comments on commit 654e6bc

Please sign in to comment.