在此 Codelab 中,您将利用面板小程序开发构建出一个 Zigbee 遥控器群组开关,通过配合 Zigbee 网关,可以实现在实体按键操作后批量控制多个被绑定设备的能力。
详见 面板小程序 - 搭建环境
产品名称:Zigbee 遥控器群组开关
首页
: 群组设置页
,点击已绑定设备的按键则根据绑定的设备类型判断跳入 灯光群组编辑页
或 窗帘群组编辑页
。群组设置页
: 灯光/窗帘群组编辑页
: 首页
区分不同的绑定功能。创建群组页
进行设备的选择和绑定。创建群组页
就已经完成了,不会受到保存按钮的影响。创建群组页
: 当前 Zigbee 遥控器群组开关需要的功能点见下,其中场景按键 DP 可以根据实际设备的长度自行删减,但注意必须在 switch_1 -> switch_8 的数值范围之间,此外 scene_switch DP 点的枚举值必须和开关的 DP 点一一对应上。
DP ID | 功能点名称 | 标识符 | 数据传输类型 | 数据类型 | 功能点属性 | |
24 | 开关1 | switch_1 | 可下发可上报(rw) | bool | ||
25 | 开关2 | switch_2 | 可下发可上报(rw) | bool | ||
26 | 开关3 | switch_3 | 可下发可上报(rw) | bool | ||
27 | 开关4 | switch_4 | 可下发可上报(rw) | bool | ||
112 | 按键 | scene_switch | 可下发可上报(rw) | enum | 枚举值: switch_1, switch_2, switch_3, switch_4 |
首先需要创建一个 Zigbee 遥控器群组开关产品,定义产品有哪些功能点,然后面板中再根据这些功能点进行实现。
这部分我们在 小程序开发者
平台上进行操作,注册登录 小程序开发者平台 进行操作。
详细的操作路径可参考 面板小程序 - 创建面板小程序
这部分我们在 Tuya MiniApp Tools
上进行操作,打开 IDE 创建一个 Zigbee 遥控器群组的面板小程序项目。
详细的操作路径可参考 面板小程序 - 初始化项目工程
在本章节中,您会将了解到 动态渲染开关按键列表
及 开关按键设备绑定状态
的功能逻辑,方便您在后续的开发过程中,基于我们提供的示例代码,根据差异化的业务场景实现不同的功能交互。
SDM
的 SmartDeviceSchema
类型定义来获取到当前设备的 DP 功能点列表,然后根据 scene_switch
的 DP 功能点来获取到当前设备的按键列表。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
函数获取到的按键列表,动态渲染开关按键列表。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 { selectGroupDevices } from '@/redux/modules/groupSlice';
import { ResGetZigbeeLocalIds } from '@/api/getZigbeeLocalIds';
import { GroupDevices } from '@/api/getGroupDevices';
import { BindType } from '@/constant';
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;
按键绑定设备数据
,因此在这里我们通过 Redux dispatch getZigbeeLocalIdAsync
异步 Action。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));
// 在强依赖的数据加载完毕之前,阻塞用户的操作行为
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;
本地 ID
,然后再通过 getGroupDevices
API 来获取到当前设备的 按键绑定设备数据
,最后再写入到 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
获取到 Redux Store 中的 groupDevices
数据,然后根据 bindType
、bindGroupInfo
、bindGroupDevices
三个字段来判断当前按键是否已经绑定了设备,以及绑定的设备类型,注意需要把 btnId
按键 ID 传递给下一个页面,用于判断属于哪个按键。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
来判断当前按键是否已经绑定了设备,以及绑定的设备类型,然后在页面中展示不同的 UI。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 { mapObject2QueryString } from '@/utils/mapObject2QueryString';
import { selectGroupIcons } from '@/redux/modules/groupSlice';
import { GroupDevices } from '@/api/getGroupDevices';
import { BindType } from '@/constant';
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 = () => ({}); // ... 在按键绑定设备章节中实现
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>
);
};
在我们完成开关按键的渲染后,接下来我们需要实现开关按键的绑定设备功能,这里我们需要实现 群组设置页
、灯光/窗帘群组编辑页
、创建群组页
三个页面的功能逻辑。
此时,我们可以通过实现 handleNavToGroup
来控制点击按键列表跳转到 群组设置页
或 灯光/窗帘群组编辑页
,这里我们需要根据 bindType
来判断跳转的页面,如果未绑定则跳转到 群组设置页
,如果已绑定则根据绑定设备品类跳转到 灯光/窗帘群组编辑页
。
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]
);
群组设置列表的渲染功能逻辑较为简单,根据实际的业务场景需求来渲染即可,当前模板内仅用于区分遥控器群组可绑定的不同品类入口。
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;
设备维度的云端存储
或者 App 维度的本地存储
来进行保存,而本模板采用的是 设备维度的云端存储
。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 基准
const [curGroupIcon, setCurSelectIcon] = React.useState(groupIcon || iconList[0]);
const [show, { setTrue, setFalse }] = useBoolean();
const initialParam = React.useRef({ bindGroupDevices }); // 用于重置
const isChanged =
groupIcon !== curGroupIcon ||
initialParam.current.bindGroupDevices?.length !== bindGroupDevices?.length;
const isValid = !!curGroupIcon && bindGroupDevices.length > 0;
const isSaveDisabled = !isValid || !isChanged; // 只有在数据有效且数据有变化时才可保存
useAppEvent('onShow', () => {
dispatch(updateGroupDevicesAsync(categoryCode));
});
const handleNavToZigbeePair = ({}); // ... 详见绑定设备
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 }); // 直接跳转回首页
},
});
});
},
{ 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>
);
};
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;
}
);
当我们学习编辑完按键功能后,此时就来到了遥控器群组最核心的功能了,如何进行按键和设备的绑定。
其实在面板小程序应用层的逻辑非常简单,只需要我们调用 openZigbeeLocalGroup
API 即可,其中
const handleNavToZigbeePair = React.useCallback(() => {
openZigbeeLocalGroup({ deviceId: devId, localId, categoryCode, codes: props.codes });
}, []);