UNPKG

homebridge-jci-hitachi-platform

Version:

Homebridge platform plugin providing HomeKit support for Jci Hitachi air conditioners.

944 lines (627 loc) 27.7 kB
import JciHitachiPlatformLogger from './logger'; import axios,{ AxiosError, AxiosResponse, all } from 'axios'; import {mqtt5, auth, iot} from "aws-iot-device-sdk-v2"; import {once} from "events"; import {toUtf8} from "@aws-sdk/util-utf8-browser"; import { AWS_SSL_CERT } from './cert'; const https = require( "https" ); const AWS_REGION = "ap-northeast-1"; const AWS_COGNITO_IDP_ENDPOINT = `cognito-idp.${AWS_REGION}.amazonaws.com`; const AWS_COGNITO_ENDPOINT = `cognito-identity.${AWS_REGION}.amazonaws.com`; const AWS_COGNITO_CLIENT_ID = "7kfnjsb66ei1qt5s5gjv6j1lp6"; const AWS_COGNITO_USERPOOL_ID = `${AWS_REGION}_aTZeaievK`; const AWS_IOT_ENDPOINT = "iot-api.jci-hitachi-smarthome.com"; const AWS_MQTT_ENDPOINT = `a8kcu267h96in-ats.iot.${AWS_REGION}.amazonaws.com`; const QOS = mqtt5.QoS.AtLeastOnce; export interface NotifyCallback { (thingName: AWSThings|undefined): void; } function generateRandomHex(length: number): string { const characters = 'abcdef0123456789'; let result = ''; for (let i = 0; i < length; i++) { const randomIndex = Math.floor(Math.random() * characters.length); result += characters[randomIndex]; } return result; } class AWSTokens { access_token: string id_token: string refresh_token: string expiration: number constructor(access_token: string = "", id_token: string = "", refresh_token: string = "", expiration: number = 0) { this.access_token = access_token; this.id_token = id_token; this.refresh_token = refresh_token; this.expiration = expiration; } } class AWSIdentity { identity_id: string host_identity_id: string user_name: string user_attributes: {[key: string]: string} constructor(identity_id: string, user_name: string, user_attributes: {[key: string]: string}) { this.identity_id = identity_id; this.host_identity_id = user_attributes["custom:host_identity_id"], this.user_name = user_name; this.user_attributes = user_attributes; } } class AWSCredentials{ access_key_id: string = "" secret_access_key: string = "" session_token: string = "" expiration: number = 0 constructor(awscredentialsContent: string ){ const awscredentialsJson = JSON.parse(awscredentialsContent); this.access_key_id = awscredentialsJson['AccessKeyId']; this.secret_access_key = awscredentialsJson['SecretKey']; this.session_token = awscredentialsJson['SessionToken']; this.expiration = awscredentialsJson['Expiration']; } } export class AWSThingDictionary{ things: {[thingName:string]:AWSThings} = {}; log: JciHitachiPlatformLogger|undefined constructor(awsallthingsContent: string, log: JciHitachiPlatformLogger|undefined = undefined){ this.log = log; const awsallthingsJson = JSON.parse(awsallthingsContent); const things = awsallthingsJson['results']['Things']; for (let i = 0; i < things.length; i++) { const thing = new AWSThings(JSON.stringify(things[i])); this.things[thing.ThingName] = thing; } this.log?.info(`You have ${Object.keys(this.things).length} devices.`); } public getThingNameByCustomDeviceName(customDeviceName: string): string|undefined { for (let i = 0; i < Object.keys(this.things).length; i++) { if (this.things[i].CustomDeviceName == customDeviceName) { return this.things[i].ThingName; } } return undefined; } public getAllThings(): {[thingName:string]:AWSThings} { return this.things; } public hasThingName(thingName: string): boolean { return this.things[thingName] !== undefined; } public getDevice(thingName: string): AWSThings|undefined { return this.things[thingName]; } public updateDeviceStatusPayload(thingName: string, payload: Object): void { if(this.hasThingName(thingName) == false){ return; } this.things[thingName].updateStatusPayload(payload); } public updateDeviceRegistrationPayload(thingName: string, payload: Object): void { if(this.hasThingName(thingName) == false){ return; } this.things[thingName].updateRegistrationPayload(payload); } } export class AWSThings{ thingObject: Object; statusPayload: Object|undefined = undefined registrationPayload: Object|undefined = undefined constructor(awsthingsContent: string ){ this.thingObject = JSON.parse(awsthingsContent); } public get ThingName(): string { return this.thingObject['ThingName']; } public get CustomDeviceName(): string { return this.thingObject['CustomDeviceName']; } public get DeviceType(): number { return this.thingObject['DeviceType']; } public get SwitchOn(): boolean { return this.statusPayload ? this.statusPayload['Switch'] : false; } public get TemperatureSetting(): number|undefined { return this.statusPayload ? this.statusPayload['TemperatureSetting'] : undefined; } public get TemperatureSettingMin(): number{ return this.registrationPayload ? (this.registrationPayload['TemperatureSetting'] >> 8 & 255) : 16; } public get TemperatureSettingMax(): number{ return this.registrationPayload ? (this.registrationPayload['TemperatureSetting'] & 255) : 32; } public get IndoorTemperature(): number|undefined { return this.statusPayload ? this.statusPayload['IndoorTemperature'] : undefined; } public get IndoorHumidity(): number|undefined { return this.statusPayload ? this.statusPayload['IndoorHumidity'] : undefined; } public get PM25(): number|undefined { return this.statusPayload ? this.statusPayload['PM25'] : undefined; } public get FanSpeed(): number|undefined { return this.statusPayload ? this.statusPayload['FanSpeed'] : undefined; } public get QuickMode(): boolean { return this.statusPayload ? this.statusPayload['QuickMode'] : false; } public get CleanNotification(): boolean { return this.statusPayload ? this.statusPayload['CleanNotification'] : false; } public get CleanSwitch(): boolean { return this.statusPayload ? this.statusPayload['CleanSwitch'] : false; } public get FirmwareVersion(): string|undefined { return this.registrationPayload ? this.registrationPayload['FirmwareVersion'] : undefined; } public get Model(): string|undefined { return this.registrationPayload ? this.registrationPayload['Model'] : undefined; } public updateStatusPayload(payload: Object): void { this.statusPayload = payload; } public updateRegistrationPayload(payload: Object): void { this.registrationPayload = payload; } } abstract class JciHitachiAWSHttpConnection { log: JciHitachiPlatformLogger; constructor(log: JciHitachiPlatformLogger){ this.log = log; } //Request https connection with AWS_SSL_CERT, and return the response protected async requestHttps(url: string, method: string, headers: any, data: any): Promise<AxiosResponse> { return await axios.request({ httpsAgent: new https.Agent({ ca: [AWS_SSL_CERT], }), method: method, url: url, headers: headers, data: JSON.stringify(data) }); } } class JciHitachiAWSCognitoConnection extends JciHitachiAWSHttpConnection { email: string; password: string; aws_tokens: AWSTokens|undefined; constructor(email: string, password: string, aws_tokens:AWSTokens|undefined, log: JciHitachiPlatformLogger) { super(log); this.email = email; this.password = password; this.aws_tokens = aws_tokens; } protected _generateHeaders(target: string): object { return { "X-Amz-Target": target, "User-Agent": "Dalvik/2.1.0", "content-type": "application/x-amz-json-1.1", "Accept": "application/json", }; } protected _handle_response(response: AxiosResponse):AxiosResponse { if (response.status == 200) { //this.log.debug(`login_req: ${JSON.stringify(response.data)}`); return response; } else { this.log.error(`login_req: ${JSON.stringify(response.data)}`); return response; } } protected _send(target: string, data : any): Promise<AxiosResponse> { const endpoint = `https://${this.constructor.name === 'GetCredentials' ? AWS_COGNITO_ENDPOINT : AWS_COGNITO_IDP_ENDPOINT}`; return this.requestHttps(endpoint, 'post', this._generateHeaders(target), data) } public getAWSTokens(): AWSTokens|undefined { return this.aws_tokens } public async login(use_refresh_token: boolean): Promise<AWSTokens|undefined> { let login_json_data: any; const login_headers = this._generateHeaders("AWSCognitoIdentityProviderService.InitiateAuth"); if(use_refresh_token && this.aws_tokens){ login_json_data = { "AuthFlow": 'REFRESH_TOKEN_AUTH', "AuthParameters":{ 'REFRESH_TOKEN': this.aws_tokens.refresh_token, }, "ClientId": AWS_COGNITO_CLIENT_ID, } } else{ login_json_data = { "AuthFlow": 'USER_PASSWORD_AUTH', "AuthParameters": { 'USERNAME': this.email, 'PASSWORD': this.password, }, "ClientId": AWS_COGNITO_CLIENT_ID, } } const login_req:Promise<AxiosResponse> = this.requestHttps(`https://${AWS_COGNITO_IDP_ENDPOINT}`, 'post', login_headers, login_json_data); const response = this._handle_response(await login_req); if(response.status == 200){ const auth_result = response.data['AuthenticationResult']; this.aws_tokens = new AWSTokens( auth_result['AccessToken'], auth_result['IdToken'], use_refresh_token && this.aws_tokens ? this.aws_tokens.refresh_token : auth_result['RefreshToken'], new Date().valueOf() + auth_result['ExpiresIn'] ); } else{ this.log.error(`login_req: ${JSON.stringify(response.data)}`); } return this.aws_tokens; } } class GetUser extends JciHitachiAWSCognitoConnection { public async get_data(): Promise<AWSIdentity|undefined> { if(!this.aws_tokens){ return undefined; } const json_data = { "AccessToken": this.aws_tokens.access_token, } const response:AxiosResponse = await this._send("AWSCognitoIdentityProviderService.GetUser", json_data); if(response.status == 200){ const user_attributes = response.data['UserAttributes'].reduce((acc: {[key: string]: string}, cur: {Name: string, Value: string}) => { acc[cur.Name] = cur.Value; return acc; }, {}); return new AWSIdentity(user_attributes['custom:cognito_identity_id'],user_attributes['Username'], user_attributes); } } } class GetCredentials extends JciHitachiAWSCognitoConnection { public async get_data(aws_identity:AWSIdentity): Promise<AWSCredentials|undefined> { if(!this.aws_tokens){ return undefined; } const json_data = JSON.parse(`{ "IdentityId": "${aws_identity.identity_id}", "Logins": { "${AWS_COGNITO_IDP_ENDPOINT}/${AWS_COGNITO_USERPOOL_ID}": "${this.aws_tokens.id_token}" } }`); const response:AxiosResponse = await this._send("AWSCognitoIdentityService.GetCredentialsForIdentity", json_data); if(response.status == 200){ return new AWSCredentials(JSON.stringify(response.data['Credentials'])); } } } class JciHitachiAWSIoTConnection extends JciHitachiAWSHttpConnection{ aws_tokens: AWSTokens; constructor(aws_tokens: AWSTokens, log: JciHitachiPlatformLogger) { super(log); this.aws_tokens = aws_tokens; } protected _generateAWSIOTHeaders(need_access_token: boolean): object { let headers = {}; if(need_access_token){ headers = { "authorization": `Bearer ${this.aws_tokens.id_token}`, "accesstoken": `Bearer ${this.aws_tokens.access_token}`, "User-Agent": "Dalvik/2.1.0", "content-type": "application/json", "Accept": "application/json", }; } else{ headers = { "authorization": `Bearer ${this.aws_tokens.id_token}`, "User-Agent": "Dalvik/2.1.0", "content-type": "application/json", "Accept": "application/json", }; } return headers; } protected _handle_response(response: AxiosResponse):AxiosResponse { if (response.status == 200) { this.log.debug(`login_req: ${JSON.stringify(response.data)}`); return response; } else { this.log.error(`login_req: ${JSON.stringify(response.data)}`); return response; } } protected _send(target: string, data : any, need_access_token:boolean): Promise<AxiosResponse> { const endpoint = `https://${AWS_IOT_ENDPOINT}${target}`; return this.requestHttps(endpoint, 'post', this._generateAWSIOTHeaders(need_access_token), data); } } class GetAllDevice extends JciHitachiAWSIoTConnection { public async get_data(): Promise<AWSThingDictionary> { const response:AxiosResponse = await this._send('/GetAllDevice', {}, false); return new AWSThingDictionary(JSON.stringify(response.data), this.log); } } class ListSubUser extends JciHitachiAWSIoTConnection { public async getHostUserID(): Promise<string> { const response:AxiosResponse = await this._send('/ListSubUser', {}, false); const familyMemberList = response.data['results']['FamilyMemberList']; for(const familyMember of familyMemberList){ if(familyMember['isHost']){ this.log.info(`Host User : ${familyMember['firstName']},${familyMember['lastName']}`); return familyMember['userId']; } } this.log.error(`No Host User : ${JSON.stringify(response.data)}`); return ''; } } class GetAllGroup extends JciHitachiAWSIoTConnection { public async get_data(): Promise<Object> { const response:AxiosResponse = await this._send('/GetAllGroup', {}, false); return response.data; } } export default class JciHitachiAWSAPI { email: string; password: string; log: JciHitachiPlatformLogger; mqttclient: mqtt5.Mqtt5Client|undefined; aws_tokens: AWSTokens|undefined; aws_credentials: AWSCredentials|undefined; aws_identity: AWSIdentity|undefined; aws_thing_dict: AWSThingDictionary|undefined; task_id:number = 0; is_host:boolean = false; last_received_time: number = 0; callback:NotifyCallback|undefined; isConnected: boolean = false; isLoginFailed: boolean = false; constructor(email: string, password: string, log: JciHitachiPlatformLogger) { this.email = email; this.password = password; this.log = log; } deconstructor(){ this.Logout(); } setCallback(callback:NotifyCallback){ this.callback = callback; } public async Login(): Promise<boolean> { try{ await this.Logout(); if(this.isConnected){ return true; } this.aws_tokens = await (new JciHitachiAWSCognitoConnection(this.email, this.password, undefined, this.log)).login(false); if(!this.aws_tokens){ this.log.info('Login failed'); return false; } this.aws_identity = await (new GetUser(this.email, this.password, this.aws_tokens, this.log)).get_data(); this.aws_thing_dict = await (new GetAllDevice(this.aws_tokens, this.log)).get_data(); this.log.debug("aws_identity:" + JSON.stringify(this.aws_identity)); if(this.aws_identity){ this.aws_credentials = await (new GetCredentials(this.email, this.password, this.aws_tokens, this.log)).get_data(this.aws_identity); this.log.debug(JSON.stringify(this.aws_identity) + ` host_user_id: ${this.aws_identity.host_identity_id}`); this.is_host = this.aws_identity.identity_id === this.aws_identity.host_identity_id; if(this.aws_credentials && this.aws_identity.host_identity_id.length > 0){ this.log.debug(JSON.stringify(this)); this.mqttclient = this.createMQTTClient(); } if(this.mqttclient){ const attemptingConnect = once(this.mqttclient, "attemptingConnect"); const connectionSuccess = once(this.mqttclient, "connectionSuccess"); this.mqttclient.start(); await attemptingConnect; await connectionSuccess; const suback = await this.mqttclient.subscribe({ subscriptions: [ { qos: QOS, topicFilter: `${this.aws_identity.host_identity_id}/+/+/response` } ] }); this.log.debug('Suback result: ' + JSON.stringify(suback)); await this.RefeshAWSThingDictionary('registration'); await this.RefeshAWSThingDictionary('status'); return true; } } }catch(e){ this.log.error(`Login Error: ${e}`); } return false; } public async Logout(): Promise<boolean> { try{ this.isConnected = false; if(this.mqttclient){ const unsuback = await this.mqttclient.unsubscribe({ topicFilters: [ `${this.aws_identity?.host_identity_id}/#` ] }); this.log.debug('Unsuback result: ' + JSON.stringify(unsuback)); const disconnection = once(this.mqttclient, "disconnection"); const stopped = once(this.mqttclient, "stopped"); this.mqttclient.stop(); await disconnection; await stopped; this.mqttclient = undefined; } return true; }catch(e){ this.mqttclient = undefined; this.log.error(`Logout Error: ${e}`); } return true; } public get isHost(): boolean { return this.is_host; } public getDevices(): AWSThingDictionary|undefined { return this.aws_thing_dict; } public getDevice(thingName:string): AWSThings|undefined { return this.aws_thing_dict?.getDevice(thingName); } public async RefeshAWSThingDictionary(actionName:string = 'status'){ if(!this.aws_thing_dict){ return; } for(const thingName in this.aws_thing_dict.getAllThings()){ this.publish(thingName, actionName); } } public async RefeshDevice(thingName:string): Promise<boolean> { if(this.last_received_time != 0 && Math.ceil(Date.now() / 1000) - this.last_received_time > 600){ this.log.error('MQTT Connection Timeout'); await this.Logout(); this.log.info('Re-Login'); await this.Login(); return false; } if(this.aws_thing_dict?.hasThingName(thingName)){ return await this.publish(thingName, 'status'); } return false; } public async GetDeviceStatus(thingName:string, status_name:string, need_refresh:boolean = false): Promise<Object|undefined> { if(need_refresh){ await this.RefeshDevice(thingName); } const device = this.aws_thing_dict?.getDevice(thingName); if(!device || device.statusPayload === undefined){ return undefined; } return device.statusPayload[status_name]; } public async SetDeviceStatus(thingName:string, status_name:string, status_value:number): Promise<boolean> { const payload = { "Condition": { "ThingName": thingName, "Index": 0, "Geofencing": { "Arrive": null, "Leave": null }, }, "TaskID": this.task_id++, "Timestamp": Math.ceil(Date.now() / 1000) }; payload[status_name] = status_value; return await this.publish(thingName, 'control', payload); } protected handleMQTTMessage(topic: string, payload: any): void { try{ const topic_parts = topic.split('/'); const thingName = topic_parts[1]; const actionName = topic_parts[2]; const actionType = topic_parts[3]; const payloadContent = payload ? JSON.parse(toUtf8(payload as Buffer)) : {}; this.log.debug(`Received: ${topic} ${JSON.stringify(payloadContent)}`); if(actionType !== 'response'){ return; } if(this.aws_thing_dict === undefined){ return; } this.last_received_time = Math.ceil(Date.now() / 1000); if(this.getDevice(thingName)){ if(actionName === 'status'){ this.aws_thing_dict.updateDeviceStatusPayload(thingName, payloadContent); if(this.callback){ this.callback(this.getDevice(thingName)); } } else if(actionName === 'registration'){ this.aws_thing_dict.updateDeviceRegistrationPayload(thingName, payloadContent); } else if(actionName === 'control'){ this.RefeshDevice(thingName); } } }catch(e){ this.log.error(`MQTT Message Error: ${e}`); } } protected createMQTTClient(): mqtt5.Mqtt5Client { if(this.aws_credentials === undefined || this.aws_identity === undefined){ throw new Error('aws_credentials is undefined'); } const wsConfig : iot.WebsocketSigv4Config = { credentialsProvider: auth.AwsCredentialsProvider.newStatic(this.aws_credentials.access_key_id, this.aws_credentials.secret_access_key, this.aws_credentials.session_token), region: AWS_REGION } const builder: iot.AwsIotMqtt5ClientConfigBuilder = iot.AwsIotMqtt5ClientConfigBuilder.newWebsocketMqttBuilderWithSigv4Auth( AWS_MQTT_ENDPOINT, wsConfig ) const clientId = `${this.aws_identity.identity_id}_${generateRandomHex(16)}`; this.log.debug(`clientId: ${clientId}`); builder.withConnectProperties({ keepAliveIntervalSeconds: 120, clientId: `${clientId}` }); const client : mqtt5.Mqtt5Client = new mqtt5.Mqtt5Client(builder.build()); client.on('error', (error) => { this.log.error("Error event: " + error.toString()); this.isConnected = false; this.isLoginFailed = true; }); client.on("messageReceived",(eventData: mqtt5.MessageReceivedEvent) : void => { this.handleMQTTMessage(eventData.message.topicName, eventData.message.payload); } ); client.on('attemptingConnect', (eventData: mqtt5.AttemptingConnectEvent) => { this.log.debug("Attempting Connect event"); }); client.on('connectionSuccess', (eventData: mqtt5.ConnectionSuccessEvent) => { this.log.debug("Connection Success event"); this.log.debug("Connack: " + JSON.stringify(eventData.connack)); this.log.debug("Settings: " + JSON.stringify(eventData.settings)); this.isConnected = true; }); client.on('connectionFailure', (eventData: mqtt5.ConnectionFailureEvent) => { this.log.error("Connection failure event: " + eventData.error.toString()); this.isConnected = false; this.isLoginFailed = true; //throw new Error("Connection failure event: " + eventData.error.toString()); if(this.callback){ this.callback(undefined); } }); client.on('disconnection', (eventData: mqtt5.DisconnectionEvent) => { this.log.debug("Disconnection event: " + eventData.error.toString()); if (eventData.disconnect !== undefined) { this.log.debug('Disconnect packet: ' + JSON.stringify(eventData.disconnect)); } this.isConnected = false; if(this.callback){ this.callback(undefined); } }); client.on('stopped', (eventData: mqtt5.StoppedEvent) => { this.log.debug("Stopped event"); }); return client; } protected async publish(thingName: string, request:string, payload: Object|undefined = undefined): Promise<boolean> { try{ const defaultPayload = JSON.stringify({ "Timestamp": Math.ceil(Date.now() / 1000) }); if(this.mqttclient && this.isConnected){ const topic = `${this.aws_identity?.host_identity_id}/${thingName}`; const qosPublishRegistrationResult = await this.mqttclient.publish({ qos: QOS, topicName: `${topic}/${request}/request`, payload: payload ? JSON.stringify(payload) : defaultPayload }); this.log.debug(`${topic}/${request}/request ${payload ? JSON.stringify(payload) : defaultPayload}`); return true; } }catch(e){ this.log.error(`Publish Error: ${e}`); this.Logout(); if(this.callback){ this.callback(undefined); } } return false; } }