From 7657ee8c885cefc53d8110155a2c7f10f754f97f Mon Sep 17 00:00:00 2001
From: Joost Loohuis <github@loohuis.dev>
Date: Thu, 22 Aug 2024 13:37:03 +0200
Subject: [PATCH 1/2] Add datapoints to send_command flows

---
 app.ts                  | 78 ++++++++++++++++++++++++++++++++++-------
 lib/TuyaOAuth2Client.ts | 11 ++++++
 lib/TuyaOAuth2Device.ts | 12 ++++++-
 3 files changed, 87 insertions(+), 14 deletions(-)

diff --git a/app.ts b/app.ts
index 13b0fbe4..356c9fc7 100644
--- a/app.ts
+++ b/app.ts
@@ -5,7 +5,7 @@ import * as TuyaOAuth2Util from './lib/TuyaOAuth2Util';
 
 import TuyaOAuth2Device from './lib/TuyaOAuth2Device';
 import sourceMapSupport from 'source-map-support';
-import { type TuyaScene } from './types/TuyaApiTypes';
+import type { TuyaDeviceDataPoint, TuyaScene, TuyaStatusResponse } from './types/TuyaApiTypes';
 import { type ArgumentAutocompleteResults } from 'homey/lib/FlowCard';
 
 sourceMapSupport.install();
@@ -18,6 +18,13 @@ type StatusCodeArgs = { code: { id: string } };
 type StatusCodeState = { code: string };
 type HomeyTuyaScene = Pick<TuyaScene, 'id' | 'name'>;
 
+type SendArg = {
+  name: string;
+  id: string;
+  title: string;
+  dataPoint: boolean;
+};
+
 module.exports = class TuyaOAuth2App extends OAuth2App {
   static OAUTH2_CLIENT = TuyaOAuth2Client;
   static OAUTH2_DEBUG = process.env.DEBUG === '1';
@@ -34,14 +41,59 @@ module.exports = class TuyaOAuth2App extends OAuth2App {
       value,
     }: {
       device: TuyaOAuth2Device;
-      code: string | { id: string };
+      code: SendArg;
       value: unknown;
     }): Promise<void> => {
-      if (typeof code === 'object') code = code.id;
-      await device.sendCommand({ code, value });
+      if (code.dataPoint) {
+        await device.setDataPoint(code.id, value);
+      } else {
+        await device.sendCommand({ code: code.id, value });
+      }
+    };
+
+    const sendingAutocompleteListener = async (
+      query: string,
+      args: DeviceArgs,
+      filter: ({ value }: { value: unknown }) => boolean,
+    ): Promise<ArgumentAutocompleteResults> => {
+      function convert(
+        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,
+          }));
+      }
+
+      const status = await args.device.getStatus();
+      const statusOptions = convert(status, false);
+
+      const dataPoints = await args.device.queryDataPoints();
+      const dataPointOptions = convert(dataPoints.properties, true);
+
+      // Remove duplicates, preferring status options
+      const combinedMap: Record<string, ArgumentAutocompleteResults[number]> = {};
+
+      for (const dataPointOption of dataPointOptions) {
+        combinedMap[dataPointOption.name] = dataPointOption;
+      }
+
+      for (const statusOption of statusOptions) {
+        combinedMap[statusOption.name] = statusOption;
+      }
+
+      return Object.values(combinedMap);
     };
 
