前提条件

开发环境

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

产品名称:扫地机器人

需求原型

由于产品定义了面板和设备所拥有的功能点,所以在开发一个智能设备面板之前,我们首先需要创建一个激光型扫地机器人产品,定义产品有哪些功能点,然后再在面板中一一实现这些功能点。

首先,注册登录 涂鸦开发者平台,并在平台创建产品:

  1. 单击页面左侧 产品 > 产品开发,在 产品开发 页面单击 创建产品
  2. 标准类目 下选择 小家电,产品品类选择 扫地机器人
  3. 选择智能化方式,产品方案 选择 激光型扫地机,并完善产品信息,如填写 产品名称Robot

  1. 单击 创建产品,完成产品创建。
  2. 产品创建完成后,进入到 添加标准功能 页面,根据自己需求添加功能即可(这些功能未选择不影响视频预览),然后单击 确定

🎉 完成以上步骤后,一个名为 Robot 的扫地机器人产品创建完成。

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

面板小程序的开发在 小程序开发者 平台上进行操作,首先请前往 小程序开发者平台 完成平台的注册登录。

详细操作步骤可以参考 创建面板小程序

IDE 基于模板创建项目工程

打开 IDE 创建一个基于 扫地机小程序模板 的面板小程序项目,需要在 Tuya MiniApp IDE 上操作。

详细操作步骤可以参考 初始化项目工程

完成以上步骤后,一个面板小程序的开发模板初始化完成。以下为工程目录的介绍:

├── src
│  	├── api
│  	│   └── ossApi.ts // oss 地图下载相关的 API
│  	├── components
│  	│   ├── DecisionBar // 确认框组件
│  	│   ├── EmptyMap // 空地图组件
│  	│   ├── HistoryMapView // 历史地图组件
│  	│   ├── HomeTopBar // 首页 TopBar 组件
│  	│   ├── IpcRecordTimer // IPC 录制时间组件
│  	│   ├── IpcRecordTip // IPC 录制提示组件
│  	│   ├── Loading // 地图 Loading 组件
│  	│   ├── MapView // 实时地图组件
│  	│   ├── RoomNamePopLayout // 房间命名弹窗组件
│  	│   ├── RoomPreferencePopLayout // 房间清洁偏好弹窗组件
│  	│   ├── Selector // 选择器组件
│  	│   ├── TopBar // 通用 TopBar 组件
│  	├── constant
│  	│   ├── dpCodes.ts // dpCode 常量
│  	│   ├── index.ts // 存放所有的常量配置
│  	├── devices // 设备模型
│  	├── hooks // hooks
│  	├── i18n // 多语言
│  	├── iconfont // IconFont 文件
│  	├── pages
│   │   ├── addTiming // 添加定时页面
│   │   ├── cleanRecordDetail // 清扫记录详情页面
│   │   ├── cleanRecords // 清扫记录列表页面
│   │   ├── doNotDisturb // 勿扰模式页面
│   │   ├── home // 首页
│   │   ├── ipc // 视频监控页面
│   │   ├── manual // 手动控制页面
│   │   ├── mapEdit // 地图编辑页面
│   │   ├── multiMap // 多地图管理页面
│   │   ├── roomEdit // 房间编辑页面
│   │   ├── setting // 设置页
│   │   ├── timing // 定时列表页面
│   │   ├── voicePack // 语音包页面
│  	├── redux // redux
│   ├── res // 图片资源 & svg 相关
│   ├── styles // 全局样式
│   ├── utils
│   │   ├── openApi // 地图操作方法
│   │   ├── index.ts // 业务常用工具方法
│   │   ├── ipc.ts // ipc 相关工具方法
│   │   ├── robotStatus.ts // 扫地机状态判断方法
│   ├── app.config.ts
│   ├── app.less
│   ├── app.tsx
│   ├── composeLayout.tsx // 处理监听子设备添加,解绑,DP 变化等
│   ├── global.config.ts
│   ├── mixins.less // less mixins
│   ├── routes.config.ts // 配置路由
│   ├── variables.less // less variables
├── typings // 全局类型定义
├── webview // 地图 WebView 需要的 html

