SDM(Smart Device Model) 开发,关于 SDM 相关可以 查看 SDM 文档。详见 面板小程序 > 搭建环境。
产品名称:扫地机器人
由于产品定义了面板和设备所拥有的功能点,所以在开发一个智能设备面板之前,我们首先需要创建一个激光型扫地机器人产品,定义产品有哪些功能点,然后再在面板中一一实现这些功能点。
首先,注册登录 涂鸦开发者平台,并在平台创建产品:


🎉 完成以上步骤后,一个名为 Robot 的扫地机器人产品创建完成。
面板小程序的开发在 小程序开发者 平台上进行操作,首先请前往 小程序开发者平台 完成平台的注册登录。
详细操作步骤可以参考 创建面板小程序。
打开 IDE 创建一个基于 扫地机小程序模板 的面板小程序项目,需要在 Tuya MiniApp IDE 上操作。
详细操作步骤可以参考 初始化项目工程。

完成以上步骤后,一个面板小程序的开发模板初始化完成。以下为工程目录的介绍:
├── src
│ ├── api
│ │ └── ossApi.ts // oss 地图下载相关的 API
│ ├── components
│ │ ├── DecisionBar // 确认框组件
│ │ ├── EmptyMap // 空地图组件
│ │ ├── HomeTopBar // 首页 TopBar 组件
│ │ ├── IpcRecordTimer // IPC 录制时间组件
│ │ ├── IpcRecordTip // IPC 录制提示组件
│ │ ├── Loading // 地图 Loading 组件
│ │ ├── Map // 地图组件 (WebViewMap, RjsMap)
│ │ ├── RoomNamePopLayout // 房间命名弹窗组件
│ │ ├── RoomPreferencePopLayout // 房间清洁偏好弹窗组件
│ │ ├── Selector // 选择器组件
│ ├── constant
│ │ ├── dpCodes.ts // dpCode 常量
│ │ ├── index.ts // 存放所有的常量配置
│ ├── devices // 设备模型
│ ├── hooks // hooks
│ ├── i18n // 多语言
│ ├── iconfont // IconFont 文件
│ ├── pages
│ │ ├── addTiming // 添加定时页面
│ │ ├── cleanPreference // 清洁偏好页面
│ │ ├── cleanRecordDetail // 清扫记录详情页面
│ │ ├── cleanRecords // 清扫记录列表页面
│ │ ├── doNotDisturb // 勿扰模式页面
│ │ ├── home // 首页
│ │ ├── ipc // 视频监控页面
│ │ ├── manual // 手动控制页面
│ │ ├── mapEdit // 地图编辑页面
│ │ ├── multiMap // 多地图管理页面
│ │ ├── roomEdit // 房间编辑页面
│ │ ├── roomFloorMaterial // 房间地板材质页面
│ │ ├── setting // 设置页
│ │ ├── timing // 定时列表页面
│ │ ├── voicePack // 语音包页面
│ ├── redux // redux
│ ├── res // 图片资源 & svg 相关
│ ├── styles // 全局样式
│ ├── utils
│ │ ├── 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 // 全局类型定义
为了能够让开发者更关注在 UI 层面的处理,而不需要过多关心一些其他的流程逻辑处理,我们将扫地机进行模块拆分,将底层实现与业务调用独立。目前扫地机面板主要依赖的包有以下几个:
@ray-js/robot-map:业务层直接调用,提供了 RobotMap 组件和 RjsRobotMap 组件,并且暴露了地图操作的常用方法。@ray-js/robot-data-stream:业务层直接调用,封装了面板与设备的 P2P 传输方法,开发者可以忽略 P2P 通信过程中的复杂过程,只需要关注业务本身逻辑。@ray-js/robot-protocol:业务层直接调用,提供完整协议解析标准能力,将扫地机协议中比较复杂的 raw 类型 DP 点的解析、编码过程进行了封装。对于普遍的扫地机需求,基本上只关注应用业务逻辑和 UI 展示,不需要关心内部依赖包中的实现,依赖包的升级会做到向下兼容,可以在项目中针对依赖包进行单独升级。
实时地图的显示是一个扫地机应用的核心功能,那么,如何在首页渲染出第一张地图呢?