-    const generalControlAutocompleteListener = async (
+    const receivingAutocompleteListener = async (
       query: string,
       args: DeviceArgs,
       filter: ({ value }: { value: unknown }) => boolean,
@@ -65,7 +117,7 @@ module.exports = class TuyaOAuth2App extends OAuth2App {
       .getActionCard('send_command_string')
       .registerRunListener(sendCommandRunListener)
       .registerArgumentAutocompleteListener('code', async (query: string, args: DeviceArgs) =>
-        generalControlAutocompleteListener(
+        sendingAutocompleteListener(
           query,
           args,
           ({ value }) => typeof value === 'string' && !TuyaOAuth2Util.hasJsonStructure(value),
@@ -76,14 +128,14 @@ module.exports = class TuyaOAuth2App extends OAuth2App {
       .getActionCard('send_command_number')
       .registerRunListener(sendCommandRunListener)
       .registerArgumentAutocompleteListener('code', async (query: string, args: DeviceArgs) =>
-        generalControlAutocompleteListener(query, args, ({ value }) => typeof value === 'number'),
+        sendingAutocompleteListener(query, args, ({ value }) => typeof value === 'number'),
       );
 
     this.homey.flow
       .getActionCard('send_command_boolean')
       .registerRunListener(sendCommandRunListener)
       .registerArgumentAutocompleteListener('code', async (query: string, args: DeviceArgs) =>
-        generalControlAutocompleteListener(query, args, ({ value }) => typeof value === 'boolean'),
+        sendingAutocompleteListener(query, args, ({ value }) => typeof value === 'boolean'),
       );
 
     this.homey.flow
@@ -99,7 +151,7 @@ module.exports = class TuyaOAuth2App extends OAuth2App {
         },
       )
       .registerArgumentAutocompleteListener('code', async (query: string, args: DeviceArgs) =>
-        generalControlAutocompleteListener(
+        sendingAutocompleteListener(
           query,
           args,
           ({ value }) => typeof value === 'object' || TuyaOAuth2Util.hasJsonStructure(value),
@@ -111,14 +163,14 @@ module.exports = class TuyaOAuth2App extends OAuth2App {
       .getDeviceTriggerCard('receive_status_boolean')
       .registerRunListener((args: StatusCodeArgs, state: StatusCodeState) => args.code.id === state.code)
       .registerArgumentAutocompleteListener('code', async (query: string, args: DeviceArgs) =>
-        generalControlAutocompleteListener(query, args, ({ value }) => typeof value === 'boolean'),
+        receivingAutocompleteListener(query, args, ({ value }) => typeof value === 'boolean'),
       );
 
     this.homey.flow
       .getDeviceTriggerCard('receive_status_json')
       .registerRunListener((args: StatusCodeArgs, state: StatusCodeState) => args.code.id === state.code)
       .registerArgumentAutocompleteListener('code', async (query: string, args: DeviceArgs) =>
-        generalControlAutocompleteListener(
+        receivingAutocompleteListener(
           query,
           args,
           ({ value }) => typeof value === 'object' || TuyaOAuth2Util.hasJsonStructure(value),
@@ -129,14 +181,14 @@ module.exports = class TuyaOAuth2App extends OAuth2App {
       .getDeviceTriggerCard('receive_status_number')
       .registerRunListener((args: StatusCodeArgs, state: StatusCodeState) => args.code.id === state.code)
       .registerArgumentAutocompleteListener('code', async (query: string, args: DeviceArgs) =>
-        generalControlAutocompleteListener(query, args, ({ value }) => typeof value === 'number'),
+        receivingAutocompleteListener(query, args, ({ value }) => typeof value === 'number'),
       );
 
     this.homey.flow
       .getDeviceTriggerCard('receive_status_string')
       .registerRunListener((args: StatusCodeArgs, state: StatusCodeState) => args.code.id === state.code)
       .registerArgumentAutocompleteListener('code', async (query: string, args: DeviceArgs) =>
-        generalControlAutocompleteListener(
+        receivingAutocompleteListener(
           query,
           args,
           ({ value }) => typeof value === 'string' && !TuyaOAuth2Util.hasJsonStructure(value),
diff --git a/lib/TuyaOAuth2Client.ts b/lib/TuyaOAuth2Client.ts
index cb7d0405..496ec881 100644
--- a/lib/TuyaOAuth2Client.ts
+++ b/lib/TuyaOAuth2Client.ts
@@ -226,9 +226,20 @@ export default class TuyaOAuth2Client extends OAuth2Client<TuyaOAuth2Token> {
   }
 
   async queryDataPoints(deviceId: string): Promise<TuyaDeviceDataPointResponse> {
+    // https://developer.tuya.com/en/docs/cloud/116cc8bf6f?id=Kcp2kwfrpe719
     return this._get(`/v2.0/cloud/thing/${deviceId}/shadow/properties`);
   }
 
+  async setDataPoint(deviceId: string, dataPointId: string, value: unknown): Promise<void> {
+    // https://developer.tuya.com/en/docs/cloud/c057ad5cfd?id=Kcp2kxdzftp91
+    const payload = {
+      properties: JSON.stringify({
+        [dataPointId]: value,
+      }),
+    };
+    return this._post(`/v2.0/cloud/thing/${deviceId}/shadow/properties/issue`, payload);
+  }
+
   async getWebRTCConfiguration({ deviceId }: { deviceId: string }): Promise<TuyaWebRTC> {
     // https://developer.tuya.com/en/docs/cloud/96c3154b0d?id=Kam7q5rz91dml
     return this._get(`/v1.0/devices/${deviceId}/webrtc-configs`);
diff --git a/lib/TuyaOAuth2Device.ts b/lib/TuyaOAuth2Device.ts
index 28079f70..f29653d9 100644
--- a/lib/TuyaOAuth2Device.ts
+++ b/lib/TuyaOAuth2Device.ts
@@ -1,5 +1,5 @@
 import { OAuth2Device } from 'homey-oauth2app';
-import { TuyaCommand, TuyaStatusResponse, TuyaWebRTC } from '../types/TuyaApiTypes';
+import { TuyaCommand, TuyaDeviceDataPointResponse, TuyaStatusResponse, TuyaWebRTC } from '../types/TuyaApiTypes';
 
 import { TuyaStatus, TuyaStatusUpdate } from '../types/TuyaTypes';
 import TuyaOAuth2Client from './TuyaOAuth2Client';
@@ -212,6 +212,16 @@ export default class TuyaOAuth2Device extends OAuth2Device<TuyaOAuth2Client> {
     });
   }
 
+  async queryDataPoints(): Promise<TuyaDeviceDataPointResponse> {
+    const { deviceId } = this.data;
+    return this.oAuth2Client.queryDataPoints(deviceId);
+  }
+
+  setDataPoint(dataPointId: string, value: unknown): Promise<void> {
+    const { deviceId } = this.data;
+    return this.oAuth2Client.setDataPoint(deviceId, dataPointId, value);
+  }
+
   async getWebRTC(): Promise<TuyaWebRTC> {
     const { deviceId } = this.data;
     return this.oAuth2Client.getWebRTCConfiguration({ deviceId });

From 9a50ff962a6360059ee1de1e259c9833eaa8483a Mon Sep 17 00:00:00 2001
From: Joost Loohuis <github@loohuis.dev>
Date: Thu, 22 Aug 2024 15:31:03 +0200
Subject: [PATCH 2/2] Add datapoints to receive_status flows

---
 .../flow/triggers/receive_status_boolean.json |  2 +-
 .../flow/triggers/receive_status_json.json    |  2 +-
 .../flow/triggers/receive_status_number.json  |  2 +-
 .../flow/triggers/receive_status_string.json  |  2 +-
 app.json                                      |  8 ++--
 app.ts                                        | 42 ++++++-------------
 6 files changed, 20 insertions(+), 38 deletions(-)

diff --git a/.homeycompose/flow/triggers/receive_status_boolean.json b/.homeycompose/flow/triggers/receive_status_boolean.json
index 718b8d8a..7d0007e4 100644
--- a/.homeycompose/flow/triggers/receive_status_boolean.json
+++ b/.homeycompose/flow/triggers/receive_status_boolean.json
@@ -6,7 +6,7 @@
     "en": "Status [[code]] gets a new Boolean value"
   },
   "hint": {
-    "en": "This is an advanced Flow card, that can be used to receive any status from a Tuya device. You can find the status codes in the Tuya API documentation at https://homey.link/tuya-docs."
+    "en": "This is an advanced Flow card, that can be used to receive any status from a Tuya device. You can find the status codes in the Tuya API documentation at https://homey.link/tuya-docs. Data points available outside of this specification may not actually be reported by the device."
   },
   "args": [
     {
diff --git a/.homeycompose/flow/triggers/receive_status_json.json b/.homeycompose/flow/triggers/receive_status_json.json
index a770fd1f..d8f7be25 100644
--- a/.homeycompose/flow/triggers/receive_status_json.json
+++ b/.homeycompose/flow/triggers/receive_status_json.json
@@ -6,7 +6,7 @@
     "en": "Status [[code]] gets a new JSON value"
   },
   "hint": {
-    "en": "This is an advanced Flow card, that can be used to receive any status from a Tuya device. You can find the status codes in the Tuya API documentation at https://homey.link/tuya-docs."
+    "en": "This is an advanced Flow card, that can be used to receive any status from a Tuya device. You can find the status codes in the Tuya API documentation at https://homey.link/tuya-docs. Data points available outside of this specification may not actually be reported by the device."
   },
   "args": [
     {
diff --git a/.homeycompose/flow/triggers/receive_status_number.json b/.homeycompose/flow/triggers/receive_status_number.json
index 12bc56af..01714a69 100644
--- a/.homeycompose/flow/triggers/receive_status_number.json
+++ b/.homeycompose/flow/triggers/receive_status_number.json
@@ -6,7 +6,7 @@
     "en": "Status [[code]] gets a new Number value"
   },
   "hint": {
-    "en": "This is an advanced Flow card, that can be used to receive any status from a Tuya device. You can find the status codes in the Tuya API documentation at https://homey.link/tuya-docs."
+    "en": "This is an advanced Flow card, that can be used to receive any status from a Tuya device. You can find the status codes in the Tuya API documentation at https://homey.link/tuya-docs. Data points available outside of this specification may not actually be reported by the device."
   },
   "args": [
     {
diff --git a/.homeycompose/flow/triggers/receive_status_string.json b/.homeycompose/flow/triggers/receive_status_string.json
index de1f2996..b6865d44 100644
--- a/.homeycompose/flow/triggers/receive_status_string.json
+++ b/.homeycompose/flow/triggers/receive_status_string.json
@@ -6,7 +6,7 @@
     "en": "Status [[code]] gets a new String value"
   },
   "hint": {
-    "en": "This is an advanced Flow card, that can be used to receive any status from a Tuya device. You can find the status codes in the Tuya API documentation at https://homey.link/tuya-docs."
+    "en": "This is an advanced Flow card, that can be used to receive any status from a Tuya device. You can find the status codes in the Tuya API documentation at https://homey.link/tuya-docs. Data points available outside of this specification may not actually be reported by the device."
   },
   "args": [
     {
diff --git a/app.json b/app.json
index d6b86fe9..7ed73b5b 100644
--- a/app.json
+++ b/app.json
@@ -392,7 +392,7 @@
           "en": "Status [[code]] gets a new Boolean value"
         },
         "hint": {
-          "en": "This is an advanced Flow card, that can be used to receive any status from a Tuya device. You can find the status codes in the Tuya API documentation at https://homey.link/tuya-docs."
+          "en": "This is an advanced Flow card, that can be used to receive any status from a Tuya device. You can find the status codes in the Tuya API documentation at https://homey.link/tuya-docs. Data points available outside of this specification may not actually be reported by the device."
         },
         "args": [
           {
@@ -433,7 +433,7 @@
           "en": "Status [[code]] gets a new JSON value"
         },
         "hint": {
-          "en": "This is an advanced Flow card, that can be used to receive any status from a Tuya device. You can find the status codes in the Tuya API documentation at https://homey.link/tuya-docs."
+          "en": "This is an advanced Flow card, that can be used to receive any status from a Tuya device. You can find the status codes in the Tuya API documentation at https://homey.link/tuya-docs. Data points available outside of this specification may not actually be reported by the device."
         },
         "args": [
           {
@@ -474,7 +474,7 @@
           "en": "Status [[code]] gets a new Number value"
         },
         "hint": {
-          "en": "This is an advanced Flow card, that can be used to receive any status from a Tuya device. You can find the status codes in the Tuya API documentation at https://homey.link/tuya-docs."
+          "en": "This is an advanced Flow card, that can be used to receive any status from a Tuya device. You can find the status codes in the Tuya API documentation at https://homey.link/tuya-docs. Data points available outside of this specification may not actually be reported by the device."
         },
         "args": [
           {
@@ -515,7 +515,7 @@
           "en": "Status [[code]] gets a new String value"
         },
         "hint": {
-          "en": "This is an advanced Flow card, that can be used to receive any status from a Tuya device. You can find the status codes in the Tuya API documentation at https://homey.link/tuya-docs."
+          "en": "This is an advanced Flow card, that can be used to receive any status from a Tuya device. You can find the status codes in the Tuya API documentation at https://homey.link/tuya-docs. Data points available outside of this specification may not actually be reported by the device."
         },
         "args": [
           {
diff --git a/app.ts b/app.ts
index 356c9fc7..af0dc0c6 100644
--- a/app.ts
+++ b/app.ts
@@ -14,11 +14,11 @@ const CACHE_KEY = 'scenes';
 const CACHE_TTL = 30;
 
 type DeviceArgs = { device: TuyaOAuth2Device };
-type StatusCodeArgs = { code: { id: string } };
+type StatusCodeArgs = { code: AutoCompleteArg };
 type StatusCodeState = { code: string };
 type HomeyTuyaScene = Pick<TuyaScene, 'id' | 'name'>;
 
-type SendArg = {
+type AutoCompleteArg = {
   name: string;
   id: string;
   title: string;
@@ -41,7 +41,7 @@ module.exports = class TuyaOAuth2App extends OAuth2App {
       value,
     }: {
       device: TuyaOAuth2Device;
-      code: SendArg;
+      code: AutoCompleteArg;
       value: unknown;
     }): Promise<void> => {
       if (code.dataPoint) {
@@ -51,7 +51,7 @@ module.exports = class TuyaOAuth2App extends OAuth2App {
       }
     };
 
-    const sendingAutocompleteListener = async (
+    const autocompleteListener = async (
       query: string,
       args: DeviceArgs,
       filter: ({ value }: { value: unknown }) => boolean,
@@ -93,31 +93,13 @@ module.exports = class TuyaOAuth2App extends OAuth2App {
       return Object.values(combinedMap);
     };
 
-    const receivingAutocompleteListener = async (
-      query: string,
-      args: DeviceArgs,
-      filter: ({ value }: { value: unknown }) => boolean,
-    ): Promise<ArgumentAutocompleteResults> => {
-      const status = await args.device.getStatus();
-      return status
-        .filter(filter)
-        .filter(({ code }: { code: string }) => {
-          return code.toLowerCase().includes(query.toLowerCase());
-        })
-        .map(({ code }: { code: string }) => ({
-          name: code,
-          id: code,
-          title: code,
-        }));
-    };
-
     // Register Tuya Web API Flow Cards
     // Sending
     this.homey.flow
       .getActionCard('send_command_string')
       .registerRunListener(sendCommandRunListener)
       .registerArgumentAutocompleteListener('code', async (query: string, args: DeviceArgs) =>
-        sendingAutocompleteListener(
+        autocompleteListener(
           query,
           args,
           ({ value }) => typeof value === 'string' && !TuyaOAuth2Util.hasJsonStructure(value),
@@ -128,14 +110,14 @@ module.exports = class TuyaOAuth2App extends OAuth2App {
       .getActionCard('send_command_number')
       .registerRunListener(sendCommandRunListener)
       .registerArgumentAutocompleteListener('code', async (query: string, args: DeviceArgs) =>
-        sendingAutocompleteListener(query, args, ({ value }) => typeof value === 'number'),
+        autocompleteListener(query, args, ({ value }) => typeof value === 'number'),
       );
 
     this.homey.flow
       .getActionCard('send_command_boolean')
       .registerRunListener(sendCommandRunListener)
       .registerArgumentAutocompleteListener('code', async (query: string, args: DeviceArgs) =>
-        sendingAutocompleteListener(query, args, ({ value }) => typeof value === 'boolean'),
+        autocompleteListener(query, args, ({ value }) => typeof value === 'boolean'),
       );
 
     this.homey.flow
@@ -151,7 +133,7 @@ module.exports = class TuyaOAuth2App extends OAuth2App {
         },
       )
       .registerArgumentAutocompleteListener('code', async (query: string, args: DeviceArgs) =>
-        sendingAutocompleteListener(
+        autocompleteListener(
           query,
           args,
           ({ value }) => typeof value === 'object' || TuyaOAuth2Util.hasJsonStructure(value),
@@ -163,14 +145,14 @@ module.exports = class TuyaOAuth2App extends OAuth2App {
       .getDeviceTriggerCard('receive_status_boolean')
       .registerRunListener((args: StatusCodeArgs, state: StatusCodeState) => args.code.id === state.code)
       .registerArgumentAutocompleteListener('code', async (query: string, args: DeviceArgs) =>
-        receivingAutocompleteListener(query, args, ({ value }) => typeof value === 'boolean'),
+        autocompleteListener(query, args, ({ value }) => typeof value === 'boolean'),
       );
 
     this.homey.flow
       .getDeviceTriggerCard('receive_status_json')
       .registerRunListener((args: StatusCodeArgs, state: StatusCodeState) => args.code.id === state.code)
       .registerArgumentAutocompleteListener('code', async (query: string, args: DeviceArgs) =>
-        receivingAutocompleteListener(
+        autocompleteListener(
           query,
           args,
           ({ value }) => typeof value === 'object' || TuyaOAuth2Util.hasJsonStructure(value),
@@ -181,14 +163,14 @@ module.exports = class TuyaOAuth2App extends OAuth2App {
       .getDeviceTriggerCard('receive_status_number')
       .registerRunListener((args: StatusCodeArgs, state: StatusCodeState) => args.code.id === state.code)
       .registerArgumentAutocompleteListener('code', async (query: string, args: DeviceArgs) =>
-        receivingAutocompleteListener(query, args, ({ value }) => typeof value === 'number'),
+        autocompleteListener(query, args, ({ value }) => typeof value === 'number'),
       );
 
     this.homey.flow
       .getDeviceTriggerCard('receive_status_string')
       .registerRunListener((args: StatusCodeArgs, state: StatusCodeState) => args.code.id === state.code)
       .registerArgumentAutocompleteListener('code', async (query: string, args: DeviceArgs) =>
-        receivingAutocompleteListener(
+        autocompleteListener(
           query,
           args,
           ({ value }) => typeof value === 'string' && !TuyaOAuth2Util.hasJsonStructure(value),