为了能够让开发者更关注在 UI 层面的处理,而不需要过多关心一些其他的流程逻辑处理,我们将扫地机进行模块拆分,将底层实现与业务调用独立。目前扫地机面板主要依赖的包有以下几个:

对于普遍的扫地机需求,基本上只关注应用业务逻辑和 UI 展示,不需要关心内部依赖包中的实现,依赖包的升级会做到向下兼容,可以在项目中针对依赖包进行单独升级。

实时地图的显示是一个扫地机应用的核心功能,那么,如何在首页渲染出第一张地图呢?

地图组件

@ray-js/robot-map-component 提供了两种类型的地图组件:

业务层面模板封装了 实时地图组件 ,你可以按业务场景需要选择使用 全屏组件 还是 动态组件

首页通常为实时展示的扫地机地图,更推荐使用 全屏组件,可以按以下方式引入:

import MapView from "@/components/MapView";

// Add your custom logic here

return (
  <MapView
    isFullScreen
    onMapId={onMapId}
    onClickSplitArea={onClickSplitArea}
    onDecodeMapData={onDecodeMapData}
    onClickRoomProperties={onClickRoomProperties}
    onClickMaterial={onClickMaterial}
    onClickRoom={onClickRoom}
    style={{
      height: "75vh",
    }}
  />
);

数据接入

那么,地图和路径数据是怎么注入到这个组件的呢?

模板封装了 @ray-js/robot-data-stream 工具库,内置了 P2P 初始化、建立连接、下载数据流、销毁等一系列流程,只需要调用 useP2PDataStream 这个 Hooks,就能实时获得从扫地机设备传输过来的地图、路径数据(前提是您的扫地机设备已经开发支持 P2P 数据传输)。

import { useP2PDataStream } from "@ray-js/robot-data-stream";
import { useMapData, usePathData } from "@/hooks";

// useMapData 是将地图数据进行一些处理后注入到 <MapView /> 的hooks
const { onMapData } = useMapData();
// usePathData 是将路径数据进行一些处理后注入到 <MapView /> 的hooks
const { onPathData } = usePathData();

useP2PDataStream(getDevInfo().devId, onMapData, onPathData);

IDE 的地图调试

如以上步骤已顺利完成,也许你已经成功在手机上看到了地图,但如果开发阶段就要依赖于手机扫码的方式进行调试,会有些许不便。那么,是否有方法能在 IDE 上也展示实时地图呢?

虽然 IDE 不具备 P2P 连接的环境,但可以借助 扫地机调试助手 插件来实现,具体使用方式可参考 文档

清扫模式

清扫是扫地机器人最基本的功能。模板内置了 4 种清扫模式:全屋、选区、定点、划区。

其中,选区、定点、划区模式会涉及到地图状态的变更:

/**
 * 修改地图状态
 * @param status 地图状态
 */
const setMapStatusChange = (status: number) => {
  const { mapId } = store.getState().mapState;
  const edit = status !== ENativeMapStatusEnum.normal;
  // 当切换为选区清扫时,冻结地图阻止地图更新
  if (status === ENativeMapStatusEnum.mapClick) {
    freezeMapUpdate(mapId, true);
  }
  // 切换回来时恢复地图
  if (status === ENativeMapStatusEnum.normal) {
    freezeMapUpdate(mapId, false);
  }

  setLaserMapStateAndEdit(mapId, { state: status, edit: edit || false });
};

/**
 * 切换清扫模式
 * @param modeValue
 * @param mapStatus
 */
const handleSwitchMode = (modeValue: string, mapStatus: number) => {
  const { mapId } = store.getState().mapState;

  setMapStatus(mapStatus);

  // 是否是切换到选区清扫
  if (mapStatus === ENativeMapStatusEnum.mapClick) {
    setLaserMapSplitType(mapId, EMapSplitStateEnum.click);
  }

  if (mapStatus === ENativeMapStatusEnum.normal) {
    setLaserMapSplitType(mapId, EMapSplitStateEnum.normal);
  }

  // 指哪扫哪功能支持不点击地图立即生成一个可移动区域
  if (mapStatus === 1) {
    addPosPoints();
  }
};


