Knowledge prerequisites

Building target

Use panel miniapp to build a Zigbee remote control group switch in CodeLab and connect it to the Zigbee gateway to control multiple bound devices in batches by pressing a physical switch button.

Learning objectives

Environment preparations

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

Product name: Zigbee remote control group switch

Requirement prototype

Features

The data points required by the Zigbee remote control group 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 an identifier ranging from switch_1 to switch_8. In addition, the enum values of scene_switch must correspond to the data points of the switch.

DP ID

DP name

Identifier

Data transmission type

Data type

DP property

24

Switch 1

switch_1

Send and report (read-write)

bool

-

25

Switch 2

switch_2

Send and report (read-write)

bool

-

26

Switch 3

switch_3

Send and report (read-write)

bool

-

27

Switch 4

switch_4

Send and report (read-write)

bool

-

112

Scene switch

scene_switch

Send and report (read-write)

enum

Enum values: switch_1, switch_2, switch_3, switch_4

Create a Zigbee remote control group switch, define data points, and configure the data points in the panel.

  1. Log in to Tuya Developer Platform, go to Product > Development, and then click Create on the Product Development page.
  2. On the Standard Category tab, select Electrical. In the Select a product step, select Scene Switch. Then, select a smart mode and a solution and complete the product information.
  3. Click Create.
  4. In the Add Standard Function window, keep the default settings and click OK.
  5. In the Advanced Functions section, select data points as needed based on the actual scenario. For example, you can select four switch data points with the identifier ranging from switch_1 to switch_4 and the scene_switch data point to create a remote control group switch with four buttons, as shown in the following figure.

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.

In this section, you will learn about the functional logic for dynamically rendering a list of switch buttons and binding these buttons to device status. This knowledge will facilitate the implementation of various functional interactions in your subsequent development, allowing you to tailor interactions to specific business scenarios using the sample code provided by Tuya.

Dynamically render switch buttons

  1. Get the device DP list based on the type definition of SmartDeviceSchema of SDM and get the button list based on the data points of scene_switch.
    import { SmartDeviceSchema } from 'typings/sdm';
    
    export const getSwitchDps = (schema: SmartDeviceSchema) => {
     if (!Array.isArray(schema)) return [];
     const sceneSwitchSchema = schema.find(s => s.code === 'scene_switch');
     // @ts-ignore
     const sceneSwitchRange = sceneSwitchSchema?.property?.range as string[];
     if (!sceneSwitchRange) return [];
     return sceneSwitchRange
       .filter(range => {
         const btnIdx = +range.match(/switch_(\d+)/)?.[1];
         return btnIdx >= 1 && btnIdx <= 8;
       })
       .map(range => {
         const btnIdx = +range.match(/switch_(\d+)/)?.[1];
         return btnIdx;
       });
    };
    
  2. Dynamically render the switch buttons based on the button list got through the getSwitchDps function.
    import React from 'react';
    import { Image, ScrollView, View } from '@ray-js/ray';
    import { TopBar } from '@/components';
    import { useCreation } from 'ahooks';
    import { useSelector } from 'react-redux';
    import { useDevice } from '@ray-js/panel-sdk';
    import { BindType } from '@/constant';
    import { selectGroupDevices } from '@/redux/modules/groupSlice';
    import { ResGetZigbeeLocalIds } from '@/api/getZigbeeLocalIds';
    import { GroupDevices } from '@/api/getGroupDevices';
    import { getSwitchDps } from '@/utils/getSwitchDps';
    import { Group } from './components/group';
    import styles from './index.module.less';
    
    export function Home() {
     const schema = useDevice(d => d.devInfo.schema);
     const groupDevices = useSelector(selectGroupDevices);
    
     const dataSource = useCreation(() => {
       return getSwitchDps(schema).map(btnId => {
         let bindType: BindType;
         const bindGroupInfo = localIds.find(localInfo => localInfo.code === `switch_${btnId}`);
         let bindGroupDevices: GroupDevices;
         const categoryCode = bindGroupInfo?.categoryCode;
         if (categoryCode && groupDevices[categoryCode]) {
           bindGroupDevices = groupDevices[categoryCode]?.filter(d => d.checked);
           if (bindGroupDevices?.[0]?.category === 'lamp') {
             bindType = BindType.GroupLight;
           } else if (bindGroupDevices?.[0]?.category === 'curtain') {
             bindType = BindType.GroupCurtain;
           }
         }
         return {
           btnId,
           bindType,
           bindGroupInfo,
           bindGroupDevices,
         };
       });
     }, [schema, groupDevices]);
    
     return (
       <View className={styles.view}>
         <TopBar />
         <View className={styles.content}>
           <View className={styles.main}>
             <View className={styles.logo}>
               <Image src="/images/logo.png" />
             </View>
           </View>
           <ScrollView
             style={{ maxHeight: '360px', height: 'auto' }}
             className={styles.card}
             refresherTriggered
             scrollY
           >
             <Group data={dataSource} />
           </ScrollView>
         </View>
       </View>
     );
    }
    
    export default Home;
    