@ray-js/robot-map 提供了两种类型的地图组件:
你可以按业务场景需要选择使用 WebViewMap 组件 还是 RjsMap 组件。
首页通常为实时展示的扫地机地图,更推荐使用 WebViewMap 组件,可以按以下方式引入:
import WebViewMap from "@/components/Map/WebViewMap";
// Add your custom logic here
return (
<WebViewMap
map={map}
path={path}
roomProperties={roomProperties}
virtualWalls={virtualWalls}
forbiddenSweepZones={forbiddenSweepZones}
forbiddenMopZones={forbiddenMopZones}
cleanZones={cleanZones}
spots={spots}
runtime={{
enableRoomSelection: false,
selectRoomIds,
editingCleanZoneIds,
}}
onMapReady={handleMapReady}
onMapDrawed={handleMapDrawed}
onClickRoom={handleClickRoom}
onClickRoomProperties={handleClickRoom}
/>
);
你可以通过文档查阅更多有关于 @ray-js/robot-map 的详细信息:
$ npx serve node_modules/@ray-js/robot-map-sdk/dist-docs
那么,地图和路径数据是怎么注入到这个组件的呢?
模板封装了 @ray-js/robot-data-stream 工具库,内置了 P2P 初始化、建立连接、下载数据流、销毁等一系列流程,只需要调用 useP2PDataStream 这个 Hooks,就能实时获得从扫地机设备传输过来的地图、路径数据(前提是您的扫地机设备已经开发支持 P2P 数据传输)。
import { useP2PDataStream } from "@ray-js/robot-data-stream";
import { useMapData, usePathData } from "@/hooks";
// useMapData 是将地图数据进行一些处理后注入到 地图组件 的hooks
const { onMapData } = useMapData();
// usePathData 是将路径数据进行一些处理后注入到 地图组件 的hooks
const { onPathData } = usePathData();
useP2PDataStream(getDevInfo().devId, onMapData, onPathData);
如以上步骤已顺利完成,也许你已经成功在手机上看到了地图,但如果开发阶段就要依赖于手机扫码的方式进行调试,会有些许不便。那么,是否有方法能在 IDE 上也展示实时地图呢?
虽然 IDE 不具备 P2P 连接的环境,但可以借助 扫地机调试助手 插件来实现,具体使用方式可参考 文档。

