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.
For more information, see Panel MiniApp > Set up environment.
Product name: Zigbee remote control group switch
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.
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.Log in to Tuya MiniApp Developer Platform to create a panel miniapp.
For more information, see Panel MiniApp > Create panel miniapp.
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.
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;
});
};
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;
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;
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) ?? [];
};
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]);
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.
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]
);
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;
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>
);
};
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;
}
);
The following content describes how to bind switch buttons with devices.
You can call openZigbeeLocalGroup
to complete binding.
deviceId
: Pass in the device ID of the remote control switch.localId
: Pass in the local device ID corresponding to the remote control switch button.categoryCode
: Pass in the group code corresponding to the remote control switch button.codes
: Pass in the level-2 category code for device filtering. For example, display only lighting devices for light groups.const handleNavToZigbeePair = React.useCallback(() => {
openZigbeeLocalGroup({ deviceId: devId, localId, categoryCode, codes: props.codes });
}, []);