Switch button binding status

  1. Some data that the project depends on, such as groupDevices, is required during project initialization. As a result, the getZigbeeLocalIdAsync 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 {
     getOssUrlAsync,
     getCurrentHomeInfoAsync,
     setIsInitialized,
    } from './redux/modules/uiStateSlice';
    import { getGroupIconsAsync, getZigbeeLocalIdAsync } from './redux/modules/groupSlice';
    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) {
         console.log('=== App onLaunch', object);
         devices.common.init();
         devices.common.onInitialized(device => dpKit.init(device));
         // Block user actions before the data that the project depends on is loaded.
         showLoading({ title: '', mask: true });
         Promise.all([dispatch(getOssUrlAsync()), dispatch(getZigbeeLocalIdAsync())]).finally(() => {
           dispatch(setIsInitialized(true));
           hideLoading();
         });
         dispatch(getGroupIconsAsync());
         dispatch(getCurrentHomeInfoAsync());
       }
    
       render() {
         const { extraInfo } = this.props;
    
         return (
           <Provider store={store}>
             <SubComp extraInfo={extraInfo} {...this.props} />
           </Provider>
         );
       }
     };
    };
    
    export default composeLayout;
    
  2. The asynchronous action gets the local device ID of the current device, calls the getGroupDevices API to get the groupDevices field of the current device, and writes it to Redux Store.
    export const getZigbeeLocalIdAsync = createAsyncThunk(
     'scene/getZigbeeLocalIdAsync',
     async (_, thunkApi) => {
       const res = await getZigbeeLocalIds();
       thunkApi.dispatch(groupSlice.actions.initLocalIds({ localIds: res.locals }));
       const groupDevices = await Promise.all(
         res.locals.map(local => getGroupDevices(local.categoryCode))
       );
       const groupDevicesMap = res.locals.reduce((acc, local, index) => {
         acc[local.categoryCode] = groupDevices[index];
         return acc;
       }, {});
       thunkApi.dispatch(groupSlice.actions.initGroupDevices({ groupDevices: groupDevicesMap }));
       return res;
     }
    );
    
    export const selectBindGroupDevices = (state: ReduxState, categoryCode: string) => {
     return state.group.groupDevices?.[categoryCode]?.filter(item => item.checked) ?? [];
    };
    
  3. Use the useSelector function to get the groupDevices field in Redux Store and determine whether the current button is bound with a device and the type of the bound device based on the bindType, bindGroupInfo, and bindGroupDevices fields. Pass the btnId field to the group settings page for button identification.
    const schema = useDevice(d => d.devInfo.schema);
    const groupDevices = useSelector(selectGroupDevices);
    
    const dataSource = useCreation(() => {
     return getSwitchDps(schema).map(btnId => {
       let bindType: BindType;
       const bindGroupInfo = localIds.find(localInfo => localInfo.code === `switch_${btnId}`);
       let bindGroupDevices: GroupDevices;
       const categoryCode = bindGroupInfo?.categoryCode;
       if (categoryCode && groupDevices[categoryCode]) {
         bindGroupDevices = groupDevices[categoryCode]?.filter(d => d.checked);
         if (bindGroupDevices?.[0]?.category === 'lamp') {
           bindType = BindType.GroupLight;
         } else if (bindGroupDevices?.[0]?.category === 'curtain') {
           bindType = BindType.GroupCurtain;
         }
       }
       return {
         btnId,
         bindType,
         bindGroupInfo,
         bindGroupDevices,
       };
     });
    }, [schema, groupDevices]);
    
  4. Use the bindType field to determine whether the current button is bound with a device and the type of the bound device and display such information accordingly.
    import React from 'react';
    import clsx from 'clsx';
    import { Text, View, Image, router } from '@ray-js/ray';
    import { useSelector } from 'react-redux';
    import Strings from '@/i18n';
    import { ColorImage, Empty } from '@/components';
    import { selectIsInitialized, selectCoverList } from '@/redux/modules/uiStateSlice';
    import { BindType } from '@/constant';
    import { mapObject2QueryString } from '@/utils/mapObject2QueryString';
    import { selectGroupIcons } from '@/redux/modules/groupSlice';
    import { GroupDevices } from '@/api/getGroupDevices';
    import styles from './index.module.less';
    
    const groupTypes = [BindType.GroupLight, BindType.GroupCurtain];
    
    interface Props {
     data: Array<{
       bindType: BindType;
       bindGroupInfo: {
         code: string;
         order: number;
         localId: string;
         categoryCode: string;
       };
       bindGroupDevices: GroupDevices;
     }>;
    }
    
    export const Group: React.FC<Props> = ({ data }) => {
     const iconList = useSelector(selectCoverList);
     const groupIcons = useSelector(selectGroupIcons);
     const isInitialized = useSelector(selectIsInitialized);
    
     const handleNavToGroup = () => ({}); // ... Implement this during button-device binding.
    
     return (
       <View>
         <View className={styles.title}>{Strings.getLang('group')}</View>
         {data.length === 0 ? (
           <Empty title={Strings.getLang('groupEmptyTip')} />
         ) : (
           <View className={styles.list}>
             {data.map((d, idx) => {
               const { bindType, bindGroupInfo } = d;
               const isBind = !!bindType;
               let itemText = Strings.getLang('defaultName');
               let itemIcon: string;
               if (groupTypes.includes(bindType)) {
                 itemText = Strings.getLang('groupNameDefault');
                 itemIcon = isInitialized
                   ? iconList[groupIcons.find(v => v.code === bindGroupInfo.code)?.value] ?? ''
                   : '';
               }
               const style = {
                 flex: data.length === 1 ? '0 0 100%' : '0 0 calc(50% - 6px)',
                 height: data.length > 2 ? '68px' : '110px',
               };
               return (
                 <View key={idx} style={style} className={styles.item} onClick={handleNavToGroup(d)}>
                   <View className={clsx(!isBind && styles.invisible)}>
                     <ColorImage
                       width="28px"
                       height="28px"
                       color="rgba(25, 97, 206, 0.9)"
                       src={itemIcon}
                     />
                   </View>
                   <View className={styles.content}>
                     <View className={styles.textWrapper}>
                       <Text className={styles.textNo}>{idx + 1}</Text>
                       <Text className={styles.textScene}>{itemText}</Text>
                     </View>
                   </View>
                   <Image className={styles.imageIcon} src="/images/icon_triangle.png" />
                 </View>
               );
             })}
           </View>
         )}
       </View>
     );
    };
    

