前提条件

构建内容

在此 Codelab 中,您将利用面板小程序开发构建出一个 Zigbee 遥控器群组开关,通过配合 Zigbee 网关,可以实现在实体按键操作后批量控制多个被绑定设备的能力。

学习内容

所需条件

详见 面板小程序 - 搭建环境

产品名称:Zigbee 遥控器群组开关

需求原型

  1. 首页
    • 顶部展现了当前设备的图片,中心区域则展示设备的按键列表信息,并显著标明每个按键是否已与家庭中的其他设备成功绑定。
    • 点击未绑定设备的按键跳入 群组设置页,点击已绑定设备的按键则根据绑定的设备类型判断跳入 灯光群组编辑页窗帘群组编辑页

  1. 群组设置页
    • 展示当前支持品类的遥控器群组创建页面的入口列表,点击则进入对应品类的群组创建页。

  1. 灯光/窗帘群组编辑页
    • 点击顶部的图标可以选择不同的遥控器群组图标,用于在 首页 区分不同的绑定功能。
    • 点击选择设备列表项,会跳转进入 创建群组页 进行设备的选择和绑定。
    • 点击底部的保存按钮,则会保存当前遥控器群组的图标,注意设备的绑定在 创建群组页 就已经完成了,不会受到保存按钮的影响。

  1. 创建群组页
    • 展示当前家庭中被筛选后的设备列表,点击设备列表项并点击右上角的保存即可完成设备的配对绑定。
    • 绑定完成后,此时点击对应的实体按键,会同时触发被绑定设备的控制。

功能汇总

当前 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 遥控器群组开关产品,定义产品有哪些功能点,然后面板中再根据这些功能点进行实现。

  1. 进入 IoT 平台,点击左侧 产品菜单,产品开发,创建产品,选择 标准类目 -> 电工 -> 场景开关,如下图所示:

  1. 选择功能点,在这里我们根据实际场景把对应的开关功能点选上,在当前 Codelab 中我们选择了 switch_1 -> switch_4 四个开关功能点,以及一个 scene_switch 功能点,对于定义来说,它就是一个存在四个按键的遥控器群组开关设备,具体如下图所示:

开发者平台创建面板小程序

这部分我们在 小程序开发者 平台上进行操作,注册登录 小程序开发者平台 进行操作。

详细的操作路径可参考 面板小程序 - 创建面板小程序

IDE 基于模板创建项目工程

这部分我们在 Tuya MiniApp Tools 上进行操作,打开 IDE 创建一个 Zigbee 遥控器群组的面板小程序项目。

详细的操作路径可参考 面板小程序 - 初始化项目工程

在本章节中,您会将了解到 动态渲染开关按键列表开关按键设备绑定状态 的功能逻辑,方便您在后续的开发过程中,基于我们提供的示例代码,根据差异化的业务场景实现不同的功能交互。

动态渲染开关按键列表

  1. 首先我们需要根据 DP 功能点的定义,动态渲染开关按键列表,这里我们可以通过 SDMSmartDeviceSchema 类型定义来获取到当前设备的 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;
    });
};
  1. 然后我们在 首页 页面中根据 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;

开关按键设备绑定状态

  1. 我们需要在项目初始化时,获取一些项目强依赖的一些数据,比如 按键绑定设备数据,因此在这里我们通过 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;
  1. 可以看到这个异步 Action 首先获取到当前设备的 本地 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) ?? [];
};
  1. 现在,我们就可以回到 首页 通过 useSelector 获取到 Redux Store 中的 groupDevices 数据,然后根据 bindTypebindGroupInfobindGroupDevices 三个字段来判断当前按键是否已经绑定了设备,以及绑定的设备类型,注意需要把 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]);
  1. 最后,我们就可以根据 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;

按键编辑

  1. 按键编辑的功能在灯光群组和窗帘群组完全一致,当我们点击 Icon 图标时,会弹出一个内置的图标选择弹框,选择完毕以后可以通过 设备维度的云端存储 或者 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>
  );
};
  1. 当我们选择完图标,点击确认时,会调用 Redux updateGroupIconsAsync 异步 Action 来保存当前设备的群组图标至云端。
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 });
}, []);