Knowledge prerequisites

Building target

Use panel miniapp to build a tap-to-run and automated wireless switch in CodeLab to implement the following capabilities through scene linkage:

Learning objectives

Environment preparations

For more information, see Panel MiniApp > Set up environment.

Product name: Wireless switch

Requirement prototype

Features

The data points required by the wireless switch are listed in the table below. The data points of the scene switch can be added or deleted as needed based on the actual device, with the format of switch_type_${btnId}.

DP ID

DP name

Identifier

Data transmission type

Data type

DP property

1

Button 1

switch_type_1

Report only (read-only)

enum

Enum values: single_click, double_click, long_press

2

Button 2

switch_type_2

Report only (read-only)

enum

Enum values: single_click, double_click, long_press

3

Button 3

switch_type_3

Report only (read-only)

enum

Enum values: single_click, double_click, long_press

4

Button 4

switch_type_4

Report only (read-only)

enum

Enum values: single_click, double_click, long_press

Create a wireless switch, define the data points, and configure the data points in the panel.

Log in to Tuya Developer Platform and create a wireless switch.

  1. Go to Product > Development and click Create on the Product Development page.
  2. On the Standard Category tab, select Electrical, and select Wireless Switch. Select the smart mode and solution and complete the product information.
  3. Click Create.
  4. In the Add Standard Function window, select the data points according to the actual scenario. For example, select four switch data points with the identifier ranging from switch_type_1 to switch_type_4, and a wireless switch with four buttons is created, as shown in the following figure.
  5. Click OK to complete the data point selection.

Create panel miniapp on Tuya MiniApp Developer Platform

Log in to Tuya MiniApp Developer Platform to create a panel miniapp.

For more information, see Panel MiniApp > Create panel miniapp.

Create project based on template in IDE

Open IDE and create a panel miniapp based on the wireless switch tap-to-run template in Tuya MiniApp Tools.