定点和划区清扫模式,都支持在地图上绘制图形,模板封装了几个 Hooks 用于在地图上绘制图形的功能:

你可以用 usePoseClean 在地图上添加一个定点清扫的移动点:

import { usePoseClean } from "@/hooks";

const { drawPoseCleanArea } = usePoseClean();

/**
 * 增加一个定点清扫的移动点
 */
const addPosPoints = async () => {
  const { mapId } = store.getState().mapState;
  drawPoseCleanArea(mapId);
};

清扫指令

当设定好清扫模式后,需要下发指令让机器开始清扫,除了像 清扫开关-switch_go 这样必要的布尔型 DP 外,应该注意到 指令传输-command_trans 这样的 raw 类型 DP 用于告诉设备选区、定点、划区模式下的图元信息。

关于如何组装出一个字节型的指令数据,或许您已学习过 涂鸦激光扫地机协议 文档。目前,模板封装了@ray-js/robot-protocol扫地机协议库用于组装/解析指令数据,目前已经涵盖了 90% 以上的常用指令功能。

如果要进行选区清扫的指令下发,假设您的产品约定使用 0x14(0x15) 选区清扫 Room Clean 这个选区清扫协议,那么可以使用 encodeRoomClean0x14 来进行指令数据的组装:

import { encodeRoomClean0x14 } from "@ray-js/robot-protocol";
import { useActions } from "@ray-js/panel-sdk";

const actions = useActions();

const roomCleanFunc = () => {
  const { version, selectRoomData } = store.getState().mapState;

  const data = encodeRoomClean0x14({
    cleanTimes: 1,
    // selectRoomData 是在选择房间后由 MapView 的 onClickSplitArea 抛出的房间信息
    roomHexIds: selectRoomData,
    mapVersion: version,
  });

  actions[commandTransCode].set(data);
};

同理,如果进入面板后发现处于选区清扫状态,你需要解析设备上报的选区清扫指令,来得知设备当前正在清扫哪几个房间,您可以结合使用 requestRoomClean0x15decodeRoomClean0x15

// 如果进入面板后处于选区清扫的状态,那么需要下发查询型的指令,请求设备上报具体的指令数据。
actions[commandTransCode].set(
  requestRoomClean0x15({ version: PROTOCOL_VERSION })
);

// 当收到设备上报的数据后,进行选区清扫解析
const roomClean = decodeRoomClean0x15({
  // command 是设备上报的指令的 dp 值
  command,
  mapVersion,
});

if (roomClean) {
  const { roomHexIds } = roomClean;
  // 更新 selectRoomData 后,地图上正在选区清扫的房间就会高亮
  dispatch(updateMapData({ selectRoomData: roomHexIds }));
}

多地图管理页面会展示设备已经存放的所有历史地图。尽管数据协议和渲染方式一致,历史地图和实时地图的数据来源是截然不同的。实时地图的数据来源于 P2P,而历史地图的数据来源于云端文件下载。关于多地图数据的获取,请参考 多地图 API

模板在 Redux 里封装了 multiMapsSlice 用于多地图数据的查询,可以参考相关代码。模板还封装了 HistoryMapView 组件专用于历史地图的展示。

import HistoryMapView from "@/components/HistoryMapView";

return (
  <HistoryMapView
    isFullScreen={false}
    // bucket 和 file 数据来源于 getMultipleMapFiles 接口请求
    history={{
      bucket,
      file,
    }}
  />
);

关于使用地图与删除地图的功能,使用 @ray-js/robot-protocol 提供的 encodeUseMap0x2eencodeDeleteMap0x2c 即可:

import { encodeDeleteMap0x2c, encodeUseMap0x2e } from "@ray-js/robot-protocol";

const actions = useActions();

const handleDelete = () => {
  actions[commandTransCode].set(encodeDeleteMap0x2c({ id }));
};