Configure the functional logic of the Group Settings page, Light/Curtain Group editing page, and the Group Creation page.

Implement jumping to settings or editing page

Use the handleNavToGroup function to implement jumping to the Group Settings page or the Light/Curtain Group editing page and determine the destination page by using the bindType field. If no device is bound, you will be redirected to the Group Settings page. Otherwise, you will be redirected to the Light/Curtain Group editing page based on the type of the bound device.

const handleNavToGroup = React.useCallback(
  (d: (typeof data)[number]) => {
    return () => {
      const { bindType, bindGroupInfo } = d;
      if (!bindType) {
        router.push(`/group-setting?btnId=${d.btnId}`);
        return;
      }
      const path = mapObject2QueryString(
        bindType === BindType.GroupLight ? '/group-light-edit' : '/group-curtain-edit',
        {
          groupName: Strings.getLang('groupNameDefault'),
          groupIcon: iconList[groupIcons.find(v => v.code === bindGroupInfo.code)?.value] ?? '',
          code: bindGroupInfo.code,
          localId: bindGroupInfo.localId,
          categoryCode: bindGroupInfo.categoryCode,
        }
      );
      router.push(path);
    };
  },
  [iconList, groupIcons]
);

Group settings page

Render the group settings page based on the actual business scenario. The template is only used to distinguish the entry for different categories of devices that can be bound for the remote control group.


