diff --git a/src/accessory/BlueAir.ts b/src/accessory/BlueAir.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/accessory/BlueAirAware.ts b/src/accessory/BlueAirAware.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/accessory/BlueAirClassic.ts b/src/accessory/BlueAirClassic.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/api/BlueAirAws.ts b/src/api/BlueAirAws.ts deleted file mode 100644 index 126c2fe..0000000 --- a/src/api/BlueAirAws.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { Logger } from 'homebridge'; -import { RegionMap } from '../platformUtils'; -import { BlueAirBase, BlueAirDevice } from './BlueAirBase'; -import axios, { AxiosInstance } from 'axios'; - -type APIConfigValue = { - gigyaRegion: string; - restApiId: string; - awsRegion: string; - apiKey: string; -}; - -type APIConfig = { [key in string]: APIConfigValue }; - -const BLUEAIR_AWS_APIKEYS: APIConfig = { - 'us': { - gigyaRegion: 'us1', - restApiId: 'on1keymlmh', - awsRegion: 'us-east-2', - apiKey: '3_-xUbbrIY8QCbHDWQs1tLXE-CZBQ50SGElcOY5hF1euE11wCoIlNbjMGAFQ6UwhMY', - }, - 'eu': { - gigyaRegion: 'eu1', - restApiId: 'hkgmr8v960', - awsRegion: 'eu-west-1', - apiKey: '3_qRseYzrUJl1VyxvSJANalu_kNgQ83swB1B9uzgms58--5w1ClVNmrFdsDnWVQQCl', - }, -}; - - -export class BlueAirAws implements BlueAirBase { - - private readonly API_CONFIG: APIConfigValue; - private readonly GIGYA_API: AxiosInstance; - private readonly AWS_API: AxiosInstance; - - private access_token?: string; - - constructor( - private readonly username: string, - private readonly password: string, - region: string, - private readonly logger: Logger, - ) { - this.API_CONFIG = BLUEAIR_AWS_APIKEYS[RegionMap[region]]; - - this.GIGYA_API = axios.create({ - baseURL: `https://accounts.${this.API_CONFIG.gigyaRegion}.gigya.com`, - headers: { - 'Accept': '*/*', - 'Connection': 'keep-alive', - 'Accept-Encoding': 'gzip, deflate, br', - }, - }); - - this.AWS_API = axios.create({ - baseURL: `https://${this.API_CONFIG.restApiId}.execute-api.${this.API_CONFIG.awsRegion}.amazonaws.com`, - headers: { - 'Accept': '*/*', - 'Connection': 'keep-alive', - 'Accept-Encoding': 'gzip, deflate, br', - }, - }); - } - - async login(): Promise { - - if (this.access_token) { - return true; - } - - try { - const { token, secret } = await this.getGigyaSession(); - const { jwt } = await this.getGigyaJWT(token, secret); - const { accessToken } = await this.getAwsAccessToken(jwt); - - this.access_token = accessToken; - return true; - } catch (error) { - this.logger.error(`Login error: ${error}`); - return false; - } - } - - async getDevices(): Promise { - return []; - } - - async getDeviceStatus(uuid: string): Promise { - return uuid; - } - - async setDeviceStatus(uuid: string, status: Record): Promise { - this.logger.debug(`setDeviceStatus: ${uuid} ${JSON.stringify(status)}`); - return true; - } - - - private async getGigyaSession(): Promise<{token: string; secret: string }> { - - const params = new URLSearchParams({ - apiKey: this.API_CONFIG.apiKey, - loginID: this.username, - password: this.password, - targetEnv: 'mobile', - }); - const response = await this.GIGYA_API.post('/accounts.login', params.toString()); - - if (response.data.errorCode) { - throw new Error(`Gigya login error: ${response.data.errorCode}`); - } - - return { - token: response.data.sessionInfo.sessionToken, - secret: response.data.sessionInfo.sessionSecret, - }; - } - - private async getGigyaJWT(token: string, secret: string): Promise<{jwt: string}> { - - const params = new URLSearchParams({ - oauth_token: token, - secret: secret, - targetEnv: 'mobile', - }); - const response = await this.GIGYA_API.post('/accounts.getJWT', params.toString()); - - if (response.data.errorCode) { - throw new Error(`Gigya JWT error: ${response.data.errorCode}`); - } - - return { - jwt: response.data.id_token, - }; - } - - private async getAwsAccessToken(jwt: string): Promise<{accessToken: string}> { - - const response = await this.AWS_API.post('/prod/c/login', undefined, { - headers: { - 'Authorization': `Bearer ${jwt}`, - 'idtoken': jwt, - }, - }); - - if (response.data.errorCode) { - throw new Error(`AWS access token error: ${response.data.errorCode}`); - } - - return { - accessToken: response.data.access_token, - }; - } -} \ No newline at end of file diff --git a/src/api/BlueAirAwsApi.ts b/src/api/BlueAirAwsApi.ts new file mode 100644 index 0000000..c8acfe6 --- /dev/null +++ b/src/api/BlueAirAwsApi.ts @@ -0,0 +1,218 @@ +import { Logger } from 'homebridge'; +import { RegionMap } from '../platformUtils'; +import axios, { AxiosInstance, AxiosResponse } from 'axios'; +import { GigyaApi } from './GigyaApi'; +import { BLUEAIR_API_TIMEOUT, BLUEAIR_CONFIG, LOGIN_EXPIRATION } from './Consts'; + +export type BlueAirDevice = { + mac: string; + 'mcu-firmware': string; + name: string; + type: string; + 'user-type': string; + uuid: string; + 'wifi-firmware': string; +}; + +export type BlueAirDeviceState = { + cfv: string; + germshield: boolean; + gsnm: boolean; + standby: boolean; + fanspeed: number; + childlock: boolean; + nightmode: boolean; + mfv: string; + automode: boolean; + ofv: string; + brightness: number; + safetyswitch: boolean; + filterusage: number; + disinfection: boolean; + disinftime: number; +}; + +export type BlueAirDeviceSensorData = { + fanspeed: number; + hcho: number; + humidity: number; + pm1: number; + pm10: number; + pm25: number; + temperature: number; + tvoc: number; +}; + +export const BlueAirDeviceSensorDataMap = { + fsp0: 'fanspeed', + hcho: 'hcho', + h: 'humidity', + pm1: 'pm1', + pm10: 'pm10', + pm2_5: 'pm25', + t: 'temperature', + tVOC: 'voc', +}; + +export class BlueAirAwsApi { + + private readonly gigyaApi: GigyaApi; + private readonly blueairAxios: AxiosInstance; + + private last_login?: number; + private name?: string; + + constructor( + username: string, + password: string, + region: string, + private readonly logger: Logger, + ) { + const config = BLUEAIR_CONFIG[RegionMap[region]].awsConfig; + + this.gigyaApi = new GigyaApi(username, password, region, logger); + this.blueairAxios = axios.create({ + baseURL: `https://${config.restApiId}.execute-api.${config.awsRegion}.amazonaws.com/prod/c`, + headers: { + 'Accept': '*/*', + 'Connection': 'keep-alive', + 'Accept-Encoding': 'gzip, deflate, br', + }, + timeout: BLUEAIR_API_TIMEOUT, + }); + } + + async login(): Promise { + this.logger.debug('Logging in...'); + try { + const { token, secret } = await this.gigyaApi.getGigyaSession(); + const { jwt } = await this.gigyaApi.getGigyaJWT(token, secret); + const { accessToken } = await this.getAwsAccessToken(jwt); + + this.last_login = Date.now(); + + this.blueairAxios.defaults.headers['Authorization'] = `Bearer ${accessToken}`; + this.blueairAxios.defaults.headers['idtoken'] = accessToken; + + this.logger.debug('Login successful'); + } catch (error) { + this.logger.error(`Login error: ${error}`); + } + } + + async checkTokenExpiration(): Promise { + if (!this.last_login) { + this.logger.debug('No login found, logging in...'); + return await this.login(); + } + if (LOGIN_EXPIRATION < Date.now() - this.last_login) { + this.logger.debug('Token expired, logging in...'); + return await this.login(); + } + } + + async getDevices(): Promise { + await this.checkTokenExpiration(); + + try { + const response = await this.apiCall('/registered-devices', undefined); + + if (!response.data.devices) { + throw new Error('getDevices error: no devices in response'); + } + + const devices = response.data.devices as BlueAirDevice[]; + + if (devices.length === 0) { + this.logger.warn('No devices found'); + } else { + this.logger.debug(`Found devices: ${JSON.stringify(devices)}`); + this.name = devices[0].name; + } + + return devices; + } catch (error) { + this.logger.error(`getDevices error: ${error}`); + } + return []; + } + + async getDeviceStatus(uuids: string[]): Promise { + await this.checkTokenExpiration(); + if (!this.name) { + throw new Error('getDeviceStatus error: name not defined'); + } + this.logger.debug(`getDeviceStatus: ${uuids.join(', ')}`); + + try { + const body = { + deviceconfigquery: uuids.map((uuid) => ({ id: uuid} )), + }; + const response = await this.apiCall(`/${this.name}/r/initial`, body); + + const { deviceInfo } = response.data; + if (!deviceInfo) { + throw new Error('getDeviceStatus error: no deviceInfo in response'); + } + + if (deviceInfo.length === 0) { + this.logger.warn('No deviceInfo found'); + return uuids; + } + + const status = response.data.status as BlueAirDeviceStatus; + this.logger.debug(`Device status: ${JSON.stringify(status)}`); + + return status; + } catch (error) { + this.logger.error(`getDeviceStatus error: ${error}`); + } + + return uuid; + } + + async setDeviceStatus(uuid: string, status: Record): Promise { + await this.checkTokenExpiration(); + this.logger.debug(`setDeviceStatus: ${uuid} ${JSON.stringify(status)}`); + return true; + } + + private async getAwsAccessToken(jwt: string): Promise<{accessToken: string}> { + this.logger.debug('Getting AWS access token...'); + try { + const response = await this.apiCall('/login', undefined, { + 'Authorization': `Bearer ${jwt}`, + 'idtoken': jwt, + }); + + if (!response.data.access_token) { + throw new Error(`AWS access token error: ${JSON.stringify(response.data)}`); + } + + this.logger.debug('AWS access token received'); + return { + accessToken: response.data.access_token, + }; + } catch (error) { + throw new Error(`AWS access token error: ${error}`); + } + } + + private async apiCall(url: string, data?: string | object, headers?: object, retries = 3): Promise { + try { + const response = await this.blueairAxios.post(url, data, { headers }); + if (response.status !== 200) { + throw new Error(`API call error with status ${response.status}: ${response.statusText}, ${JSON.stringify(response.data)}`); + } + return response; + } catch (error) { + this.logger.error(`API call failed: ${error}`); + if (retries > 0) { + this.logger.debug(`Retrying API call (${retries} retries left)...`); + return this.apiCall(url, data, headers, retries - 1); + } else { + throw new Error(`API call failed after ${retries} retries`); + } + } + } +} \ No newline at end of file diff --git a/src/api/BlueAirBase.ts b/src/api/BlueAirBase.ts deleted file mode 100644 index 5c6e4a9..0000000 --- a/src/api/BlueAirBase.ts +++ /dev/null @@ -1,13 +0,0 @@ -export type BlueAirDevice = { - uuid: string; - name: string; - userid: string; - mac: string; -}; - -export interface BlueAirBase { - login(): Promise; - getDevices(): Promise; - getDeviceStatus(uuid: string): Promise; - setDeviceStatus(uuid: string, status: Record): Promise; -} \ No newline at end of file diff --git a/src/api/BlueAirLegacy.ts b/src/api/BlueAirLegacy.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/api/Consts.ts b/src/api/Consts.ts new file mode 100644 index 0000000..4215dc6 --- /dev/null +++ b/src/api/Consts.ts @@ -0,0 +1,67 @@ +import { RegionMap } from '../platformUtils'; + +type AWSConfigValue = { + restApiId: string; + awsRegion: string; +}; +type AWSConfig = { [key: string]: AWSConfigValue }; + +const AWS_CONFIG : AWSConfig = { + 'us': { + restApiId: 'on1keymlmh', + awsRegion: 'us-east-2', + }, + 'eu': { + restApiId: 'hkgmr8v960', + awsRegion: 'eu-west-1', + }, + 'cn': { + restApiId: 'ftbkyp79si', + awsRegion: 'cn-north-1', + }, +}; + +type GigyaConfigValue = { + gigyaRegion: string; + apiKey: string; +}; +type GigyaConfig = { [key: string]: GigyaConfigValue }; + +const GIGYA_CONFIG : GigyaConfig = { + 'us': { + gigyaRegion: 'us1', + apiKey: '3_-xUbbrIY8QCbHDWQs1tLXE-CZBQ50SGElcOY5hF1euE11wCoIlNbjMGAFQ6UwhMY', + }, + 'eu': { + gigyaRegion: 'eu1', + apiKey: '3_qRseYzrUJl1VyxvSJANalu_kNgQ83swB1B9uzgms58--5w1ClVNmrFdsDnWVQQCl', + }, + 'cn': { + gigyaRegion: 'cn1', + apiKey: '3_h3UEfJnA-zDpFPR9L4412HO7Mz2VVeN4wprbWYafPN1gX0kSnLcZ9VSfFi7bEIIU', + }, + 'au': { + gigyaRegion: 'au1', + apiKey: '3_Z2N0mIFC6j2fx1z2sq76R3pwkCMaMX2y9btPb0_PgI_3wfjSJoofFnBbxbtuQksN', + }, + 'ru': { + gigyaRegion: 'ru1', + apiKey: '3_wYhHEBaOcS_w6idVM3mh8UjyjOP-3Dwn3w9Z6AYc0FhGf-uIwUkrcoCdsYarND2k', + }, +}; + +type APIConfig = { [key: string]: { + awsConfig: AWSConfigValue; + gigyaConfig: GigyaConfigValue; +}; }; + +export const BLUEAIR_CONFIG = Object.values(RegionMap).reduce((acc, region: string) => ({ + ...acc, + [region]: { + awsConfig: region in AWS_CONFIG ? AWS_CONFIG[region] : AWS_CONFIG['eu'], + gigyaConfig: region in GIGYA_CONFIG ? GIGYA_CONFIG[region] : GIGYA_CONFIG['eu'], + }, +}), {} as APIConfig); + +export const LOGIN_EXPIRATION = 3600 * 1000 * 24; // n hours in milliseconds +export const BLUEAIR_API_TIMEOUT = 1000 * 5; // n seconds in milliseconds \ No newline at end of file diff --git a/src/api/GigyaApi.ts b/src/api/GigyaApi.ts new file mode 100644 index 0000000..9c4abd0 --- /dev/null +++ b/src/api/GigyaApi.ts @@ -0,0 +1,96 @@ +import axios, { AxiosInstance, AxiosResponse } from 'axios'; +import { Logger } from 'homebridge'; +import { BLUEAIR_CONFIG } from './Consts'; +import { RegionMap } from '../platformUtils'; + +export class GigyaApi { + + private api_key: string; + private readonly gigyaAxios: AxiosInstance; + + constructor( + private readonly username: string, + private readonly password: string, + region: string, + private readonly logger: Logger, + ) { + + const config = BLUEAIR_CONFIG[RegionMap[region]].gigyaConfig; + this.api_key = config.apiKey; + + this.gigyaAxios = axios.create({ + baseURL: `https://accounts.${config.gigyaRegion}.gigya.com`, + headers: { + 'Accept': '*/*', + 'Connection': 'keep-alive', + 'Accept-Encoding': 'gzip, deflate, br', + }, + }); + } + + public async getGigyaSession(): Promise<{ token: string; secret: string }> { + const params = new URLSearchParams({ + apiKey: this.api_key, + loginID: this.username, + password: this.password, + targetEnv: 'mobile', + }); + + try { + const response = await this.apiCall('/accounts.login', params.toString()); + + if (!response.data.oauth_token || !response.data.oauth_token_secret) { + throw new Error('Gigya session error: no oauth_token or oauth_token_secret in response'); + } + + this.logger.debug('Gigya session received'); + return { + token: response.data.oauth_token, + secret: response.data.oauth_token_secret, + }; + } catch (error) { + throw new Error(`Gigya session error: ${error}`); + } + } + + public async getGigyaJWT(token: string, secret: string): Promise<{jwt: string}> { + const params = new URLSearchParams({ + oauth_token: token, + secret: secret, + targetEnv: 'mobile', + }); + + try { + const response = await this.apiCall('/accounts.getJWT', params.toString()); + + if (!response.data.id_token) { + throw new Error('Gigya JWT error: no id_token in response'); + } + + this.logger.debug('Gigya JWT received'); + return { + jwt: response.data.id_token, + }; + } catch (error) { + throw new Error(`Gigya JWT error: ${error}`); + } + } + + private async apiCall(url: string, data: string | object, retries = 3): Promise { + try { + const response = await this.gigyaAxios.post(url, data); + if (response.status !== 200) { + throw new Error(`API call error with status ${response.status}: ${response.statusText}, ${JSON.stringify(response.data)}`); + } + return response; + } catch (error) { + this.logger.error(`API call failed: ${error}`); + if (retries > 0) { + this.logger.debug(`Retrying API call (${retries} retries left)...`); + return this.apiCall(url, data, retries - 1); + } else { + throw new Error(`API call failed after ${retries} retries`); + } + } + } +} \ No newline at end of file diff --git a/src/device/BlueAirDevice.ts b/src/device/BlueAirDevice.ts new file mode 100644 index 0000000..6ecbf9a --- /dev/null +++ b/src/device/BlueAirDevice.ts @@ -0,0 +1,7 @@ +import EventEmitter from 'events'; +import { BlueAirAwsApi } from '../api/BlueAirAwsApi'; + +export class BlueAirDevice extends EventEmitter { + + protected readonly api: BlueAirAwsApi; +} \ No newline at end of file diff --git a/src/platformUtils.ts b/src/platformUtils.ts index 9bad724..7961900 100644 --- a/src/platformUtils.ts +++ b/src/platformUtils.ts @@ -1,7 +1,7 @@ export type Config = { verboseLogging: boolean; - oauthToken: string; - oauthTokenSecret: string; + username: string; + password: string; region: Region; devices: DeviceConfig[]; }; @@ -9,7 +9,6 @@ export type Config = { export type DeviceConfig = { id: string; name: string; - isAWSDevice: boolean; led: boolean; airQualitySensor: boolean; co2Sensor: boolean; @@ -20,18 +19,17 @@ export type DeviceConfig = { }; export enum Region { + EU = 'Default (all other regions)', US = 'USA', - EU = 'Europe', - CA = 'Canada', CN = 'China', + AU = 'Australia', + RU = 'Russia', } export const RegionMap = { [Region.US]: 'us', - [Region.EU]: 'eu', - [Region.CA]: 'eu', [Region.CN]: 'cn', + [Region.EU]: 'eu', + [Region.AU]: 'au', + [Region.RU]: 'ru', }; - -export const LOGIN_EXPIRATION = 3600 * 1000 * 12; // n hours in milliseconds -export const DEVICE_UPDATE_INTERVAL = 1000 * 5; // n seconds in milliseconds