const handleUseMap = () => {
  actions[commandTransCode].set(
    encodeUseMap0x2e({
      mapId,
      url: file,
    })
  );
};

地图编辑页面引入地图的方式和首页地图类似,但部分 prop 有变化。

const uiInterFace = useMemo(() => {
  return { isFoldable: true, isShowPileRing: true };
}, []);

<MapView
  isFullScreen
  // 房间设置临时数据
  preCustomConfig={previewCustom}
  // 强制折叠房间属性标签 显示充电桩预警圈(警示禁区和虚拟墙不能设置过近)
  uiInterFace={uiInterFace}
  onMapId={onMapId}
  onLaserMapPoints={onLaserMapPoints}
  onClickSplitArea={onClickSplitArea}
  onMapLoadEnd={onMapLoadEnd}
  // 不显示路径
  pathVisible={false}
  // 无选区状态
  selectRoomData={[]}
/>;

禁区

禁区分为扫地禁区和拖地禁区,可以使用 useForbiddenNoGouseForbiddenNoMop 这两个 Hooks 来创建对应的禁区。

创建完成后,如果要保存下发禁区,可以通过 encodeVirtualArea0x38 方法将禁区信息组装成 DP 指令并下发。

import { useForbiddenNoGo, useForbiddenNoMop } from "@/hooks";
import { getMapPointsInfo } from "@/utils/openApi";
import { encodeVirtualArea0x38 } from "@ray-js/robot-protocol";

// 创建一个禁扫禁区
const { drawOneForbiddenNoGo } = useForbiddenNoGo();
// 创建一个禁拖禁区
const { drawOneForbiddenNoMop } = useForbiddenNoMop();

// 保存下发禁区
const handleSave = () => {
  const { origin } = store.getState().mapState;
  const { data } = await getMapPointsInfo(mapId.current);

  const command = encodeVirtualArea0x38({
    version: PROTOCOL_VERSION,
    protocolVersion: 1,
    virtualAreas: data.map((item) => {
      return {
        points: item.points,
        mode: item.extend.forbidType === "sweep" ? 1 : 2,
        name: item.content.text,
      };
    }),
    origin,
  });

  actions[commandTransCode].set(command);
};

虚拟墙

虚拟墙功能的实现禁区类似,可以使用 useCreateVirtualWall 创建虚拟墙。

创建完成后,如果要保存下发虚拟墙,可以通过 encodeVirtualWall0x12 方法将虚拟墙信息组装成 DP 指令并下发。

import { useCreateVirtualWall } from "@/hooks";
import { getMapPointsInfo } from "@/utils/openApi";
import { encodeVirtualWall0x12 } from "@ray-js/robot-protocol";

// 创建一个虚拟墙
const { drawOneVirtualWall } = useCreateVirtualWall();

// 保存下发虚拟墙
const handleSave = () => {
  const { origin } = store.getState().mapState;
  const { data } = await getMapPointsInfo(mapId.current);

  const command = encodeVirtualWall0x12({
    version: PROTOCOL_VERSION,
    origin,
    walls: data.map((item) => item.points),
  });

  actions[commandTransCode].set(command);
};

地板材质

由于是与房间信息关联的数据,设置地板材质需要设定 MapViewpreCustomConfig

进入房间后将弹出地板材质选择弹窗,选择材质后会将这些状态保存在临时的 previewCustom 里。

保存确认房间材质可以通过 encodeSetRoomFloorMaterial0x52 方法将临时的地板材质信息转换为 DP 指令去下发:

import { encodeSetRoomFloorMaterial0x52 } from "@ray-js/robot-protocol";

const [showFloorMaterialPopup, setShowFloorMaterialPopup] = useState(false);
const [previewCustom, setPreviewCustom] = useState<{
  [key: string]: { roomId: number; floorMaterial: number };
}>({});

// 对某个房间设置地板材质
const handleFloorMaterialConfirm = (hexId: string) => {
  const room = {
    roomId: roomIdState.roomId,
    floorMaterial: parseInt(hexId, 16),
  };
  const curRoom = {
    [roomIdState.roomIdHex]: {
      ...room,
    },
  };

  setPreviewCustom({ ...previewCustom, ...curRoom });
  setShowFloorMaterialPopup(false);
};