import React from 'react';
import { View, router, useQuery } from '@ray-js/ray';
import { TopBar } from '@/components';
import Strings from '@/i18n';
import { useHideMenuButton } from '@/hooks/useHideMenuButton';
import { useCreation } from 'ahooks';
import { useDevice } from '@ray-js/panel-sdk';
import { mapObject2QueryString } from '@/utils/mapObject2QueryString';
import { useSelector } from 'react-redux';
import { selectLocalIds } from '@/redux/modules/groupSlice';
import { selectCoverList } from '@/redux/modules/uiStateSlice';
import { SceneItem } from './components/scene-item';
import styles from './index.module.less';
import { GroupSettingQuery } from './index.type';

export function GroupSetting() {
  useHideMenuButton();
  const query: GroupSettingQuery = useQuery();
  const dpSchema = useDevice(d => d.dpSchema);
  const localIds = useSelector(selectLocalIds);
  const iconList = useSelector(selectCoverList);
  const dataSource = useCreation(() => {
    const groupInfo = localIds.find(d => d.code.slice(-1) === query.btnId);

    const handleNavToGroup = (location: string) => {
      const defaultParams = { groupIcon: iconList[0] };
      const path = mapObject2QueryString(location, {
        ...defaultParams,
        code: `switch_${query.btnId}`,
        localId: groupInfo?.localId,
        categoryCode: groupInfo?.categoryCode,
      });
      router.push(path);
    };

    return [
      {
        title: Strings.getLang('groupLight'),
        subTitle: Strings.getLang('groupLightDesc'),
        img: '/images/scene_setting_light.png',
        valid: !!groupInfo,
        onClick: () => handleNavToGroup('/group-light-edit'),
      },
      {
        title: Strings.getLang('groupCurtain'),
        subTitle: Strings.getLang('groupCurtainDesc'),
        img: '/images/scene_setting_curtain.png',
        valid: !!groupInfo,
        onClick: () => handleNavToGroup('/group-curtain-edit'),
      },
    ].filter(d => d.valid);
  }, [query, dpSchema, iconList]);

  return (
    <View className={styles.view}>
      <TopBar title={Strings.getLang('groupSetting')} isSubPage />
      <View className={styles.content}>
        {dataSource.map((item, index) => {
          return <SceneItem key={index} {...item} />;
        })}
      </View>
    </View>
  );
}

export default GroupSetting;