清扫是扫地机器人最基本的功能。模板内置了 4 种清扫模式:全屋、选区、定点、划区。
所有清扫模式的状态都通过 Redux 进行管理,存储在 mapStateSlice 中。当前激活的清扫模式保存在 currentMode 字段中。
清扫模式的切换非常简单,只需要通过 Redux 更新 currentMode 状态:
import { useDispatch } from "react-redux";
import { updateMapState } from "@/redux/modules/mapStateSlice";
const dispatch = useDispatch();
/**
* 切换清扫模式
* @param mode - 清扫模式:'smart' | 'select_room' | 'pose' | 'zone'
*/
const handleSwitchMode = async (mode: Mode) => {
// 获取视口中心点(用于定点清扫)
let spotPoint = { x: 0, y: 0 };
if (mode === "pose") {
spotPoint = await mapApi.getSpotPointByViewportCenter();
}
dispatch(
updateMapState({
currentMode: mode,
selectRoomIds: [],
spots: mode === "pose" ? [{ id: "0", point: spotPoint }] : [],
cleanZones: mode === "zone" ? [] : [],
})
);
};
定点清扫模式允许用户在地图上添加一个可移动的清扫点。新模板通过 MapApi 实例和 Redux 状态来实现这一功能。
MapApi 实例在地图组件加载完成后会通过 onMapReady 回调返回,并存储到 Redux 中:
import { setMapApi } from "@/redux/modules/mapApisSlice";
const handleMapReady = (mapApi: MapApi) => {
// 将 mapApi 实例存储到 Redux,方便其他组件调用
dispatch(setMapApi({ key: "home", mapApi }));
};
在其他组件中,你可以通过 Redux selector 获取 mapApi 实例,并使用它来获取视口中心的定点坐标:
import { useSelector, useDispatch } from "react-redux";
import { selectMapApiByKey } from "@/redux/modules/mapApisSlice";
import { updateMapState } from "@/redux/modules/mapStateSlice";
const mapApi = useSelector(selectMapApiByKey("home"));
const dispatch = useDispatch();
/**
* 添加一个定点清扫的移动点
*/
const handleAddSpot = async () => {
// 获取视口中心点坐标
const spotPoint = await mapApi.getSpotPointByViewportCenter();
dispatch(
updateMapState({
currentMode: "pose",
spots: [
{
id: "0",
point: spotPoint,
},
],
})
);
};
定点清扫的交互由 WebViewMap 组件的 props 控制:
<WebViewMap
// 定点清扫的点位数据
spots={spots}
runtime={{
// 当机器人不工作且处于定点模式时,允许编辑定点位置
editingSpotIds:
robotIsNotWorking(dpStatus) && currentMode === "pose"
? spots.map((spot) => spot.id)
: [],
}}
// 定点更新回调
onUpdateSpot={(spot: SpotParam) => {
dispatch(updateMapState({ spots: [spot] }));
}}
/>
划区清扫模式允许用户在地图上绘制多个矩形区域进行清扫。
使用 mapApi.getCleanZonePointsByViewportCenter() 方法可以在视口中心生成一个划区框:
import { useSelector, useDispatch } from "react-redux";
import { selectMapApiByKey } from "@/redux/modules/mapApisSlice";
import {
selectMapStateByKey,
updateMapState,
} from "@/redux/modules/mapStateSlice";
import { nanoid } from "@reduxjs/toolkit";
import { offsetPointsToAvoidOverlap } from "@ray-js/robot-map";
const mapApi = useSelector(selectMapApiByKey("home"));
const cleanZones = useSelector(selectMapStateByKey("cleanZones"));
const dispatch = useDispatch();
/**
* 新增划区框
*/
const handleAddCleanZone = async () => {
// 在视口中心生成一个划区框,大小为 1.6m
const zonePoints = await mapApi.getCleanZonePointsByViewportCenter({
size: 1.6,
});
// 避免与现有划区框重叠
offsetPointsToAvoidOverlap(
zonePoints,
cleanZones.map((zone) => zone.points)
);
const newId = nanoid();
dispatch(
updateMapState({
cleanZones: [
...cleanZones,
{
points: zonePoints,
id: newId,
},
],
// 设置为可编辑状态
editingCleanZoneIds: [newId],
})
);
};
<WebViewMap
// 划区清扫的区域数据
cleanZones={cleanZones}
runtime={{
// 当前可编辑的划区ID列表
editingCleanZoneIds,
}}
// 点击划区框时触发
onClickCleanZone={(data: ZoneParam) => {
dispatch(updateMapState({ editingCleanZoneIds: [data.id] }));
}}
// 划区框更新时触发(拖动、缩放)
onUpdateCleanZone={(cleanZone: ZoneParam) => {
dispatch(
updateMapState({
cleanZones: cleanZones.map((zone) =>
zone.id === cleanZone.id ? cleanZone : zone
),
})
);
}}
// 删除划区框时触发
onRemoveCleanZone={(id: string) => {
dispatch(
updateMapState({
cleanZones: cleanZones.filter((zone) => zone.id !== id),
})
);
}}
/>
选区清扫允许用户点击地图上的房间来选择需要清扫的区域。
<WebViewMap
runtime={{
// 开启房间选择模式
enableRoomSelection: currentMode === "select_room",
// 已选中的房间ID列表
selectRoomIds,
}}
// 点击房间时触发
onClickRoom={(data: RoomData) => {
if (robotIsNotWorking(dpStatus) && currentMode === "select_room") {
const { selectRoomIds } = store.getState().mapState;
if (selectRoomIds.includes(data.id)) {
// 取消选择
dispatch(
updateMapState({
selectRoomIds: selectRoomIds.filter((id) => id !== data.id),
})
);
} else {
// 添加选择
dispatch(
updateMapState({
selectRoomIds: [...selectRoomIds, data.id],
})
);
}
}
}}
/>
当用户点击开始清扫按钮时,需要根据当前的清扫模式下发相应的指令:
import {
encodeZoneClean0x3a,
encodeSpotClean0x3e,
encodeRoomClean0x14,
} from "@ray-js/robot-protocol";
import { PROTOCOL_VERSION } from "@/constant";
/**
* 下发划区清扫指令
*/
const handleZoneStart = async () => {
const { cleanZones } = store.getState().mapState;
const command = encodeZoneClean0x3a({
version: PROTOCOL_VERSION,
protocolVersion: 2,
cleanMode: 0,
suction: 1,
cistern: 1,
cleanTimes: 1,
origin: { x: 0, y: 0 },
zones: cleanZones.map((item) => ({
name: "",
points: item.points,
})),
});
await dpActions[commandTransCode].set(command);
await dpActions[modeCode].set("zone");
await dpActions[switchGoCode].set(true);
};
/**
* 下发定点清扫指令
*/
const handlePoseStart = async () => {
const { spots } = store.getState().mapState;
const command = encodeSpotClean0x3e({
version: PROTOCOL_VERSION,
protocolVersion: 1,
cleanMode: 0,
suction: 4,
cistern: 0,
cleanTimes: 2,
origin: { x: 0, y: 0 },
points: spots.map((item) => item.point),
});
await dpActions[commandTransCode].set(command);
await dpActions[modeCode].set("pose");
await dpActions[switchGoCode].set(true);
};
/**
* 下发选区清扫指令
*/
const handleSelectRoomStart = async () => {
const { version, selectRoomIds } = store.getState().mapState;
const data = encodeRoomClean0x14({
cleanTimes: 1,
roomIds: selectRoomIds,
mapVersion: version,
});
dpActions[commandTransCode].set(data);
dpActions[modeCode].set("select_room");
dpActions[switchGoCode].set(true);
};
mapStateSlice 中mapApisSlice 存储和访问,提供地图操作的核心方法WebViewMap 组件通过 props 接收状态,通过回调函数通知状态变更currentMode 字段控制,配合不同的 runtime 配置实现不同的交互模式多地图功能允许扫地机器人保存和管理多张不同的地图(例如不同楼层的地图)。模板采用了快照图片的方式实现多地图列表展示,相比直接渲染多个地图组件实例,这种方式具有更好的性能表现。
建议使用的两种多地图实现方案:
方案 | 优点 | 缺点 | 适用场景 |
快照图片方式(推荐) | 性能优秀,内存占用低,加载速度快 | 图片为静态快照,无法交互 | 多地图列表展示 |
RjsMap 组件方式 | 可交互,功能完整 | 创建多个实例导致性能开销大,内存占用高 | 需要交互的场景 |
模板采用快照图片方式,使用 MapApi.snapshotByData() 方法生成地图快照。
多地图功能的核心逻辑封装在 multiMapsSlice 中,主要包含以下几个部分:
通过 fetchMultiMaps 异步 action 获取设备存储的多地图数据:
import { useDispatch, useSelector } from "react-redux";
import {
fetchMultiMaps,
selectMultiMaps,
} from "@/redux/modules/multiMapsSlice";
const dispatch = useDispatch();
const multiMaps = useSelector(selectMultiMaps);
useEffect(() => {
// 获取多地图列表
dispatch(fetchMultiMaps());
}, []);
fetchMultiMaps 的执行流程:
getMultipleMapFiles API 获取云端地图文件列表模板使用异步任务队列 createAsyncQueue 来串行处理快照生成任务,避免同时处理过多地图导致性能问题:
const taskQueue = createAsyncQueue(
async (params: { filePathKey: string; bucket: string; file: string }) => {
try {
const { filePathKey, bucket, file } = params;
// 1. 从云端下载地图数据
const data = await getMapInfoFromCloudFile({
bucket,
file,
});
const {
virtualState: { virtualWalls, forbiddenMopZones, forbiddenSweepZones },
} = data;
// 2. 获取 home 页面的 MapApi 实例
const homeMapApi = store.getState().mapApis.home;
if (homeMapApi) {
// 3. 使用 snapshotByData 生成快照图片
const snapshotImage = await homeMapApi.snapshotByData({
map: data.originMap,
roomProperties: decodeRoomProperties(data.originMap),
virtualWalls,
forbiddenMopZones,
forbiddenSweepZones,
});
// 4. 将快照存储到 Redux
store.dispatch(
upsertSnapshotImage({ key: filePathKey, snapshotImage })
);
}
} catch (err) {
console.error(err);
}
},
// 队列处理完成后,将所有快照保存到本地缓存
() => {
const { snapshotImageMap } = store.getState().multiMaps;
setStorage({
key: `snapshotImageMap_${devices.common.getDevInfo().devId}`,
data: JSON.stringify(snapshotImageMap),
});
}
);
关键点:
snapshotByData() 方法接收地图原始数据,返回 base64 格式的图片在多地图列表页面,直接使用 Image 组件显示快照:
import { Image } from "@ray-js/ray";
import { useSelector } from "react-redux";
import { ReduxState } from "@/redux";
const Item: FC<{ data: MultiMap }> = ({ data }) => {
const { filePathKey } = data;
// 从 Redux 获取快照图片
const snapshotImage = useSelector(
(state: ReduxState) => state.multiMaps.snapshotImageMap[filePathKey]
);
return (
<View className={styles.mapWrapper}>
{snapshotImage && (
<Image
className={styles.mapImage}
src={snapshotImage}
mode="aspectFit"
/>
)}
{/* 快照未生成时显示 Loading */}
<Loading isLoading={!snapshotImage} />
</View>
);
};
多地图支持两种操作:使用地图 和 删除地图。
import { encodeUseMap0x2e } from "@ray-js/robot-protocol";
import { commandTransCode } from "@/constant/dpCodes";
const handleUseMap = async () => {
const { bucket, robotUseFile, mapId } = data;
// 获取云端文件的完整 URL
const { data: url } = await ossApiInstance.getCloudFileUrl(
bucket,
robotUseFile
);
// 下发使用地图指令
actions[commandTransCode].set(
encodeUseMap0x2e({
mapId,
url,
})
);
};
import { encodeDeleteMap0x2c } from "@ray-js/robot-protocol";
const handleDelete = () => {
const { id } = data;
// 下发删除地图指令
actions[commandTransCode].set(encodeDeleteMap0x2c({ id }));
};
如果需要在多地图列表中实现交互功能,可以使用 RjsMap 组件直接渲染地图:
import RjsMap from "@/components/Map/RjsMap";
const Item: FC<{ data: MultiMap }> = ({ data }) => {
return (
<RjsMap
map={map}
path={path}
roomProperties={roomProperties}
virtualWalls={virtualWalls}
forbiddenSweepZones={forbiddenSweepZones}
forbiddenMopZones={forbiddenMopZones}
cleanZones={cleanZones}
spots={spots}
// ... 其他配置
/>
);
};
API | 说明 |
| 获取多地图列表(异步 thunk) |
| 从云端下载地图数据 |
| 根据地图数据生成快照图片 |
| 更新快照图片到 Redux |
| 选择所有地图列表 |
| 根据 key 选择快照图片 |
地图编辑功能允许用户在地图上设置虚拟限制区域,包括虚拟墙、禁扫区和禁拖区。这些区域会限制扫地机器人的清扫范围,保护特定区域不被打扰。
地图编辑页面使用本地 state 管理所有编辑状态,不直接修改 Redux 中的数据,只有在用户点击"确定"时才下发指令:
const [virtualWalls, setVirtualWalls] = useState<VirtualWallParam[]>(
() => store.getState().mapState.virtualWalls
);
const [forbiddenSweepZones, setForbiddenSweepZones] = useState<ZoneParam[]>(
() => store.getState().mapState.forbiddenSweepZones
);
const [forbiddenMopZones, setForbiddenMopZones] = useState<ZoneParam[]>(
() => store.getState().mapState.forbiddenMopZones
);
// 编辑状态
const [editingVirtualWallIds, setEditingVirtualWallIds] = useState<string[]>(
[]
);
const [editingForbiddenMopZoneIds, setEditingForbiddenMopZoneIds] = useState<
string[]
>([]);
const [editingForbiddenSweepZoneIds, setEditingForbiddenSweepZoneIds] =
useState<string[]>([]);
虚拟墙是一条可拖动两端点的线段,使用 mapApi.getWallPointsByViewportCenter() 在视口中心生成:
import { useThrottleFn } from "ahooks";
import { nanoid } from "@reduxjs/toolkit";
import { offsetPointsToAvoidOverlap } from "@ray-js/robot-map";
const { run: handleVirtualWall } = useThrottleFn(
async () => {
if (!mapApi) return;
// 在视口中心生成虚拟墙,宽度 1.2m
const wallPoints = await mapApi.getWallPointsByViewportCenter({
width: 1.2,
});
// 自动偏移,避免与已有的虚拟墙重叠
offsetPointsToAvoidOverlap(
wallPoints,
virtualWalls.map((wall) => wall.points)
);
const newId = nanoid();
setVirtualWalls([...virtualWalls, { id: newId, points: wallPoints }]);
setEditingVirtualWallIds([newId]);
},
{ wait: 300, leading: true, trailing: false }
);
关键点:
useThrottleFn 节流防止快速点击offsetPointsToAvoidOverlap 自动偏移避免重叠禁扫区是一个可拖动、可缩放的矩形区域:
const { run: handleNoGo } = useThrottleFn(
async () => {
// 在视口中心生成禁扫区,大小 1.6m x 1.6m
const points = await mapApi.getForbiddenSweepZonePointsByViewportCenter({
size: 1.6,
});
// 自动偏移,避免与已有的禁扫区和禁拖区重叠
offsetPointsToAvoidOverlap(points, [
...forbiddenSweepZones.map((zone) => zone.points),
...forbiddenMopZones.map((zone) => zone.points),
]);
const newId = nanoid();
setForbiddenSweepZones([...forbiddenSweepZones, { id: newId, points }]);
setEditingForbiddenSweepZoneIds([newId]);
},
{ wait: 300, leading: true, trailing: false }
);
禁拖区的实现与禁扫区类似,只是使用不同的 API 方法:
const { run: handleNoMop } = useThrottleFn(
async () => {
// 在视口中心生成禁拖区
const points = await mapApi.getForbiddenMopZonePointsByViewportCenter({
size: 1.6,
});
offsetPointsToAvoidOverlap(points, [
...forbiddenSweepZones.map((zone) => zone.points),
...forbiddenMopZones.map((zone) => zone.points),
]);
const newId = nanoid();
setForbiddenMopZones([...forbiddenMopZones, { id: newId, points }]);
setEditingForbiddenMopZoneIds([newId]);
},
{ wait: 300, leading: true, trailing: false }
);
在 WebViewMap 组件中配置虚拟墙和禁区的交互:
<WebViewMap
onMapReady={handleMapReady}
// 数据
virtualWalls={virtualWalls}
forbiddenSweepZones={forbiddenSweepZones}
forbiddenMopZones={forbiddenMopZones}
runtime={{
// 编辑状态
editingVirtualWallIds,
editingForbiddenMopZoneIds,
editingForbiddenSweepZoneIds,
// 隐藏路径
showPath: false,
}}
// 虚拟墙事件
onUpdateVirtualWall={handleUpdateVirtualWall}
onClickVirtualWall={handleClickVirtualWall}
onRemoveVirtualWall={handleRemoveVirtualWall}
// 禁拖区事件
onUpdateForbiddenMopZone={handleUpdateForbiddenMopZone}
onClickForbiddenMopZone={handleClickForbiddenMopZone}
onRemoveForbiddenMopZone={handleRemoveForbiddenMopZone}
// 禁扫区事件
onUpdateForbiddenSweepZone={handleUpdateForbiddenSweepZone}
onClickForbiddenSweepZone={handleClickForbiddenSweepZone}
onRemoveForbiddenSweepZone={handleRemoveForbiddenSweepZone}
/>
更新: 拖动或缩放时触发
const handleUpdateVirtualWall = (wall: VirtualWallParam) => {
setVirtualWalls(virtualWalls.map((w) => (w.id === wall.id ? wall : w)));
};
const handleUpdateForbiddenSweepZone = (zone: ZoneParam) => {
setForbiddenSweepZones(
forbiddenSweepZones.map((z) => (z.id === zone.id ? zone : z))
);
};
点击: 切换编辑状态
const handleClickVirtualWall = (wall: VirtualWallParam) => {
setEditingVirtualWallIds([wall.id]);
};
const handleClickForbiddenSweepZone = (zone: ZoneParam) => {
setEditingForbiddenSweepZoneIds([zone.id]);
};
删除: 移除禁区或虚拟墙
const handleRemoveVirtualWall = (removedId: string) => {
setVirtualWalls(virtualWalls.filter((w) => w.id !== removedId));
setEditingVirtualWallIds(
editingVirtualWallIds.filter((id) => id !== removedId)
);
};
取消按钮: 恢复到进入页面时的状态
const handleReset = () => {
// 从 Redux store 中重新获取初始数据
const initialState = store.getState().mapState;
// 重置所有数据到初始状态
setVirtualWalls(initialState.virtualWalls);
setForbiddenSweepZones(initialState.forbiddenSweepZones);
setForbiddenMopZones(initialState.forbiddenMopZones);
// 清空所有编辑状态
setEditingVirtualWallIds([]);
setEditingForbiddenMopZoneIds([]);
setEditingForbiddenSweepZoneIds([]);
};
确定按钮: 下发指令保存到设备
import {
encodeVirtualArea0x38,
encodeVirtualWall0x12,
} from "@ray-js/robot-protocol";
import { PROTOCOL_VERSION } from "@/constant";
const handleConfirm = async () => {
// 编码禁区指令(禁扫区 + 禁拖区)
const zonesCommand = encodeVirtualArea0x38({
version: PROTOCOL_VERSION,
protocolVersion: 1,
virtualAreas: forbiddenSweepZones
.map((item) => ({
points: item.points,
mode: 1, // 禁扫区
name: "",
}))
.concat(
forbiddenMopZones.map((item) => ({
points: item.points,
mode: 2, // 禁拖区
name: "",
}))
),
origin: { x: 0, y: 0 },
});
// 编码虚拟墙指令
const virtualWallsCommand = encodeVirtualWall0x12({
version: PROTOCOL_VERSION,
origin: { x: 0, y: 0 },
walls: virtualWalls.map((item) => item.points),
});
// 下发指令
actions[commandTransCode].set(zonesCommand);
actions[commandTransCode].set(virtualWallsCommand);
// 清空编辑状态
setEditingForbiddenMopZoneIds([]);
setEditingForbiddenSweepZoneIds([]);
setEditingVirtualWallIds([]);
};
offsetPointsToAvoidOverlap 自动偏移新创建的禁区useThrottleFn 防止用户快速点击导致创建多个禁区房间编辑功能允许用户对地图上的房间进行管理,包括房间合并、房间分割、房间重命名和清扫顺序设置。这些功能帮助用户更好地组织和控制清扫任务。
房间编辑使用多层状态管理,临时状态只在确定时才提交:
type RoomEditStatus = "normal" | "split" | "merge" | "reName" | "order";
// 编辑状态
const [roomEditStatus, setRoomEditStatus] = useState<RoomEditStatus>("normal");
const [enableRoomSelection, setEnableRoomSelection] = useState(false);
const [selectRoomIds, setSelectRoomIds] = useState<number[]>([]);
const [dividingRoomId, setDividingRoomId] = useState<number | null>(null);
// 房间选择模式
const [roomSelectionMode, setRoomSelectionMode] = useState<
"checkmark" | "order"
>("checkmark");
// 临时状态(未提交)
const [tempCleaningOrder, setTempCleaningOrder] = useState<
Record<number, number>
>({});
const [tempName, setTempName] = useState<Record<number, string>>({});
// 合并原始数据和临时状态
const finalRoomProperties = useMemo(() => {
return roomProperties.map((room) => ({
...room,
order: tempCleaningOrder[room.id] ?? room.order ?? 0,
name: tempName[room.id] ?? room.name ?? "",
}));
}, [roomProperties, tempCleaningOrder, tempName]);
合并两个相邻的房间为一个:
/**
* 进入合并模式
*/
const handleMerge = async () => {
setEnableRoomSelection(true);
setSelectRoomIds([]);
setRoomEditStatus("merge");
setRoomSelectionMode("checkmark");
};
/**
* 点击房间选择逻辑
*/
const handleClickRoom = (room: RoomData) => {
if (roomEditStatus === "merge") {
if (selectRoomIds.includes(room.id)) {
// 取消选择
const newSelectRoomIds = selectRoomIds.filter((id) => id !== room.id);
setSelectRoomIds(newSelectRoomIds);
setActiveConfirm(newSelectRoomIds.length === 2);
} else {
if (selectRoomIds.length >= 2) {
// 只能合并两个房间
ToastInstance("只能选择两个房间进行合并");
return;
}
const newSelectRoomIds = [...selectRoomIds, room.id];
setSelectRoomIds(newSelectRoomIds);
setActiveConfirm(newSelectRoomIds.length === 2);
}
}
};
/**
* 确定合并
*/
const handleConfirmMerge = async () => {
// 检查房间是否相邻
const isAdjacent = await mapApi.areRoomsAdjacent(selectRoomIds);
if (!isAdjacent) {
ToastInstance.fail("只能合并相邻的房间");
return;
}
showLoading({ title: "" });
// 编码合并指令
const command = encodePartitionMerge0x1e({
roomIds: selectRoomIds,
version: PROTOCOL_VERSION,
});
actions[commandTransCode].set(command);
// 设置超时提示
timerRef.current = setTimeout(() => {
hideLoading();
ToastInstance.fail({ message: "合并失败" });
}, 20 * 1000);
};
关键点:
mapApi.areRoomsAdjacent() 检查房间是否相邻在房间内绘制分割线,将其分成两个房间:
/**
* 进入分割模式
*/
const handleSplit = () => {
setActiveConfirm(false);
setRoomEditStatus("split");
setEnableRoomSelection(true);
setSelectRoomIds([]);
setRoomSelectionMode("checkmark");
};
/**
* 点击房间选择要分割的房间
*/
const handleClickRoom = (room: RoomData) => {
if (roomEditStatus === "split") {
setSelectRoomIds([room.id]);
setDividingRoomId(room.id); // 启用分割线绘制
setActiveConfirm(true);
}
};
/**
* 分割线更新时,检查是否有效
*/
const handleUpdateDivider = async () => {
const effectiveDividerPoints = await mapApi.getEffectiveDividerPoints();
if (!effectiveDividerPoints) {
setActiveConfirm(false); // 无效的分割线,禁用确定按钮
} else {
setActiveConfirm(true);
}
};
/**
* 确定分割
*/
const handleConfirmSplit = async () => {
// 获取有效的分割线点位
const points = await mapApi.getEffectiveDividerPoints();
const command = encodePartitionDivision0x1c({
roomId: dividingRoomId,
points,
origin: { x: 0, y: 0 },
version: PROTOCOL_VERSION,
});
actions[commandTransCode].set(command);
};
关键点:
dividingRoomId 后,地图组件会显示分割线mapApi.getEffectiveDividerPoints() 获取分割线点位为房间设置自定义名称:
/**
* 进入重命名模式
*/
const handleRename = () => {
setRoomEditStatus("reName");
setEnableRoomSelection(true);
setSelectRoomIds([]);
setRoomSelectionMode("checkmark");
};
/**
* 点击房间显示重命名弹窗
*/
const handleClickRoom = (room: RoomData) => {
if (roomEditStatus === "reName") {
setSelectRoomIds([room.id]);
setShowRenameModal(true);
}
};
/**
* 重命名弹窗确定
*/
const handleRenameConfirm = (name: string) => {
setShowRenameModal(false);
setTempName({ ...tempName, [selectRoomIds[0]]: name });
setActiveConfirm(true);
};
/**
* 确定重命名
*/
const handleConfirmRename = async () => {
const { version } = store.getState().mapState;
const command = encodeSetRoomName0x24({
mapVersion: version,
version: PROTOCOL_VERSION,
rooms: Object.entries(tempName).map(([roomId, name]) => ({
roomId: Number(roomId),
name,
})),
});
actions[commandTransCode].set(command);
showLoading({ title: "" });
};
关键点:
tempName 存储未提交的房间名称设置房间的清扫先后顺序:
/**
* 进入排序模式
*/
const handleOrder = async () => {
setRoomEditStatus("order");
setEnableRoomSelection(true);
setSelectRoomIds([]);
setRoomSelectionMode("order"); // 使用顺序选择模式
};
/**
* 点击房间设置/取消顺序
*/
const handleClickRoom = (room: RoomData) => {
if (roomEditStatus === "order") {
const currentOrder =
finalRoomProperties.find((r) => r.id === room.id)?.order || 0;
setTempCleaningOrder((prev) => {
if (currentOrder > 0) {
// 取消顺序,其他房间顺序递减
const updates = { ...prev, [room.id]: 0 };
finalRoomProperties.forEach((r) => {
if (r.order > currentOrder) {
const originalOrder =
roomProperties.find((orig) => orig.id === r.id)?.order || 0;
updates[r.id] = (prev[r.id] ?? originalOrder) - 1;
}
});
return updates;
}
// 设置新顺序(追加到末尾)
const maxOrder = Math.max(0, ...finalRoomProperties.map((r) => r.order));
return { ...prev, [room.id]: maxOrder + 1 };
});
}
};
/**
* 确定排序
*/
const handleConfirmOrder = async () => {
const { version } = store.getState().mapState;
// 按顺序排列房间 ID
const roomIds = finalRoomProperties
.sort((a, b) => a.order - b.order)
.map((room) => room.id);
const command = encodeRoomOrder0x26({
version: PROTOCOL_VERSION,
roomIds,
mapVersion: version,
});
actions[commandTransCode].set(command);
showLoading({ title: "" });
};
关键点:
roomSelectionMode: 'order' 显示顺序数字<WebViewMap
roomProperties={finalRoomProperties}
runtime={{
// 启用房间选择
enableRoomSelection,
selectRoomIds,
// 显示房间顺序数字
showRoomOrder: true,
// 分割模式下的房间 ID
dividingRoomId,
// 选择模式:checkmark(勾选)或 order(顺序)
roomSelectionMode,
// 隐藏路径
showPath: false,
}}
onMapReady={handleMapReady}
onMapFirstDrawed={handleMapFirstDrawed}
onClickRoom={handleClickRoom}
onClickRoomProperties={handleClickRoom}
onUpdateDivider={handleUpdateDivider}
/>
监听设备的指令回复,处理成功/失败状态:
useEffect(() => {
const handleRoomEditResponse = ({ cmd, command }) => {
if (timerRef.current) {
// 房间分割回复
if (cmd === PARTITION_DIVISION_CMD_ROBOT_V1) {
const splitResponse = decodePartitionDivision0x1d({ command });
if (splitResponse) {
clearTimeout(timerRef.current);
hideLoading();
handleNormal(); // 恢复正常状态
if (splitResponse.success) {
ToastInstance.success({ message: "分割成功" });
} else {
ToastInstance.fail({ message: "分割失败" });
}
}
}
// 房间合并回复
if (cmd === PARTITION_MERGE_CMD_ROBOT_V1) {
const mergeResponse = decodePartitionMerge0x1f({ command });
// ... 类似处理
}
// 房间重命名回复
if (cmd === SET_ROOM_NAME_CMD_ROBOT_V1) {
const roomNameResponse = decodeSetRoomName0x25({
command,
version: PROTOCOL_VERSION,
mapVersion: store.getState().mapState.version,
});
// ... 类似处理
}
}
};
emitter.on("receiveRoomEditResponse", handleRoomEditResponse);
return () => {
emitter.off("receiveRoomEditResponse", handleRoomEditResponse);
};
}, []);
定时功能使用 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>
);
清扫记录详情页面展示单次清扫任务的完整信息,包括清扫时间、清扫面积、清扫模式以及对应的地图和路径。
从 Redux store 获取清扫记录,并从云端下载对应的地图文件:
// 获取清扫记录
const { bucket, file, extendInfo } = useSelector((state: ReduxState) =>
selectCleanRecordById(state, Number(id))
);
const { mapLength, pathLength, cleanMode, time, area } = extendInfo;
// 从云端加载地图数据
useEffect(() => {
const fetchHistoryMap = async () => {
const mapData = await getMapInfoFromCloudFile({
bucket,
file,
mapLen: mapLength,
pathLen: pathLength,
});
if (mapData) {
const { originMap, originPath, virtualState } = mapData;
setMap(originMap);
setPath(originPath);
// 解析虚拟墙和禁区
setVirtualWalls(
virtualState.virtualWallData.map((points) => ({
points,
id: nanoid(),
}))
);
setForbiddenSweepZones(
virtualState.virtualAreaData.map(({ points }) => ({
points,
id: nanoid(),
}))
);
setForbiddenMopZones(
virtualState.virtualMopAreaData.map(({ points }) => ({
points,
id: nanoid(),
}))
);
}
};
fetchHistoryMap();
}, [bucket, file, mapLength, pathLength]);
使用 WebViewMap 组件渲染历史地图和路径:
<WebViewMap
map={map} // 地图数据
path={path} // 清扫路径
virtualWalls={virtualWalls} // 虚拟墙
forbiddenSweepZones={forbiddenSweepZones} // 禁扫区
forbiddenMopZones={forbiddenMopZones} // 禁拖区
/>
关键点:
map 和 path props 直接传入历史数据,而非实时数据
关于语音包数据的获取,请参考 机器语音 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 提供的 encodeVoice0x34 和 decodeVoice0x35 完成 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 通用模板 教程。