// 保存下发所有的地板材质信息
const handleSave = () => {
  const onConfirm = () => {
    const rooms = Object.keys(previewCustom).map((roomIdHex: string) => {
      const room = previewCustom[roomIdHex];
      return {
        roomId: room.roomId,
        material: room.floorMaterial,
      };
    });
    const command = encodeSetRoomFloorMaterial0x52({
      version: PROTOCOL_VERSION,
      rooms,
    });

    actions[commandTransCode].set(command);
  };
};

return (
  <View>
    <MapView
      isFullScreen
      // 房间设置临时数据
      preCustomConfig={previewCustom}
      uiInterFace={uiInterFace}
      onMapId={onMapId}
      onLaserMapPoints={onLaserMapPoints}
      onClickSplitArea={onClickSplitArea}
      onMapLoadEnd={onMapLoadEnd}
      pathVisible={false}
      selectRoomData={[]}
    />
    {/*
      地板材质选择弹窗
    */}
    <FloorMaterialPopLayout
      show={showFloorMaterialPopup}
      onConfirm={handleFloorMaterialConfirm}
    />
  </View>
);

房间编辑页面引入地图的方式和首页类似,但部分 prop 有变化。

<MapView
  isFullScreen
  // 房间设置临时数据
  preCustomConfig={previewCustom}
  onMapId={onMapId}
  onClickSplitArea={onClickSplitArea}
  onSplitLine={onSplitLine}
  onMapLoadEnd={onMapLoadEnd}
  // 不显示路径
  pathVisible={false}
  // 无选区状态
  selectRoomData={[]}
  // 不显示地图上的定点、划区、禁区、虚拟墙等信息
  areaInfoList={[]}
/>

房间合并

单击 房间合并,将地图设置为房间合并状态。

import { setMapStatusMerge } from "@/utils/openApi/mapStatus";
import { changeAllMapAreaColor } from "@/utils/openApi";

/**
 * 进入房间合并状态
 */
const handleMergeStatus = async () => {
  // 将地图设置为房间合并状态
  setMapStatusMerge(mapId.current);
  // 设置所有房间颜色为未选中状态
  changeAllMapAreaColor(mapId.current, true);
};

选中需要合并的两个房间后,可以通过 encodePartitionMerge0x1e 将房间信息转换为 DP 指令并做下发。

import { getLaserMapMergeInfo } from "@/utils/openApi";
import { encodePartitionMerge0x1e } from "@ray-js/robot-protocol";

// 下发房间合并指令
const handleSave = () => {
  const { version } = store.getState().mapState;

  const res = await getLaserMapMergeInfo(mapId.current);
  const { type, data } = res;
  const roomIds = data.map((room) => parseRoomId(room.pixel, version));

  const command = encodePartitionMerge0x1e({
    roomIds,
    version: PROTOCOL_VERSION,
  });
  actions[commandTransCode].set(command);
};

房间分割

单击 房间分割,将地图设置为房间分割状态。

import { setMapStatusSplit } from "@/utils/openApi/mapStatus";

/**
 * 进入房间分割状态
 */
const handleSplitStatus = async () => {
  // 将地图设置为房间分割状态
  setMapStatusSplit(mapId.current);
};

在选中房间并设置需要的分割线后,可以通过 encodePartitionDivision0x1c 将房间分割信息转换为 DP 指令并做下发。

import { getLaserMapSplitPoint } from "@/utils/openApi";
import { encodePartitionDivision0x1c } from "@ray-js/robot-protocol";

// 下发房间分割指令
const handleSave = () => {
  const { version } = store.getState().mapState;

  const {
    type,
    data: [{ points, pixel }],
  } = await getLaserMapSplitPoint(mapId.current);
  const roomId = parseRoomId(pixel, version);

  const command = encodePartitionDivision0x1c({
    roomId,
    points,
    origin,
    version: PROTOCOL_VERSION,
  });
  actions[commandTransCode].set(command);
};