For more information, see Panel MiniApp > Initialize project.

  1. Dynamically render the switch buttons based on the data point definition. Get the device DP list based on the type definition of SmartDeviceSchema of SDM and get the button list based on the naming conventions of the data points of switch_type_${no}.
    import { SmartDeviceSchema } from 'typings/sdm';
    
    export const getSceneDps = (schema: SmartDeviceSchema) => {
       if (!Array.isArray(schema)) return [];
       return schema.map(s => s.code.match(/switch_type_(\d+)/)?.[1]);
    };
    
  2. Dynamically render the switch buttons based on the button list got through the getSceneDps function.
    import React from 'react';
    import { Image, ScrollView, View } from '@ray-js/ray';
    import { useCreation } from 'ahooks';
    import { useSelector } from 'react-redux';
    import { DpSchema, useDevice } from '@ray-js/panel-sdk';
    import { TopBar } from '@/components';
    import { selectBindTapToRunRules } from '@/redux/modules/sceneSlice';
    import { getSceneDps } from '@/utils/getSceneDps';
    import { Scene } from './components/scene';
    import styles from './index.module.less';
    
    export function Home() {
       const schema = useDevice(d => d.devInfo.schema);
       const dpSchema = useDevice(d => d.dpSchema);
       const devInfo = useDevice(d => d.devInfo);
       const bindTapToRunRules = useSelector(selectBindTapToRunRules);
    
       const sceneDpList = useCreation(() => {
           return getSceneDps(schema).map(btnId => {
           // Some logical codes that can be ignored temporarily.
           });
       }, [schema, bindTapToRunRules]);
    
       return (
           <View className={styles.view}>
           <TopBar />
           <View className={styles.content}>
               <View className={styles.main}>
               <View className={styles.logo}>
                   <Image src={devInfo.iconUrl} />
               </View>
               </View>
               <ScrollView
               style={{ maxHeight: '360px', height: 'auto' }}
               className={styles.card}
               refresherTriggered
               scrollY
               >
               <Scene sceneDpList={sceneDpList} />
               </ScrollView>
           </View>
           </View>
       );
    }
    
  1. Some data that the project depends on, such as bindTapToRunRules, is required during project initialization. As a result, the getBindTapToRunRules asynchronous action needs to be dispatched using Redux.
    import React, { Component } from 'react';
    import { Provider } from 'react-redux';
    import { hideLoading, showLoading } from '@ray-js/ray';
    import { devices, dpKit } from './devices';
    import store from './redux';
    import { getCurrentHomeInfoAsync, setIsInitialized } from './redux/modules/uiStateSlice';
    import { getBindTapToRunRules, getTapToRunRulesAsync } from './redux/modules/sceneSlice';
    import './styles/index.less';
    
    interface Props {
       devInfo: DevInfo;
       // eslint-disable-next-line react/require-default-props
       extraInfo?: Record<string, any>;
    }
    
    interface State {
       devInfo: DevInfo;
    }
    
    const composeLayout = (SubComp: React.ComponentType<any>) => {
       const { dispatch } = store;
       return class PanelComponent extends Component<Props, State> {
           async onLaunch(object: any) {
              devices.common.init();
              devices.common.onInitialized(device => {
                  dpKit.init(device);
                  // Block user actions before data is loaded.
                  showLoading({ title: '', mask: true });
                  Promise.all([dispatch(getBindTapToRunRules())]).finally(() => {
                  dispatch(setIsInitialized(true));
                  hideLoading();
                  });
                  dispatch(getTapToRunRulesAsync());
                  dispatch(getCurrentHomeInfoAsync());
              });
           }
    
           render() {
              const { extraInfo } = this.props;
              return (
                  <Provider store={store}>
                  <SubComp extraInfo={extraInfo} {...this.props} />
                  </Provider>
              );
           }
       };
    };
    
    export default composeLayout;
    
  2. The asynchronous action calls the getBindTapToRunRules API to get bindTapToRunRules and writes it to Redux Store.
    export const getBindTapToRunRules = createAsyncThunk<BindTapToRunRules>(
       'scene/getBindTapToRunRules',
       async (_, thunkApi) => {
           try {
           const data = await devices.common.model.abilities.tapToRun.getBindTapToRunRules();
           thunkApi.dispatch(sceneSlice.actions.initBindTapToRunRules(data));
           return data;
           } catch (error) {
           return [];
           }
       }
    );
    
    export const selectTapToRunRules = (state: ReduxState) => state.scene.tapToRunRules;
    
  3. Go back to the homepage and use the useSelector function to get the bindTapToRunRules data and run the logical codes ignored before for determination of the binding status.
    const sceneDpList = useCreation(() => {
       return getSceneDps(schema).map(btnId => {
          let bindDpId: number;
          let bindDpValue: string;
          const bindScene = bindTapToRunRules?.find(b => {
              const switchSceneSchema = dpSchema[`switch_type_${btnId}`] as DpSchema;
              bindDpId = +b?.associativeEntityId.split('#')?.[0]; // Data points bound with tap-to-run scenes.
              bindDpValue = b?.associativeEntityId.split('#')?.[1]; // Values of the data points bound with tap-to-run scenes.
              if (switchSceneSchema?.id === bindDpId) {
                 return true;
              }
              return false;
          });
          const bindRule = bindScene?.associativeEntityValueList?.[0];
          return {
              btnId,
              bindDpId,
              bindDpValue,
              bindScene,
              bindRule,
          };
       });
    }, [schema, bindTapToRunRules]);
    
  4. Use the parsed data, such as btnId, bindScene, and bindRule, to determine whether the current button is bound with a tap-to-run scene and display related information, such as the scene name.
    import React, { useState } from 'react';
    import { useRequest } from 'ahooks';
    import {
     Text,
     View,
     Image,
     router,
     showLoading,
     hideLoading,
     showToast,
     openEditScene,
    } from '@ray-js/ray';
    import { BindTapToRunRules } from '@ray-js/panel-sdk/lib/sdm/abilities/tapToRun/type';
    import { ActionSheet, Dialog, DialogInstance, SwipeCell } from '@ray-js/smart-ui';
    import Strings from '@/i18n';
    import { Empty } from '@/components';
    import { useAppDispatch } from '@/redux';
    import { devices } from '@/devices';
    import { unbindAsync, updateTapToRunRulesAsync } from '@/redux/modules/sceneSlice';
    import { mapObject2QueryString } from '@/utils/mapObject2QueryString';
    import { usePageReShow } from '@/hooks/usePageReShow';
    import styles from './index.module.less';
    
    interface Props {
     sceneDpList: Array<{
       btnId: string;
       bindDpId?: number;
       bindDpValue?: string;
       bindScene?: BindTapToRunRules[number];
       bindRule?: BindTapToRunRules[number]['associativeEntityValueList'][0];
     }>;
    }
    
    enum SwitchType {
     SingleClick = 'single_click',
     DoubleClick = 'double_click',
     LongPress = 'long_press',
    }
    
    const actions = Object.values(SwitchType).map(type => ({
     id: type,
     name: Strings.getLang(`sceneSwitchType_${type}`),
    }));
    
    export const Scene: React.FC<Props> = ({ sceneDpList }) => {
     const dispatch = useAppDispatch();
     const [showSwitchTypeDpId, setShowSwitchTypeDpId] = useState('');
    
     // Refresh the data source after creating or editing the tap-to-run scene.
     usePageReShow(() => {
       dispatch(updateTapToRunRulesAsync());
     });
    
     const hideActionSheet = React.useCallback(() => {
       setShowSwitchTypeDpId('');
     }, []);
    
     const { run: runTriggerRule } = useRequest(
       (ruleId: string) => devices.common.model.abilities.tapToRun.trigger({ ruleId }),
       {
         manual: true,
         loadingDelay: 1000,
         onBefore: () => showLoading({ title: '', mask: true }),
         onSuccess: (_, [ruleId]) => {
           const scene = sceneDpList.find(d => d?.bindRule?.triggerRuleId === ruleId);
           hideLoading();
           return showToast({
             title: Strings.formatValue('sceneTriggerSuccess', scene.bindRule.name),
           });
         },
         onError: (_, [ruleId]) => {
           const scene = sceneDpList.find(d => d?.bindRule?.triggerRuleId === ruleId);
           hideLoading();
           return showToast({
             title: Strings.formatValue('sceneTriggerFailed', scene.bindRule.name),
             icon: 'error',
           });
         },
       }
     );
    
     const handleSelectSwitchType = React.useCallback(
       data => {
         const switchType = data?.detail?.id as string;
         const path = mapObject2QueryString('/scene-select', {
           dpId: showSwitchTypeDpId,
           dpValue: switchType,
         });
         hideActionSheet();
         return router.push(path);
       },
       [showSwitchTypeDpId]
     );
    
     const handleClose = React.useCallback((data: typeof sceneDpList[number]) => {
       return event => {
         const { position, instance } = event.detail;
         switch (position) {
           case 'cell':
             instance.close();
             break;
           case 'right':
             DialogInstance.confirm({
               message: Strings.getLang('unbindTip'),
               cancelButtonText: Strings.getLang('cancel'),
               confirmButtonText: Strings.getLang('confirm'),
             })
               .then(() => {
                 const bindId = `${data?.bindScene?.bindId}`;
                 dispatch(unbindAsync({ bindId })).then(result => {
                   instance.close();
                   if (result.meta.requestStatus === 'rejected') {
                     showToast({
                       title: Strings.getLang('requestFailed'),
                       icon: 'error',
                     });
                   }
                 });
               })
               .catch(() => instance.close());
             break;
           default:
         }
       };
     }, []);
    
     const handleSceneItemClick = React.useCallback((data: typeof sceneDpList[number]) => {
       return () => {
         const { btnId, bindRule } = data;
         if (!bindRule) {
           setShowSwitchTypeDpId(`${btnId}`);
           return;
         }
         DialogInstance.confirm({
           message: Strings.getLang('triggerTip'),
           cancelButtonText: Strings.getLang('cancel'),
           confirmButtonText: Strings.getLang('confirm'),
         })
           .then(() => {
             const triggerRuleId = data?.bindRule?.triggerRuleId;
             runTriggerRule(`${triggerRuleId}`);
           })
           .catch(() => true);
       };
     }, []);
    
     const handleClickSetting = React.useCallback((data: typeof sceneDpList[number]) => {
       return (evt => {
         evt?.origin?.stopPropagation();
         const sceneId = data?.bindRule?.id;
         if (data?.bindRule?.id) {
           openEditScene({ sceneId });
         }
       }) as React.ComponentProps<typeof View>['onClick'];
     }, []);
    
     return (
       <View>
         {sceneDpList.length === 0 ? (
           <Empty title={Strings.getLang('sceneEmptyTip')} />
         ) : (
           <View>
             {sceneDpList.map((d, idx) => {
               const { bindRule, bindDpValue } = d;
               const itemText = bindRule?.name
                 ? `${Strings.getLang(`sceneSwitchType_${bindDpValue}` as any)}: ${bindRule?.name}`
                 : Strings.getLang('defaultName');
               return (
                 <SwipeCell
                   key={idx}
                   asyncClose
                   rightWidth={65}
                   disabled={!bindRule} // Unbinding by swiping is not allowed for buttons with no binding rules.
                   slot={{
                     right: <View className={styles.right}>{Strings.getLang('unbind')}</View>,
                   }}
                   onClose={handleClose(d)}
                 >
                   <View className={styles.item} onClick={handleSceneItemClick(d)}>
                     <View className={styles.content}>
                       <View className={styles.textWrapper}>
                         <Text className={styles.textNo}>{idx + 1}</Text>
                         <Text className={styles.textScene}>{itemText}</Text>
                       </View>
                     </View>
                     {bindRule && (
                       <View onClick={handleClickSetting(d)}>
                         <Image className={styles.imageIcon} src="/images/icon_triangle.png" />
                       </View>
                     )}
                   </View>
                 </SwipeCell>
               );
             })}
           </View>
         )}
         <Dialog id="smart-dialog" />
         <ActionSheet
           description={Strings.getLang('sceneSwitchTypeSelect')}
           cancelText={Strings.getLang('cancel')}
           show={!!showSwitchTypeDpId}
           actions={actions}
           onSelect={handleSelectSwitchType}
           onClose={hideActionSheet}
           onCancel={hideActionSheet}
         />
       </View>
     );
    };
    
  1. Some data of the home, such as tapToRunRules, is required during project initialization. As a result, the getTapToRunsAsync asynchronous action needs to be dispatched using Redux.
    import React, { Component } from 'react';
    import { Provider } from 'react-redux';
    import { hideLoading, showLoading } from '@ray-js/ray';
    import { devices, dpKit } from './devices';
    import store from './redux';
    import { getCurrentHomeInfoAsync, setIsInitialized } from './redux/modules/uiStateSlice';
    import { getBindTapToRunRules, getTapToRunRulesAsync } from './redux/modules/sceneSlice';
    import './styles/index.less';
    
    interface Props {
     devInfo: DevInfo;
     // eslint-disable-next-line react/require-default-props
     extraInfo?: Record<string, any>;
    }
    
    interface State {
     devInfo: DevInfo;
    }
    
    const composeLayout = (SubComp: React.ComponentType<any>) => {
     const { dispatch } = store;
     return class PanelComponent extends Component<Props, State> {
       async onLaunch(object: any) {
         devices.common.init();
         devices.common.onInitialized(device => {
           dpKit.init(device);
           // Block user actions before data is loaded.
           showLoading({ title: '', mask: true });
           Promise.all([dispatch(getBindTapToRunRules())]).finally(() => {
             dispatch(setIsInitialized(true));
             hideLoading();
           });
           dispatch(getTapToRunRulesAsync());
           dispatch(getCurrentHomeInfoAsync());
         });
       }
    
       render() {
         const { extraInfo } = this.props;
         return (
           <Provider store={store}>
             <SubComp extraInfo={extraInfo} {...this.props} />
           </Provider>
         );
       }
     };
    };
    
    export default composeLayout;
    
  2. On the Linkage Selection page, dynamically render the tap-to-run scenes based on the tapToRunRules data, as shown in the following figure.
  3. Select the tap-to-run scenes to be bound and tap Save. Dispatch the bindAsync asynchronous action to bind the current button with the tap-to-run scene and refresh the tap-to-run scene list. The dpId and dpValue parameters represent the button DP ID and button DP value, respectively. For example, if you double-tap on button 1 to enter the linkage selection page, the passed in DP ID is 1, and the passed in DP value is double_click. In this case, when button 1 reports double_click, the bound tap-to-run scene is automatically triggered.
    import React from 'react';
    import { Image, View, openCreateTapToRunScene, openEditScene, router, useQuery } from '@ray-js/ray';
    import clsx from 'clsx';
    import { useSelector } from 'react-redux';
    import { TopBar, Empty, FixedBottom, RuleItem } from '@/components';
    import Strings from '@/i18n';
    import { getCachedSystemInfo } from '@/api/getCachedSystemInfo';
    import { useHideMenuButton } from '@/hooks/useHideMenuButton';
    import { useAppDispatch } from '@/redux';
    import { TopBarHeight } from '@/components/top-bar';
    import { usePageReShow } from '@/hooks/usePageReShow';
    import {
     bindAsync,
     selectTapToRunRules,
     updateTapToRunRulesAsync,
    } from '@/redux/modules/sceneSlice';
    import styles from './index.module.less';
    import { SceneSelectQuery } from './index.type';
    
    const FooterHeight = 88;
    const { screenHeight } = getCachedSystemInfo();
    const contentHeight = `${screenHeight - (TopBarHeight + FooterHeight)}px`;
    
    export function SceneSelect() {
     // Refresh the data source after creating or editing the tap-to-run scene.
     usePageReShow(() => {
       dispatch(updateTapToRunRulesAsync());
     });
     useHideMenuButton();
     const dispatch = useAppDispatch();
     const query: SceneSelectQuery = useQuery();
     const tapToRunRules = useSelector(selectTapToRunRules);
     const [fixedBottomHeight, setFixedBottomHeight] = React.useState('180rpx'); // iPhone 6 base width is used by default.
     const [activeIdx, setActiveIdx] = React.useState(-1);
     const isEmpty = tapToRunRules.length === 0;
    
     const handleItemClick = React.useCallback((idx: number) => {
       return () => setActiveIdx(idx);
     }, []);
    
     const handleSave = React.useCallback(() => {
       const data = tapToRunRules?.[activeIdx];
       dispatch(bindAsync({ dpId: query.dpId, dpValue: query.dpValue, ruleId: data.id }));
       router.back();
     }, [tapToRunRules, activeIdx]);
    
     const handleAddClick = React.useCallback(() => {
       const rule = tapToRunRules[activeIdx];
       if (!rule) {
         // Smart Life app 5.13.0 and later versions are supported.
         openCreateTapToRunScene().catch(err => console.warn('openCreateTapToRunScene failed', err));
         return;
       }
       openEditScene({ sceneId: rule.id });
     }, [tapToRunRules, activeIdx]);
    
     return (
       <View className={styles.view}>
         <TopBar title={Strings.getLang('sceneSelect')} isSubPage />
         <View className={styles.content} style={{ marginBottom: fixedBottomHeight }}>
           {isEmpty ? (
             <Empty
               style={{ height: contentHeight, marginTop: 0 }}
               title={Strings.getLang('sceneSelectEmptyTip')}
             />
           ) : (
             <View className={styles.list}>
               {tapToRunRules?.map((data, idx) => {
                 const bindDeviceNums = data?.actions?.length ?? 0;
                 const isActive = activeIdx === idx;
                 return (
                   <RuleItem
                     key={idx}
                     isActive={isActive}
                     title={data.name}
                     subTitle={Strings.formatValue(
                       bindDeviceNums > 1 ? 'sceneBindDeviceNums' : 'sceneBindDeviceNum',
                       bindDeviceNums
                     )}
                     onClick={handleItemClick(idx)}
                   />
                 );
               })}
             </View>
           )}
           <FixedBottom
             className={styles.footer}
             contentHeight="100rpx"
             getFixedBottomHeight={setFixedBottomHeight}
           >
             <View
               className={clsx(
                 styles.button,
                 (isEmpty || activeIdx === -1) && styles.button__disabled
               )}
               onClick={handleSave}
             >
               {Strings.getLang('save')}
             </View>
             <Image className={styles.imageAdd} src="/images/icon_add.png" onClick={handleAddClick} />
           </FixedBottom>
         </View>
       </View>
     );
    }
    
    export const bindAsync = createAsyncThunk<boolean, BindParams>(
     'scene/bindAsync',
     async (params, thunkApi) => {
       await devices.common.model.abilities.tapToRun.bind({
         dpId: params.dpId,
         dpValue: params.dpValue,
         ruleId: params.ruleId,
       });
       thunkApi.dispatch(updateBindTapToRunRulesAsync());
       return true;
     }
    );
    
  4. After binding, you will be redirected to the homepage, and the list of bound tap-to-run scenes will be refreshed.
    // Refresh the data source after creating or editing the tap-to-run scene.
    usePageReShow(() => {
      dispatch(updateTapToRunRulesAsync());
    });
    
      import { usePageEvent } from '@ray-js/ray';
      import { useRef } from 'react';
    
      /**
       * The action is triggered only when the onShow event occurs for the second time to prevent situations that do not require list refresh for the first occurrence of the onShow event, such as page return from the app.
       */
      export function usePageReShow(fn: (...arg: any) => any) {
        const isMounted = useRef(false);
        usePageEvent('onShow', () => {
          if (isMounted.current) fn();
          isMounted.current = true;
        });
      }
    
  1. A second confirmation dialog box for manual execution will pop up after you complete tap-to-run scene binding, go back to the homepage, and tap on the button bound with a tap-to-run scene, as shown in the following figure.
  2. After you tap OK, you can call the trigger API of SmartTapToRunAbility to trigger the tap-to-run scene. The codes are as follows:
    const { run: runTriggerRule } = useRequest(
     (ruleId: string) => devices.common.model.abilities.tapToRun.trigger({ ruleId }),
     {
       manual: true,
       loadingDelay: 1000,
       onBefore: () => showLoading({ title: '', mask: true }),
       onSuccess: (_, [ruleId]) => {
         const scene = sceneDpList.find(d => d?.bindRule?.triggerRuleId === ruleId);
         hideLoading();
         return showToast({
           title: Strings.formatValue('sceneTriggerSuccess', scene.bindRule.name),
         });
       },
       onError: (_, [ruleId]) => {
         const scene = sceneDpList.find(d => d?.bindRule?.triggerRuleId === ruleId);
         hideLoading();
         return showToast({
           title: Strings.formatValue('sceneTriggerFailed', scene.bindRule.name),
           icon: 'error',
         });
       },
     }
    );
    
    const handleSceneItemClick = React.useCallback((data: typeof sceneDpList[number]) => {
     return () => {
       const { btnId, bindRule } = data;
       if (!bindRule) {
         setShowSwitchTypeDpId(`${btnId}`);
         return;
       }
       DialogInstance.confirm({
         message: Strings.getLang('triggerTip'),
         cancelButtonText: Strings.getLang('cancel'),
         confirmButtonText: Strings.getLang('confirm'),
       })
         .then(() => {
           const triggerRuleId = data?.bindRule?.triggerRuleId;
           runTriggerRule(`${triggerRuleId}`);
         })
         .catch(() => true);
     };
    }, []);
    
  3. If the passed in triggerRuleId is correct, the corresponding tap-to-run rule is valid, and the device is running properly, the tap-to-run scene will be triggered. Otherwise, a prompt indicating trigger failure will pop up.

As described in the Bind button with tap-to-run scene section, you can trigger the tap-to-run scene by double tapping on physical button 1 for it to report double_click. Note that the tap-to-run feature can be used when the network connection is available.

If no physical device is available, you can also use the device debugging function of Tuya Developer Platform to simulate the report operation to verify whether the developed functions are normal, as shown in the following figure.