homebridge-jci-hitachi-platform
Version:
Homebridge platform plugin providing HomeKit support for Jci Hitachi air conditioners.
944 lines (627 loc) • 27.7 kB
text/typescript
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;
}
}