房间命名

单击 房间命名,将地图设置为房间命名状态。

import { setMapStatusRename } from "@/utils/openApi/mapStatus";

/**
 * 进入房间命名状态
 */
const handleRenameStatus = async () => {
  // 将地图设置为房间命名状态
  setMapStatusRename(mapId.current);
};

在选中房间并在弹窗里填入名称后,临时的房间命名信息会存放在 previewCustom 状态里,可以通过 encodeSetRoomName0x24 将房间命名信息转换为 DP 指令并做下发。

import { encodeSetRoomName0x24 } from "@ray-js/robot-protocol";

const [showRenameModal, setShowRenameModal] = useState(false);
const [previewCustom, setPreviewCustom] = useState({});

// 房间命名弹窗确认
const handleRenameConfirm = (name: string) => {
  const room = previewCustom[roomHexId] || {};
  const curRoom = {
    [roomHexId]: {
      ...room,
      name,
    },
  };
  const newPreviewCustom = { ...previewCustom, ...curRoom };
  setShowRenameModal(false);
  setPreviewCustom(newPreviewCustom);
};

// 下发房间命名指令
const handleSave = () => {
  const { version } = store.getState().mapState;

  const keys = Object.keys(previewCustom);

  const command = encodeSetRoomName0x24({
    mapVersion: version,
    version: PROTOCOL_VERSION,
    rooms: keys.map((key) => {
      return {
        roomHexId: key,
        name: previewCustom[key].name,
      };
    }),
  });
  actions[commandTransCode].set(command);
};

return (
  <View>
    <MapView
      isFullScreen
      // 房间设置临时数据
      preCustomConfig={previewCustom}
      onMapId={onMapId}
      onClickSplitArea={onClickSplitArea}
      onSplitLine={onSplitLine}
      onMapLoadEnd={onMapLoadEnd}
      selectRoomData={[]}
      areaInfoList={[]}
      pathVisible={false}
    />
    <RoomNamePopLayout
      show={showRenameModal}
      onConfirm={handleRenameConfirm}
      defaultValue=""
    />
  </View>
);

房间排序

单击 房间排序,将地图设置为房间顺序设置状态。

import { setMapStatusOrder } from "@/utils/openApi/mapStatus";

/**
 * 进入房间顺序设置状态
 */
const handleMergeStatus = async () => {
  // 将地图设置为房间顺序设置状态
  setMapStatusOrder(mapId.current);
};

在对所有房间设置完顺序后,可以通过 encodeRoomOrder0x26 将房间信息转换为 DP 指令并做下发。

import { getMapPointsInfo } from "@/utils/openApi";
import { encodeRoomOrder0x26 } from "@ray-js/robot-protocol";

// 下发房间顺序设置指令
const handleSave = () => {
  const { version } = store.getState().mapState;
  const { data } = await getMapPointsInfo(mapId.current);

  const roomIdHexs = data
    .sort((a: { order: number }, b: { order: number }) => a.order - b.order)
    .map((item) => item.pixel);

  const command = encodeRoomOrder0x26({
    version: PROTOCOL_VERSION,
    roomIdHexs,
    mapVersion: version,
  });
  actions[commandTransCode].set(command);
};

定时功能使用 DP 定时-device_timer

定时列表

通过 decodeDeviceTimer0x31 可以将定时 DP 解析为定时列表数据。

import { decodeDeviceTimer0x31 } from "@ray-js/robot-protocol";

type TimerData = {
  effectiveness: number;
  week: number[];
  time: {
    hour: number;
    minute: number;
  };
  roomIds: number[];
  cleanMode: number;
  fanLevel: number;
  waterLevel: number;
  sweepCount: number;
  roomNum: number;
};

const [timerList, setTimerList] = useState<TimerData[]>([]);

const dpDeviceTimer = useProps((props) => props[deviceTimerCode]);

