前提条件

开发环境

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

产品名称:扫地机器人

需求原型

首先需要创建一个激光型扫地机器人产品,定义产品有哪些功能点,然后面板中再根据这些功能点一一实现。

进入IoT 平台,点击左侧产品菜单,产品开发,创建产品,选择标准类目 -> 小家电 -> 扫地机器人 -> 激光型扫地机:

选择功能点,这里根据自己需求选择即可,这些功能未选择不影响视频预览。

🎉 在这一步,我们创建了一个名为 Robot的扫地机器人产品。

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

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

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

IDE 基于模板创建项目工程

这部分我们在 Tuya MiniApp Tools 上进行操作,打开 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 通用模板教程