From 5c4cb066f26ac8affe8cbd5ba06b011f683d9b45 Mon Sep 17 00:00:00 2001 From: bobvandevijver <bobvandevijver@users.noreply.github.com> Date: Mon, 16 Sep 2024 14:12:07 +0000 Subject: [PATCH 1/5] Update Homey App Version to v1.3.4 --- .homeychangelog.json | 3 +++ .homeycompose/app.json | 2 +- app.json | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.homeychangelog.json b/.homeychangelog.json index 6aca345..8169af5 100644 --- a/.homeychangelog.json +++ b/.homeychangelog.json @@ -67,5 +67,8 @@ }, "1.3.3": { "en": "Resolve issues with status updates" + }, + "1.3.4": { + "en": "Optimise pairing process" } } diff --git a/.homeycompose/app.json b/.homeycompose/app.json index e846e10..679b254 100644 --- a/.homeycompose/app.json +++ b/.homeycompose/app.json @@ -1,6 +1,6 @@ { "id": "com.tuya", - "version": "1.3.3", + "version": "1.3.4", "compatibility": ">=12.0.1", "brandColor": "#FF4800", "sdk": 3, diff --git a/app.json b/app.json index 962f5ee..e10aae7 100644 --- a/app.json +++ b/app.json @@ -1,7 +1,7 @@ { "_comment": "This file is generated. Please edit .homeycompose/app.json instead.", "id": "com.tuya", - "version": "1.3.3", + "version": "1.3.4", "compatibility": ">=12.0.1", "brandColor": "#FF4800", "sdk": 3, From 3d68bc2b68b1e24c713ec4df873fb32600a1f31e Mon Sep 17 00:00:00 2001 From: Bob van de Vijver <bob@drenso.nl> Date: Wed, 18 Sep 2024 10:36:09 +0200 Subject: [PATCH 2/5] Never try to filter when input query is empty --- app.ts | 42 ++++++++++++++++++++++++++---------------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/app.ts b/app.ts index b886eca..0405a66 100644 --- a/app.ts +++ b/app.ts @@ -59,7 +59,7 @@ module.exports = class TuyaOAuth2App extends OAuth2App { }; const autocompleteListener = async ( - query: string, + query: string | undefined, args: DeviceArgs, filter: ({ value }: { value: unknown }) => boolean, ): Promise<ArgumentAutocompleteResults> => { @@ -67,17 +67,21 @@ module.exports = class TuyaOAuth2App extends OAuth2App { values: TuyaStatusResponse | Array<TuyaDeviceDataPoint>, dataPoints: boolean, ): ArgumentAutocompleteResults { - return values - .filter(filter) - .filter(({ code }: { code: string }) => { - return code.toLowerCase().includes(query.toLowerCase()); - }) - .map(value => ({ - name: value.code, - id: value.code, - title: value.code, - dataPoint: dataPoints, - })); + values = values.filter(filter); + + const trimmedQuery = (query ?? '').trim(); + if (trimmedQuery) { + values = values.filter(({ code }: { code: string }) => + code.toLowerCase().includes(trimmedQuery.toLowerCase()), + ); + } + + return values.map(value => ({ + name: value.code, + id: value.code, + title: value.code, + dataPoint: dataPoints, + })); } const deviceId = args.device.getData().deviceId; @@ -221,7 +225,7 @@ module.exports = class TuyaOAuth2App extends OAuth2App { const client = this.getFirstSavedOAuth2Client(); await client.triggerScene(scene.id); }) - .registerArgumentAutocompleteListener('scene', async (query: string) => { + .registerArgumentAutocompleteListener('scene', async (query?: string) => { if (!this.apiCache.has(SCENE_CACHE_KEY)) { this.log('Retrieving available scenes'); const client = this.getFirstSavedOAuth2Client(); @@ -259,9 +263,15 @@ module.exports = class TuyaOAuth2App extends OAuth2App { this.apiCache.set(SCENE_CACHE_KEY, scenes); } - return (this.apiCache.get<HomeyTuyaScene[]>(SCENE_CACHE_KEY) ?? []).filter(scene => - scene.name.toLowerCase().includes(query.toLowerCase()), - ); + + const scenes = this.apiCache.get<HomeyTuyaScene[]>(SCENE_CACHE_KEY) ?? []; + + const trimmedQuery = (query ?? '').trim(); + if (!trimmedQuery) { + return scenes; + } + + return scenes.filter(scene => scene.name.toLowerCase().includes(trimmedQuery.toLowerCase())); }); this.log('Tuya started'); From 05dcc5cbf15e18a86a27ef7a19e5e974af10c064 Mon Sep 17 00:00:00 2001 From: Bob van de Vijver <bob@drenso.nl> Date: Wed, 18 Sep 2024 16:23:24 +0200 Subject: [PATCH 3/5] Handle webhook data deduplication in device implementation --- lib/TuyaOAuth2Device.ts | 78 ++++++++++++++++++++++++------- lib/webhooks/TuyaWebhookParser.ts | 48 ++----------------- types/TuyaTypes.ts | 3 +- 3 files changed, 69 insertions(+), 60 deletions(-) diff --git a/lib/TuyaOAuth2Device.ts b/lib/TuyaOAuth2Device.ts index e4dd28b..424fecc 100644 --- a/lib/TuyaOAuth2Device.ts +++ b/lib/TuyaOAuth2Device.ts @@ -1,7 +1,7 @@ import { OAuth2Device } from 'homey-oauth2app'; import type { TuyaCommand, TuyaDeviceDataPointResponse, TuyaStatusResponse, TuyaWebRTC } from '../types/TuyaApiTypes'; -import type { TuyaStatus } from '../types/TuyaTypes'; +import type { TuyaStatus, TuyaStatusSource } from '../types/TuyaTypes'; import TuyaOAuth2Client from './TuyaOAuth2Client'; import * as TuyaOAuth2Util from './TuyaOAuth2Util'; import * as GeneralMigrations from './migrations/GeneralMigrations'; @@ -68,6 +68,12 @@ export default class TuyaOAuth2Device extends OAuth2Device<TuyaOAuth2Client> { isOtherDevice, ); + const statusSourceUpdateCodes = this.getStoreValue('status_source_update_codes'); + if (Array.isArray(statusSourceUpdateCodes)) { + this.log('Restoring status source update codes: ', JSON.stringify(statusSourceUpdateCodes)); + statusSourceUpdateCodes.forEach(c => this.tuyaStatusSourceUpdateCodes.add(c)); + } + if (typeof TuyaOAuth2Device.SYNC_INTERVAL === 'number') { this.__syncInterval = this.homey.setInterval(this.__sync, TuyaOAuth2Device.SYNC_INTERVAL); } @@ -91,12 +97,56 @@ export default class TuyaOAuth2Device extends OAuth2Device<TuyaOAuth2Client> { /* * Tuya */ - async __onTuyaStatus(status: TuyaStatus, changedStatusCodes: string[] = []): Promise<void> { + private tuyaStatusSourceUpdateCodes: Set<string> = new Set(); + + private async __onTuyaStatus( + source: TuyaStatusSource, + status: TuyaStatus, + changedStatusCodes: string[] = [], + ): Promise<void> { + // Wait at least 100ms for initialization before trying to pass the barrier again + while (this.initBarrier) { + await new Promise(resolve => this.homey.setTimeout(resolve, 100)); + } + + // Filter duplicated data + if (source === 'status') { + changedStatusCodes.forEach(c => { + if (this.tuyaStatusSourceUpdateCodes.has(c)) { + return; + } + + this.log('Add status source update code', c); + this.tuyaStatusSourceUpdateCodes.add(c); + this.setStoreValue('status_source_update_codes', Array.from(this.tuyaStatusSourceUpdateCodes)); + }); + } + + if (source === 'iot_core_status') { + // GH-239: As we have two data sources, certain data point updates can come in twice. + // When a code has been reported with the status event, we should no longer listen to that code + // when coming in from the iot_core_status event. + for (const changedStatusCode of changedStatusCodes) { + if (!this.tuyaStatusSourceUpdateCodes.has(changedStatusCode)) { + continue; + } + + this.log('Ignoring iot_core_status code change', changedStatusCode); + delete status[changedStatusCode]; + } + + // Recompute changed status codes + changedStatusCodes = Object.keys(status); + } + this.__status = { ...this.__status, ...status, }; + this.log('onTuyaStatus', source, JSON.stringify(this.__status)); + + // Trigger the custom code cards for (const changedStatusCode of changedStatusCodes) { let changedStatusValue = status[changedStatusCode]; @@ -131,30 +181,26 @@ export default class TuyaOAuth2Device extends OAuth2Device<TuyaOAuth2Client> { .catch(this.error); } - await this.onTuyaStatus(this.__status, changedStatusCodes); - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async onTuyaStatus(status: TuyaStatus, _changedStatusCodes: string[]): Promise<void> { - // Wait at least 100ms for initialization before trying to pass the barrier again - while (this.initBarrier) { - await new Promise(resolve => this.homey.setTimeout(resolve, 100)); - } - - this.log('onTuyaStatus', JSON.stringify(status)); - if (status.online === true) { this.setAvailable().catch(this.error); } if (status.online === false) { this.setUnavailable(this.homey.__('device_offline')).catch(this.error); + + // Prevent further updates that would mark the device as available + return; } + await this.onTuyaStatus(this.__status, changedStatusCodes); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async onTuyaStatus(_status: TuyaStatus, _changedStatusCodes: string[]): Promise<void> { // Overload Me } - async __sync(): Promise<void> { + private async __sync(): Promise<void> { Promise.resolve() .then(async () => { this.log('Syncing...'); @@ -162,7 +208,7 @@ export default class TuyaOAuth2Device extends OAuth2Device<TuyaOAuth2Client> { const device = await this.oAuth2Client.getDevice({ deviceId }); const status = TuyaOAuth2Util.convertStatusArrayToStatusObject(device.status); - await this.__onTuyaStatus({ + await this.__onTuyaStatus('sync', { ...status, online: device.online, }); diff --git a/lib/webhooks/TuyaWebhookParser.ts b/lib/webhooks/TuyaWebhookParser.ts index 88cb8fa..d516e78 100644 --- a/lib/webhooks/TuyaWebhookParser.ts +++ b/lib/webhooks/TuyaWebhookParser.ts @@ -1,5 +1,4 @@ import type { SimpleClass } from 'homey'; -import { TuyaStatusResponse } from '../../types/TuyaApiTypes'; import type { DeviceRegistration, TuyaIotCoreStatusUpdate, TuyaStatus, TuyaStatusUpdate } from '../../types/TuyaTypes'; import { convertStatusArrayToStatusObject } from '../TuyaOAuth2Util'; @@ -23,8 +22,6 @@ type IotCoreStatusEvent = { type TuyaWebhookData = OnlineEvent | OfflineEvent | StatusEvent | IotCoreStatusEvent; export default class TuyaWebhookParser { - private dataHistory: string[] = []; - private dataHistoryCodes: Record<string, string[]> = {}; private readonly logContext; constructor(logContext: SimpleClass) { @@ -40,12 +37,12 @@ export default class TuyaWebhookParser { case 'offline': statusUpdate = { online: false }; break; - case 'status': // Legacy status update - statusUpdate = this.filterDuplicateData(message.data.dataId, message.data.deviceStatus); + case 'status': + statusUpdate = convertStatusArrayToStatusObject(message.data.deviceStatus); break; case 'iot_core_status': - statusUpdate = this.filterDuplicateData(message.data.dataId, message.data.properties); + statusUpdate = convertStatusArrayToStatusObject(message.data.properties); break; default: @@ -58,44 +55,9 @@ export default class TuyaWebhookParser { return; } - this.logContext.log('Changed status codes', changedStatusCodes); + this.logContext.log('Changed status codes', JSON.stringify(changedStatusCodes)); for (const device of devices) { - await device?.onStatus(statusUpdate, changedStatusCodes); + await device?.onStatus(message.event, 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 65c3997..3bce421 100644 --- a/types/TuyaTypes.ts +++ b/types/TuyaTypes.ts @@ -1,6 +1,7 @@ import type TuyaOAuth2Device from '../lib/TuyaOAuth2Device'; export type TuyaStatus = Record<string, unknown>; +export type TuyaStatusSource = 'sync' | 'online' | 'offline' | 'status' | 'iot_core_status'; // Legacy status update export type TuyaStatusUpdate<T> = { @@ -21,7 +22,7 @@ export type TuyaIotCoreStatusUpdate<T> = { export type DeviceRegistration = { productId: string; deviceId: string; - onStatus: (status: TuyaStatus, changedStatusCodes: string[]) => Promise<void>; + onStatus: (source: TuyaStatusSource, status: TuyaStatus, changedStatusCodes: string[]) => Promise<void>; }; export type SettingsEvent<T extends { [key: string]: unknown }> = { From 82794d7bcf26619f4664159ddfd970ce54369058 Mon Sep 17 00:00:00 2001 From: bobvandevijver <bobvandevijver@users.noreply.github.com> Date: Wed, 18 Sep 2024 14:40:54 +0000 Subject: [PATCH 4/5] Update Homey App Version to v1.3.5 --- .homeychangelog.json | 3 +++ .homeycompose/app.json | 2 +- app.json | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.homeychangelog.json b/.homeychangelog.json index 8169af5..03e4574 100644 --- a/.homeychangelog.json +++ b/.homeychangelog.json @@ -70,5 +70,8 @@ }, "1.3.4": { "en": "Optimise pairing process" + }, + "1.3.5": { + "en": "Improve webhook data handling" } } diff --git a/.homeycompose/app.json b/.homeycompose/app.json index 679b254..f4f727e 100644 --- a/.homeycompose/app.json +++ b/.homeycompose/app.json @@ -1,6 +1,6 @@ { "id": "com.tuya", - "version": "1.3.4", + "version": "1.3.5", "compatibility": ">=12.0.1", "brandColor": "#FF4800", "sdk": 3, diff --git a/app.json b/app.json index e10aae7..afa143b 100644 --- a/app.json +++ b/app.json @@ -1,7 +1,7 @@ { "_comment": "This file is generated. Please edit .homeycompose/app.json instead.", "id": "com.tuya", - "version": "1.3.4", + "version": "1.3.5", "compatibility": ">=12.0.1", "brandColor": "#FF4800", "sdk": 3, From fabfe745c53bf73e7b1c6ce9f12056d614a5670c Mon Sep 17 00:00:00 2001 From: Bob van de Vijver <bob@drenso.nl> Date: Mon, 23 Sep 2024 11:54:57 +0200 Subject: [PATCH 5/5] Add missing async modifier --- lib/TuyaOAuth2Client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/TuyaOAuth2Client.ts b/lib/TuyaOAuth2Client.ts index 16afc4f..d755a3f 100644 --- a/lib/TuyaOAuth2Client.ts +++ b/lib/TuyaOAuth2Client.ts @@ -198,7 +198,7 @@ export default class TuyaOAuth2Client extends OAuth2Client<TuyaOAuth2Token> { return this._get(`/v1.0/users/${this.getToken().uid}/devices`); } - getDevice({ deviceId }: { deviceId: string }): Promise<TuyaDeviceResponse> { + async getDevice({ deviceId }: { deviceId: string }): Promise<TuyaDeviceResponse> { // https://developer.tuya.com/en/docs/cloud/device-management?id=K9g6rfntdz78a return this._get(`/v1.0/devices/${deviceId}`); }