useEffect(() => {
  if (dpDeviceTimer) {
    const { list } = decodeDeviceTimer0x31({
      command: dpDeviceTimer,
      version: PROTOCOL_VERSION,
    }) ?? { list: [] };

    setTimerList(list);
  }
}, [dpDeviceTimer]);

对定时项可以进行删除/开关操作,可以使用 encodeDeviceTimer0x30 把新的定时列表转换为 DP 指令进行下发。

import { encodeDeviceTimer0x30 } from "@ray-js/robot-protocol";
import produce from "immer";

type TimerData = {
  effectiveness: number;
  week: number[];
  time: {
    hour: number;
    minute: number;
  };
  roomIds: number[];
  cleanMode: number;
  fanLevel: number;
  waterLevel: number;
  sweepCount: number;
  roomNum: number;
};

const [timerList, setTimerList] = useState<TimerData[]>([]);

// 删除一条定时
const deleteTimer = (index: number) => {
  const newList = [...timerList];
  newList.splice(index, 1);
  const command = encodeDeviceTimer0x30({
    list: newList,
    version: PROTOCOL_VERSION,
    number: newList.length,
  });
  actions[deviceTimerCode].set(command);
};

// 打开/关闭一条定时
const toggleTimer = (index: number, enable: boolean) => {
  const newList = produce(timerList, (draft) => {
    draft[index].effectiveness = enable;
  });

  const command = encodeDeviceTimer0x30({
    list: newList,
    version: PROTOCOL_VERSION,
    number: newList.length,
  });
  actions[deviceTimerCode].set(command);
};

添加定时

添加定时同样使用 encodeDeviceTimer0x30 来组装指令。

// 添加一条定时
const addTimer = (newTimer: TimerData) => {
  const newList = [newTimer, ...timerList];

  const command = encodeDeviceTimer0x30({
    list: newList,
    version: PROTOCOL_VERSION,
    number: newList.length,
  });
  actions[deviceTimerCode].set(command);
};

勿扰模式使用 DP 勿扰时间设置-disturb_time_set

在设置完开关、开始时间、结束时间信息后,单击 保存 即可下发勿扰模式,通过 encodeDoNotDisturb0x40 可以将相关信息组装为 DP 指令。

import { encodeDoNotDisturb0x40 } from "@ray-js/robot-protocol";

// Add your custom logic here

// 保存下发勿扰模式信息
const handleSave = () => {
  const command = encodeDoNotDisturb0x40({
    // 勿扰开关
    enable,
    // 开始时间-小时
    startHour,
    // 开始时间-分钟
    startMinute,
    // 结束时间-小时
    endHour,
    // 结束时间-分钟
    endMinute,
  });

  actions[commandTransCode].set(command);
};

同样,可以使用 decodeDoNotDisturb0x41 将设备上报的勿扰模式 DP 解析并呈现在页面上。

import { decodeDoNotDisturb0x41 } from "@ray-js/robot-protocol";

const dpDisturbTimeSet = useProps((props) => props[disturbTimeSetCode]);
// 勿扰模式dp解析为结构化数据
const { enable, startHour, startMinute, endHour, endMinute } =
  decodeDoNotDisturb0x41(dpDisturbTimeSet) ?? DEFAULT_VALUE;

// Add your custom logic here

清扫记录列表

清扫记录数据的获取,请参考 清扫记录 API

模板已经在 Redux 里封装了 cleanRecordsSlice 用于清扫记录数据的删改查,可以参考相关代码。

import {
  deleteCleanRecord,
  fetchCleanRecords,
  selectCleanRecords,
} from "@/redux/modules/cleanRecordsSlice";

const records = useSelector(selectCleanRecords);

const handleDelete = (id: number) => {
  dispatch(deleteCleanRecord(id));
};

useEffect(() => {
  (dispatch as AppDispatch)(fetchCleanRecords());
}, []);

return (
  <View className={styles.container}>
    {records.map((record) => (
      <Item key={record.id} data={record} onDeleted={handleDelete} />
    ))}
  </View>
);

清扫记录详情

详情需要展示实际清扫的地图与路径,清扫记录与多地图管理类似,也是历史地图,所以同样使用 HistoryMapView 组件。