Button editing

  1. The button editing feature for the light group and curtain group is the same. When you tap on the icon, a predefined icon selection window pops up. You can select the group icon and save the settings either through the device cloud storage or the app local storage. The template adopts the device cloud storage.
    import React from 'react';
    import clsx from 'clsx';
    import { useBoolean, useDebounceFn } from 'ahooks';
    import {
     Image,
     Text,
     View,
     navigateBack,
     openZigbeeLocalGroup,
     showToast,
     useAppEvent,
     useQuery,
    } from '@ray-js/ray';
    import { useSelector } from 'react-redux';
    import { useDevice } from '@ray-js/panel-sdk';
    import { TopBar, DialogSelectIcon, ColorImage, FixedBottom } from '@/components';
    import Strings from '@/i18n';
    import { useHideMenuButton } from '@/hooks/useHideMenuButton';
    import { ReduxState, useAppDispatch } from '@/redux';
    import { selectCoverList, selectIsAdmin } from '@/redux/modules/uiStateSlice';
    import {
     selectBindGroupDevices,
     updateGroupDevicesAsync,
     updateGroupIconsAsync,
    } from '@/redux/modules/groupSlice';
    import styles from './index.module.less';
    import { GroupEditQuery, Props } from './index.type';
    import { TopBarHeight } from '../top-bar';
    
    export const PageGroup: React.FC<Props> = props => {
     useHideMenuButton();
     const { code, localId, categoryCode, groupIcon }: GroupEditQuery = useQuery();
     const dispatch = useAppDispatch();
     const devId = useDevice(d => d.devInfo.devId);
     const isAdmin = useSelector(selectIsAdmin);
     const iconList = useSelector(selectCoverList);
     const bindGroupDevices = useSelector((state: ReduxState) =>
       selectBindGroupDevices(state, categoryCode)
     );
     const [fixedBottomHeight, setFixedBottomHeight] = React.useState('180rpx'); // iPhone 6 base width is used by default.
     const [curGroupIcon, setCurSelectIcon] = React.useState(groupIcon || iconList[0]);
     const [show, { setTrue, setFalse }] = useBoolean();
     const initialParam = React.useRef({ bindGroupDevices }); // Used for reset.
     const isChanged =
       groupIcon! == curGroupIcon ||
       initialParam.current.bindGroupDevices?.length! == bindGroupDevices?.length;
     const isValid = !!curGroupIcon && bindGroupDevices.length > 0;
     const isSaveDisabled = !isValid || !isChanged; // Data can be saved only when it is valid and has been changed.
    
     useAppEvent('onShow', () => {
       dispatch(updateGroupDevicesAsync(categoryCode));
     });
    
     const handleNavToZigbeePair = {}; // ... See section "Bind device" for details.
    
     const handleSelect = React.useCallback(data => {
       setFalse();
       setCurSelectIcon(data);
     }, []);
    
     const { run: handleSave } = useDebounceFn(
       () => {
         const curGroupIconIdx = iconList.findIndex(v => v === curGroupIcon);
         dispatch(updateGroupIconsAsync({ code, value: `${curGroupIconIdx}` })).then(() => {
           showToast({
             title: Strings.getLang('groupEditSuccessTip'),
             icon: 'success',
             success: () => {
               navigateBack({ delta: 2 }); // Jump to the homepage.
             },
           });
         });
       },
       { wait: 500 }
     );
    
     return (
       <View className={styles.view} style={{ background: props.background }}>
         <TopBar title={props.title} position="absolute" theme={props.theme} isSubPage />
         <View
           className={styles.gradient}
           style={{ top: `${TopBarHeight + 52}px`, background: props.background }}
         />
         <View
           style={{
             backgroundImage: props.backgroundImage,
             marginBottom: isAdmin ? fixedBottomHeight : 0,
             paddingBottom: !isAdmin ? fixedBottomHeight : 0,
           }}
           className={styles.content}
         >
           <View
             style={{ marginTop: `${TopBarHeight + 104}px` }}
             className={clsx(styles.icon, !isAdmin && styles.disabled)}
             onClick={setTrue}
           >
             <ColorImage width="84rpx" height="84rpx" src={curGroupIcon} color="#1961CE" />
           </View>
    
           <View className={styles.item} style={{ marginTop: '8px', paddingBottom: '10rpx' }}>
             <View
               className={clsx(styles.itemRow, !isAdmin && styles.disabled)}
               onClick={handleNavToZigbeePair}
             >
               <Text>{Strings.getLang('groupSelect')}</Text>
               <Image className={styles.imageIcon} src="/images/icon_arrow.png" />
             </View>
             <View className={styles.deviceList}>
               {bindGroupDevices
                 .filter(item => item.devId !== devId)
                 .map(item => {
                   return (
                     <View key={item.devId} className={styles.deviceItem}>
                       <Image src={item.iconUrl} />
                       <Text>{item.devName}</Text>
                     </View>
                   );
                 })}
             </View>
           </View>
         </View>
         <FixedBottom contentHeight="100rpx" getFixedBottomHeight={setFixedBottomHeight}>
           {isAdmin && (
             <View
               className={clsx(styles.button, isSaveDisabled && styles.button__disabled)}
               onClick={handleSave}
             >
               {Strings.getLang('save')}
             </View>
           )}
         </FixedBottom>
         <DialogSelectIcon
           show={show}
           currentSelectIcon={curGroupIcon}
           title={Strings.getLang('groupIcon')}
           onSelect={handleSelect}
           onClose={setFalse}
         />
       </View>
     );
    };
    
  2. After you select the icon and tap Save, the Redux updateGroupIconsAsync asynchronous action will be called to store the group icon of the current device in the cloud.
    export const updateGroupIconsAsync = createAsyncThunk<boolean, { code: string; value: string }>(
     'group/updateGroupIconsAsync',
     async (param, thunkApi) => {
       const state = thunkApi.getState() as ReduxState;
       const { code, value } = param;
       const groupIcons = _.cloneDeep(state.group.groupIcons) as Group['groupIcons'];
       const targetGroup = groupIcons.find(item => item.code === code);
       if (!targetGroup) {
         return false;
       }
       targetGroup.value = value;
       const res = await saveDevProperty({
         devId: deviceId,
         bizType: 0,
         propertyList: JSON.stringify([{ code: DEV_PROPERTIES.GROUP_ICONS, value: groupIcons }]),
       });
       thunkApi.dispatch(groupSlice.actions.updateGroupIcons({ groupIcons }));
       return res;
     }
    );
    

Bind device

The following content describes how to bind switch buttons with devices.

You can call openZigbeeLocalGroup to complete binding.

const handleNavToZigbeePair = React.useCallback(() => {
  openZigbeeLocalGroup({ deviceId: devId, localId, categoryCode, codes: props.codes });
}, []);