diff --git a/lib/TuyaOAuth2Client.ts b/lib/TuyaOAuth2Client.ts index d7f72dea..65dd6238 100644 --- a/lib/TuyaOAuth2Client.ts +++ b/lib/TuyaOAuth2Client.ts @@ -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 }; @@ -35,9 +36,6 @@ export default class TuyaOAuth2Client extends OAuth2Client { 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. @@ -298,9 +296,12 @@ export default class TuyaOAuth2Client extends OAuth2Client { /* * Webhooks */ - registeredDevices = new Map(); + private __updateWebhookTimeout?: NodeJS.Timeout; + private webhook?: CloudWebhook; + private webhookParser = new TuyaWebhookParser(this); + private registeredDevices = new Map(); // Devices that are added as 'other' may be duplicates - registeredOtherDevices = new Map(); + private registeredOtherDevices = new Map(); registerDevice( { @@ -309,12 +310,6 @@ export default class TuyaOAuth2Client extends OAuth2Client { onStatus = async (): Promise => { /* empty */ }, - onOnline = async (): Promise => { - /* empty */ - }, - onOffline = async (): Promise => { - /* empty */ - }, }: DeviceRegistration, other = false, ): void { @@ -323,8 +318,6 @@ export default class TuyaOAuth2Client extends OAuth2Client { productId, deviceId, onStatus, - onOnline, - onOffline, }); this.onUpdateWebhook(); } @@ -357,58 +350,23 @@ export default class TuyaOAuth2Client extends OAuth2Client { 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'); } }) diff --git a/lib/TuyaOAuth2Device.ts b/lib/TuyaOAuth2Device.ts index 5051cc4c..e4dd28bb 100644 --- a/lib/TuyaOAuth2Device.ts +++ b/lib/TuyaOAuth2Device.ts @@ -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'; @@ -63,23 +63,7 @@ export default class TuyaOAuth2Device extends OAuth2Device { this.oAuth2Client.registerDevice( { ...this.data, - onStatus: async (statuses: TuyaStatusUpdate[]) => { - const changedStatusCodes = statuses.map((status: TuyaStatusUpdate) => 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, ); diff --git a/lib/TuyaOAuth2Util.ts b/lib/TuyaOAuth2Util.ts index 9249c58f..6775f9af 100644 --- a/lib/TuyaOAuth2Util.ts +++ b/lib/TuyaOAuth2Util.ts @@ -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; diff --git a/lib/webhooks/TuyaWebhookParser.ts b/lib/webhooks/TuyaWebhookParser.ts new file mode 100644 index 00000000..88cb8fa9 --- /dev/null +++ b/lib/webhooks/TuyaWebhookParser.ts @@ -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>; + }; +}; +type IotCoreStatusEvent = { + event: 'iot_core_status'; + data: { + dataId: string; + properties?: Array>; + }; +}; + +type TuyaWebhookData = OnlineEvent | OfflineEvent | StatusEvent | IotCoreStatusEvent; + +export default class TuyaWebhookParser { + private dataHistory: string[] = []; + private dataHistoryCodes: Record = {}; + private readonly logContext; + + constructor(logContext: SimpleClass) { + this.logContext = logContext; + } + + public async handle(devices: Array, message: TuyaWebhookData): Promise { + 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; + } +} diff --git a/types/TuyaTypes.ts b/types/TuyaTypes.ts index 45fa60b2..65c39977 100644 --- a/types/TuyaTypes.ts +++ b/types/TuyaTypes.ts @@ -2,6 +2,7 @@ import type TuyaOAuth2Device from '../lib/TuyaOAuth2Device'; export type TuyaStatus = Record; +// Legacy status update export type TuyaStatusUpdate = { code: string; value: T; @@ -9,12 +10,18 @@ export type TuyaStatusUpdate = { [datapoint: string]: unknown; // Seems to be datapoint index as string to value as string }; +// IoT Core status update +export type TuyaIotCoreStatusUpdate = { + code: string; + dpId: number; + time: number; + value: T; +}; + export type DeviceRegistration = { productId: string; deviceId: string; - onStatus: (status: TuyaStatus) => Promise; - onOnline: () => Promise; - onOffline: () => Promise; + onStatus: (status: TuyaStatus, changedStatusCodes: string[]) => Promise; }; export type SettingsEvent = {