地图的引入可以参考:

import HistoryMapView from "@/components/HistoryMapView";

return (
  <HistoryMapView
    // 这里选择使用全屏地图组件
    isFullScreen={true}
    // bucket 和 file 数据来源于 getMultipleMapFiles 接口请求
    history={{
      bucket,
      file,
    }}
    pathVisible
  />
);

关于语音包数据的获取,请参考 机器语音 API

import { getVoiceList } from "@ray-js/ray";

type Voice = {
  auditionUrl: string;
  desc?: string;
  extendData: {
    extendId: number;
    version: string;
  };
  id: number;
  imgUrl: string;
  name: string;
  officialUrl: string;
  productId: string;
  region: string[];
};

const [voices, setVoices] = useState<Voice[]>([]);

useEffect(() => {
  const fetchVoices = async () => {
    const res = await getVoiceList({
      devId: getDevInfo().devId,
      offset: 0,
      limit: 100,
    });

    setVoices(res.datas);
  };

  fetchVoices();
}, []);

return (
  <View className={styles.container}>
    {voices.map((voice) => (
      <Item key={voice.id} data={voice} deviceVoice={deviceVoice} />
    ))}
  </View>
);

语音包的下发上报使用 DP 语音包数据下发-voice_data,可使用 @ray-js/robot-protocol 提供的 encodeVoice0x34decodeVoice0x35 完成 DP 数据的组装和解析。

当下发使用某个语音包:

import { useActions } from "@ray-js/panel-sdk";

const actions = useActions();

const handleUse = () => {
  actions[voiceDataCode].set(
    encodeVoice0x34({
      // id url md5 数据均来源于机器语音 API
      id: extendData.extendId,
      url: officialUrl,
      md5: desc,
    })
  );
};

解析语音包上报的数据,获取语音包信息、下载进度、使用状态:

import { useProps } from "@ray-js/panel-sdk";

const dpVoiceData = useProps((props) => props[voiceDataCode]);

const { languageId, status, progress } = decodeVoice0x35({
  command: dpVoiceData,
});

关于语音包的试听,可以参考 音频能力 中的方法。

手动控制是一般的 DP 下发功能,使用 DP 方向-direction_control

模板已经封装了简易的手动控制组件及页面,请参考 src/pages/manual 页面。

import React, { FC, useEffect } from "react";
import {
  View,
  navigateBack,
  onNavigationBarBack,
  setNavigationBarBack,
} from "@ray-js/ray";
import Strings from "@/i18n";
import { Dialog, DialogInstance } from "@ray-js/smart-ui";
import { useActions } from "@ray-js/panel-sdk";
import { directionControlCode, modeCode } from "@/constant/dpCodes";
import ManualPanel from "@/components/ManualPanel";

import styles from "./index.module.less";

const Manual: FC = () => {
  const actions = useActions();

  useEffect(() => {
    ty.setNavigationBarTitle({
      title: Strings.getLang("dsc_manual"),
    });

    // 进入远程控制需要下发手动模式
    actions[modeCode].set("manual");

    setNavigationBarBack({ type: "custom" });

    onNavigationBarBack(async () => {
      try {
        await DialogInstance.confirm({
          context: this,
          title: Strings.getLang("dsc_tips"),
          icon: true,
          message: Strings.getLang("dsc_exit_manual_tips"),
          confirmButtonText: Strings.getLang("dsc_confirm"),
          cancelButtonText: Strings.getLang("dsc_cancel"),
        });

        actions[directionControlCode].set("exit");
        setNavigationBarBack({ type: "system" });

        setTimeout(() => {
          navigateBack();
        }, 0);
      } catch (err) {
        // do nothing
      }
    });

    return () => {
      setNavigationBarBack({ type: "system" });
    };
  }, []);

  return (
    <View className={styles.container}>
      <ManualPanel />
      <Dialog id="smart-dialog" />
    </View>
  );
};

export default Manual;

模板已内置一个 视频监控 页面。

具体可参考 IPC 通用模板 教程。