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}`);
   }