Skip to content

Commit

Permalink
Merge pull request #224 from athombv/iot-core-webhook
Browse files Browse the repository at this point in the history
Support new webhook event type
  • Loading branch information
bobvandevijver authored Sep 13, 2024
2 parents 25af861 + a37015a commit 622f0d0
Show file tree
Hide file tree
Showing 5 changed files with 140 additions and 86 deletions.
84 changes: 21 additions & 63 deletions lib/TuyaOAuth2Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import TuyaOAuth2Error from './TuyaOAuth2Error';

import TuyaOAuth2Token from './TuyaOAuth2Token';
import * as TuyaOAuth2Util from './TuyaOAuth2Util';
import TuyaWebhookParser from './webhooks/TuyaWebhookParser';

type BuildRequest = { opts: { method: unknown; body: unknown; headers: object }; url: string };
type OAuth2SessionInformation = { id: string; title: string };
Expand All @@ -35,9 +36,6 @@ export default class TuyaOAuth2Client extends OAuth2Client<TuyaOAuth2Token> {
static AUTHORIZATION_URL = 'https://openapi.tuyaus.com/login';
static REDIRECT_URL = 'https://tuya.athom.com/callback';

__updateWebhookTimeout?: NodeJS.Timeout;
webhook?: CloudWebhook;

// We save this information to eventually enable OAUTH2_MULTI_SESSION.
// We can then list all authenticated users by name, e-mail and country flag.
// This is useful for multiple account across Tuya brands & regions.
Expand Down Expand Up @@ -298,9 +296,12 @@ export default class TuyaOAuth2Client extends OAuth2Client<TuyaOAuth2Token> {
/*
* Webhooks
*/
registeredDevices = new Map<string, DeviceRegistration>();
private __updateWebhookTimeout?: NodeJS.Timeout;
private webhook?: CloudWebhook;
private webhookParser = new TuyaWebhookParser(this);
private registeredDevices = new Map<string, DeviceRegistration>();
// Devices that are added as 'other' may be duplicates
registeredOtherDevices = new Map<string, DeviceRegistration>();
private registeredOtherDevices = new Map<string, DeviceRegistration>();

registerDevice(
{
Expand All @@ -309,12 +310,6 @@ export default class TuyaOAuth2Client extends OAuth2Client<TuyaOAuth2Token> {
onStatus = async (): Promise<void> => {
/* empty */
},
onOnline = async (): Promise<void> => {
/* empty */
},
onOffline = async (): Promise<void> => {
/* empty */
},
}: DeviceRegistration,
other = false,
): void {
Expand All @@ -323,8 +318,6 @@ export default class TuyaOAuth2Client extends OAuth2Client<TuyaOAuth2Token> {
productId,
deviceId,
onStatus,
onOnline,
onOffline,
});
this.onUpdateWebhook();
}
Expand Down Expand Up @@ -357,58 +350,23 @@ export default class TuyaOAuth2Client extends OAuth2Client<TuyaOAuth2Token> {
this.webhook = await this.homey.cloud.createWebhook(Homey.env.WEBHOOK_ID, Homey.env.WEBHOOK_SECRET, {
$keys: combinedKeys,
});
this.webhook?.on('message', message => {
this.log('onWebhookMessage', JSON.stringify(message));

Promise.resolve()
.then(async () => {
const key = message.headers['x-tuya-key'];

const registeredDevice = this.registeredDevices.get(key);
const registeredOtherDevice = this.registeredOtherDevices.get(key);
if (!registeredDevice && !registeredOtherDevice) return;

Promise.resolve()
.then(async () => {
switch (message.body.event) {
case 'status': {
if (!Array.isArray(message.body.data.deviceStatus)) return;

if (registeredDevice) {
await registeredDevice.onStatus(message.body.data.deviceStatus);
}
if (registeredOtherDevice) {
await registeredOtherDevice.onStatus(message.body.data.deviceStatus);
}
break;
}
case 'online': {
if (registeredDevice) {
await registeredDevice.onOnline();
}
if (registeredOtherDevice) {
await registeredOtherDevice.onOnline();
}
break;
}
case 'offline': {
if (registeredDevice) {
await registeredDevice.onOffline();
}
if (registeredOtherDevice) {
await registeredOtherDevice.onOffline();
}
break;
}
default: {
this.error(`Unknown Webhook Event: ${message.body.event}`);
}
}
})
.catch(err => this.error(err));
})

this.webhook?.on('message', async message => {
this.log('Incoming webhook', JSON.stringify(message));

const key = message.headers['x-tuya-key'];
const registeredDevice = this.registeredDevices.get(key) ?? null;
const registeredOtherDevice = this.registeredOtherDevices.get(key) ?? null;
if (!registeredDevice && !registeredOtherDevice) {
this.log('No matching devices found for webhook data');
return;
}

await this.webhookParser
.handle([registeredDevice, registeredOtherDevice], message.body)
.catch(err => this.error(`Error Handling Webhook Message: ${err.message}`));
});

this.log('Registered Webhook');
}
})
Expand Down
22 changes: 3 additions & 19 deletions lib/TuyaOAuth2Device.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { OAuth2Device } from 'homey-oauth2app';
import { TuyaCommand, TuyaDeviceDataPointResponse, TuyaStatusResponse, TuyaWebRTC } from '../types/TuyaApiTypes';
import type { TuyaCommand, TuyaDeviceDataPointResponse, TuyaStatusResponse, TuyaWebRTC } from '../types/TuyaApiTypes';

import { TuyaStatus, TuyaStatusUpdate } from '../types/TuyaTypes';
import type { TuyaStatus } from '../types/TuyaTypes';
import TuyaOAuth2Client from './TuyaOAuth2Client';
import * as TuyaOAuth2Util from './TuyaOAuth2Util';
import * as GeneralMigrations from './migrations/GeneralMigrations';
Expand Down Expand Up @@ -63,23 +63,7 @@ export default class TuyaOAuth2Device extends OAuth2Device<TuyaOAuth2Client> {
this.oAuth2Client.registerDevice(
{
...this.data,
onStatus: async (statuses: TuyaStatusUpdate<unknown>[]) => {
const changedStatusCodes = statuses.map((status: TuyaStatusUpdate<unknown>) => status.code);

this.log('changedStatusCodes', changedStatusCodes);
const status = TuyaOAuth2Util.convertStatusArrayToStatusObject(statuses);
await this.__onTuyaStatus(status, changedStatusCodes);
},
onOnline: async () => {
await this.__onTuyaStatus({
online: true,
});
},
onOffline: async () => {
await this.__onTuyaStatus({
online: false,
});
},
onStatus: this.__onTuyaStatus.bind(this),
},
isOtherDevice,
);
Expand Down
6 changes: 5 additions & 1 deletion lib/TuyaOAuth2Util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ import TuyaOAuth2Device from './TuyaOAuth2Device';
* @param {Array} statuses
* @returns {Object}
*/
export function convertStatusArrayToStatusObject(statuses: TuyaStatusResponse): TuyaStatus {
export function convertStatusArrayToStatusObject(statuses?: TuyaStatusResponse): TuyaStatus {
if (!Array.isArray(statuses)) {
return {};
}

return statuses.reduce((obj, item) => {
obj[item.code] = item.value;

Expand Down
101 changes: 101 additions & 0 deletions lib/webhooks/TuyaWebhookParser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import type { SimpleClass } from 'homey';
import { TuyaStatusResponse } from '../../types/TuyaApiTypes';
import type { DeviceRegistration, TuyaIotCoreStatusUpdate, TuyaStatus, TuyaStatusUpdate } from '../../types/TuyaTypes';
import { convertStatusArrayToStatusObject } from '../TuyaOAuth2Util';

type OnlineEvent = { event: 'online' };
type OfflineEvent = { event: 'offline' };
type StatusEvent = {
event: 'status';
data: {
dataId?: string;
deviceStatus?: Array<TuyaStatusUpdate<unknown>>;
};
};
type IotCoreStatusEvent = {
event: 'iot_core_status';
data: {
dataId: string;
properties?: Array<TuyaIotCoreStatusUpdate<unknown>>;
};
};

type TuyaWebhookData = OnlineEvent | OfflineEvent | StatusEvent | IotCoreStatusEvent;

export default class TuyaWebhookParser {
private dataHistory: string[] = [];
private dataHistoryCodes: Record<string, string[]> = {};
private readonly logContext;

constructor(logContext: SimpleClass) {
this.logContext = logContext;
}

public async handle(devices: Array<DeviceRegistration | null>, message: TuyaWebhookData): Promise<void> {
let statusUpdate: TuyaStatus;
switch (message.event) {
case 'online':
statusUpdate = { online: true };
break;
case 'offline':
statusUpdate = { online: false };
break;
case 'status': // Legacy status update
statusUpdate = this.filterDuplicateData(message.data.dataId, message.data.deviceStatus);

break;
case 'iot_core_status':
statusUpdate = this.filterDuplicateData(message.data.dataId, message.data.properties);

break;
default:
throw new Error(`Unknown Webhook Event: ${message}`);
}

const changedStatusCodes = Object.keys(statusUpdate);
if (changedStatusCodes.length === 0) {
this.logContext.log('Empty status update, ignoring');
return;
}

this.logContext.log('Changed status codes', changedStatusCodes);
for (const device of devices) {
await device?.onStatus(statusUpdate, changedStatusCodes);
}
}

private filterDuplicateData(dataId?: string, statuses?: TuyaStatusResponse): TuyaStatus {
const statusUpdate = convertStatusArrayToStatusObject(statuses);

if (!dataId) {
return statusUpdate;
}

// Check whether we already got this data point
if (!this.dataHistory.includes(dataId)) {
// We keep a history of 50 items
if (this.dataHistory.length >= 50) {
const oldDataId = this.dataHistory.shift();
if (oldDataId) {
delete this.dataHistoryCodes[oldDataId];
}
}

// Add the data registration
this.dataHistory.push(dataId);
this.dataHistoryCodes[dataId] = [];
}

for (const key of Object.keys(statusUpdate)) {
if (this.dataHistoryCodes[dataId]?.includes(key)) {
// Already received, so skip it
delete statusUpdate[key];
continue;
}

this.dataHistoryCodes[dataId]?.push(key);
}

return statusUpdate;
}
}
13 changes: 10 additions & 3 deletions types/TuyaTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,26 @@ import type TuyaOAuth2Device from '../lib/TuyaOAuth2Device';

export type TuyaStatus = Record<string, unknown>;

// Legacy status update
export type TuyaStatusUpdate<T> = {
code: string;
value: T;
t: number;
[datapoint: string]: unknown; // Seems to be datapoint index as string to value as string
};

// IoT Core status update
export type TuyaIotCoreStatusUpdate<T> = {
code: string;
dpId: number;
time: number;
value: T;
};

export type DeviceRegistration = {
productId: string;
deviceId: string;
onStatus: (status: TuyaStatus) => Promise<void>;
onOnline: () => Promise<void>;
onOffline: () => Promise<void>;
onStatus: (status: TuyaStatus, changedStatusCodes: string[]) => Promise<void>;
};

export type SettingsEvent<T extends { [key: string]: unknown }> = {
Expand Down

0 comments on commit 622f0d0

Please sign in to comment.