UI

本文档的某些部分已被弃用,请参阅 rmf-web 了解最新开发情况。

简介

本章介绍如何编写集成到 RMF 的最终用户应用程序。我们将简要介绍 RMF UI 应用程序的要求,并提供编写与 RMF 交互的 React Webapp 的教程。

概述

编写 UI 应用程序最常见的方法是使用 UI 框架。有许多可用的 UI 框架,以下是一些流行的框架供参考:

  • Multi-Paradigm
    • Qt
    • React native
    • Xamarin
    • Flutter
  • Web
    • React
    • Angular
    • Vue
  • Desktop
    • wxWidgets
    • Gtk
    • WPF (Windows only)

每个框架都有优点和缺点,我们并不认为存在一个适用于所有用例的“最佳”框架。话虽如此,由于其特定的底层技术,某些框架更适合与 RMF 集成。为了理解原因,让我们简要概述一下 RMF UI 应用程序的工作原理。

RMF UI 应用程序如何工作?

想象一个简单的 UI 应用程序,它有一个标签,显示门的当前状态,还有一个打开/关闭门的按钮。回想一下,在门适配器中,我们发布门状态并订阅门请求。在这里,我们做相反的事情;订阅门状态并发布门请求。我们将监听来自 RMF 的门状态并更新我们的显示以匹配,并且我们还将在用户单击打开或关闭按钮时发送门请求。

我们需要使用库来帮助我们做到这一点。在硬件适配器示例中,我们使用 rclcpp 库,但 rclcpp 并不是使用 ROS 2 的唯一方法。以下是一些同样有效的库

  • Direct
    • rcl (C)
    • rclcpp (C++)
    • rclpy (python)
    • rclnodejs (nodejs)
  • Indirect
    • SOSS (websocket)
    • rosbridge (websocket)

“直接”库能够直接从应用程序发送和接收 ROS 2 消息,而间接库则使用中间人服务来转发 ROS 2 消息。一般来说,“直接”方法更可取,但有时在目标平台上无法实现。在这些情况下,可以使用间接方法。

Note: This is not an exhaustive list, you can find many third-party projects that aim to bring the ROS 2 ecosystem to more platforms, the ros2-dotnet project for example, provides C# bindings for ROS 2. You can also write your own bindings and middlewares!

编写 RMF UI 应用程序与编写任何其他 UI 应用程序没有太大区别,唯一的区别在于我们将根据用户输入发送/接收 ROS 2 数据并更新 UI 的状态。

教程:React WebApp

在本节中,我们将通过一个示例来创建基于 React 的 Web 应用程序来监控门状态并发送门打开/关闭请求。本教程将重点介绍与 RMF 通信的各个方面;需要具备 React 和 TypeScript 的基本知识。

请注意,这不是创建 RMF UI 应用程序的唯一方法,如前所述,您可以使用任何 UI 工具包,唯一的要求是能够发送/接收 ROS 2 消息。

本教程的代码已发布 here.

要求

我们不会介绍设置依赖项的过程,可以在网络上或项目主页上轻松找到设置依赖项的说明。

设置

我们将使用 rmf_demo 中的示例作为我们将与之交互的 RMF 部署。这是测试您的 rmf_demo 安装是否正常运行的好时机,使用以下命令启动演示:

ros2 launch demos office.launch.xml

接下来让我们测试一下 SOSS 是否正常工作。为了运行 SOSS,您需要为其提供一个配置文件;您可以使用 模板作为开始。您需要一个 SOSS 证书;请参阅网上的各种教程来生成证书。如果您使用的是自签名证书,还要确保您的浏览器设置为接受它进行 websocket 连接。将您的证书和密钥的路径添加到 SOSS 配置并尝试使用以下命令启动 SOSS:

soss <path_to_config>

环境设置完成后,我们就可以继续设置实际的应用程序。首先,创建一个 React 工作区:

npx create-react-app react-app-tutorial --template typescript

进入新创建的“react-app-tutorial”目录并运行以下命令来安装我们将使用的所有 JavaScript 依赖项:

npm install @osrf/romi-js-core-interfaces @osrf/romi-js-soss-transport jsonwebtoken @types/jsonwebtoken

这些库不是严格必需的,但它们包含使用“soss”和与 RMF 通信的有用函数。如果您正在构建基于 JavaScript 的 RMF 应用程序,建议使用它们,我们稍后会看到它们如何简化与 RMF 的通信。

Note: Other than @osrf/romi-js-soss-transport, there is also @osrf/romi-js-rclnodejs-transport which is able to send ROS 2 messages directly, however it does not work on the browser. It is preferred if you are writing a Node.js based desktop application using something like Electron, or you are writing a server based application like a REST API provider.

门组件

首先,让我们从简单开始,创建一个组件来显示门的状态以及打开和关闭按钮。在“react-app-tutorial/src”目录中创建一个名为“Door.tsx”的新文件,并将以下代码片段复制到其中。

import React from 'react'; export interface DoorProps { name: string; state: string; onOpenClick?(e: React.MouseEvent): void; onCloseClick?(e: React.MouseEvent): void; } export const Door = (props: DoorProps) => { const { name, state, onOpenClick, onCloseClick } = props; return ( <div> Door: {name} <br /> State: {state} <br /> <button onClick={(e) => onOpenClick && onOpenClick(e)}>Open</button> <button onClick={(e) => onCloseClick && onCloseClick(e)}>Close</button> <br /> <br /> </div> ); }; export default Door;

这里还没有发生什么。我们只是渲染门的名称、状态以及打开和关闭按钮。

让我们通过运行它来测试一下。将“App.tsx”替换为以下内容:

import React from 'react'; import Door from './Door'; function App() { return <Door name="example_door" state="Closed" />; } export default App;

并使用“npm start”启动它。你应该看到类似这样的内容:

Door

太棒了!现在我们有了开始实现应用程序其余部分的基础。

获取门列表

之前我们制作了一个简单的门组件,并测试了使用硬编码值渲染它。显然这在合适的应用程序中不起作用,所以在这里我们将研究如何从 RMF 获取实际门的列表。

首先添加一个 React 状态来跟踪门列表:

const [doors, setDoors] = React.useState<RomiCore.Door[]>([]);

RMF 有一个“get_building_map”服务,我们可以使用它来获取门、电梯、楼层等许多其他数据的列表,为了使用该服务,我们需要进行 ROS 2 服务调用,因为浏览器不支持 ROS 2,我们将使用“soss”的“间接”方法。与 SOSS 建立 websocket 连接,然后 SOSS 将充当中间人并将我们的消息传递到 ROS 2 网络。

使用 SOSS 的一个简单方法是使用 @osrf/romi-js-soss-transport 包,现在就开始吧。将一个 React Effect Hook 添加到你的 App 组件中

React.useEffect(() => { (async () => { const token = jwt.sign({ user: 'example-user' }, 'rmf', { algorithm: 'HS256' }); const transport = await SossTransport.connect('example', 'wss://localhost:50001', token); })(); }, []);

我们需要导入“SossTransport”,因此将其添加到文件顶部:

import { SossTransport } from '@osrf/romi-js-soss-transport';

这将执行与 SOSS 服务器的 websocket 连接。example 是我们将要使用的 ROS 2 节点名称,我们将连接到位于 wss://localhost:50001 的 SOSS 服务器。该服务器使用在 SOSS 配置中指定的密钥签名的 JWT 令牌。示例配置使用 rmf。如果您更改了密钥,请确保也在此处进行更改。

Note: This example is only for convenience, you should never reveal the secret to the client. Usually the client would connect to an authentication server which will verify that it is a valid request and return a signed token.

现在我们已经连接到 SOSS,我们可以调用“get_building_map”服务。将其添加到 React 效果中:

const buildingMap = (await transport.call(RomiCore.getBuildingMap, {})).building_map; setDoors(buildingMap.levels.flatMap((level) => level.doors));

它使用“RomiCore”,因此将其添加到您的导入中:

import * as RomiCore from '@osrf/romi-js-core-interfaces';

这将从 RMF 下载并解析建筑地图。romi-js 使用异步 call 方法简化了 ROS 2 服务调用。如果您熟悉 rclnodejs,这大致相当于:

const client = node.createClient( 'building_map_msgs/srv/GetBuildingMap', 'get_building_map' ); client.sendRequest({}, response => { const buildingMap = response.building_map; setDoors(buildingMap.levels.flatMap((level) => level.doors)); });

请注意,我们需要为其提供消息类型(building_map_msgs/srv/GetBuildingMap)和服务名称(get_building_map),但我们如何找出服务名称和类型呢?我们可以在 RMF 运行时阅读 RMF 手册或查询 ROS 2 系统。另一种方法是借助RomiCore;它提供了已知 RMF 服务和消息的列表,因此您不必费心自己寻找它们。

在下面的语句中,我们使用RomiCore调用get_building_map服务,而无需知道服务名称和类型:

transport.call(RomiCore.getBuildingMap, {})

现在我们有了“RomiCore.Door”列表,让我们通过更新“Door.tsx”将其作为 prop 来简化事情。在此过程中,我们还可以让它接受“RomiCore.DoorState”作为 prop,因为我们稍后会使用它。

Door.tsx:

import * as RomiCore from '@osrf/romi-js-core-interfaces'; import React from 'react'; export interface DoorProps { door: RomiCore.Door; doorState?: RomiCore.DoorState; onOpenClick?(e: React.MouseEvent): void; onCloseClick?(e: React.MouseEvent): void; } export const Door = (props: DoorProps) => { const { door, doorState, onOpenClick, onCloseClick } = props; return ( <div> Door: {door.name} <br /> State: {doorState ? doorState.current_mode.value : 'Unknown'} <br /> <button onClick={(e) => onOpenClick && onOpenClick(e)}>Open</button> <button onClick={(e) => onCloseClick && onCloseClick(e)}>Close</button> <br /> <br /> </div> ); }; export default Door;

现在我们可以通过将门作为 props 传递来测试它。你的 App.tsx 组件现在应该如下所示:

import * as RomiCore from '@osrf/romi-js-core-interfaces'; import { SossTransport } from '@osrf/romi-js-soss-transport'; import * as jwt from 'jsonwebtoken'; import React from 'react'; import Door from './Door'; function App() { const [doors, setDoors] = React.useState<RomiCore.Door[]>([]); React.useEffect(() => { (async () => { const token = jwt.sign({ user: 'example-user' }, 'rmf', { algorithm: 'HS256' }); const transport = await SossTransport.connect('example', 'wss://localhost:50001', token); const buildingMap = (await transport.call(RomiCore.getBuildingMap, {})).building_map; setDoors(buildingMap.levels.flatMap((level) => level.doors)); })(); }, []); return ( <React.Fragment> {doors.map((door) => ( <Door door={door} /> ))} </React.Fragment> ); } export default App;

现在不用担心门的状态。如果一切顺利,您应该会看到列出的建筑物内的 3 扇门:

Doors

监听门的状态

之前我们设法在 RMF 系统中渲染了门的列表,但建筑地图并没有告诉我们门的状态,所以让我们来解决这个问题。首先,让我们添加一个 React 状态来跟踪门的状态:

const [doorStates, setDoorStates] = React.useState<Record<string, RomiCore.DoorState>>({});

可以通过订阅“door_states”主题来获取门的状态。将以下内容添加到您的效果中:

transport.subscribe(RomiCore.doorStates, (doorState) => setDoorStates((prev) => ({ ...prev, [doorState.door_name]: doorState })), );

这将执行对 RomiCore.doorStates 主题的 ROS 2 订阅。与我们之前执行的服务调用类似,romi-js 抽象出 ROS 2 主题名称并提供类型信息。每次收到新的门状态消息时都会触发回调。在回调中,我们只需更新 doorStates 状态即可。

现在只需将门状态传递给门组件:

<Door door={door} doorState={doorStates[door.name]} />

你的 App.tsx 的最终结果应该如下所示:

import * as RomiCore from '@osrf/romi-js-core-interfaces'; import { SossTransport } from '@osrf/romi-js-soss-transport'; import * as jwt from 'jsonwebtoken'; import React from 'react'; import Door from './Door'; function App() { const [doors, setDoors] = React.useState<RomiCore.Door[]>([]); const [doorStates, setDoorStates] = React.useState<Record<string, RomiCore.DoorState>>({}); React.useEffect(() => { (async () => { const token = jwt.sign({ user: 'example-user' }, 'rmf', { algorithm: 'HS256' }); const transport = await SossTransport.connect('example', 'wss://localhost:50001', token); const buildingMap = (await transport.call(RomiCore.getBuildingMap, {})).building_map; setDoors(buildingMap.levels.flatMap((level) => level.doors)); transport.subscribe(RomiCore.doorStates, (doorState) => setDoorStates((prev) => ({ ...prev, [doorState.door_name]: doorState })), ); })(); }, []); return ( <React.Fragment> {doors.map((door) => ( <Door door={door} doorState={doorStates[door.name]} /> ))} </React.Fragment> ); } export default App;

就这样,我们现在有了门的状态!

带门状态

门状态是数字,如 012。这是因为 RMF 使用常量来表示门状态。我们可以运行一个简单的函数将这些常量转换为字符串:

function doorModeString(doorMode: RomiCore.DoorMode): string { switch (doorMode.value) { case 2: return 'Open'; case 0: return 'Closed'; case 1: return 'Moving'; default: return 'Unknown'; } }

但是我们怎么知道“2”表示“打开”等?我们可以通过阅读 RMF 手册或检查 ROS 2 消息定义来找到答案,但我们可以使用“RomiCore”做得更好。它以更易读的形式提供了常量列表:

function doorModeString(doorMode: RomiCore.DoorMode): string { switch (doorMode.value) { case RomiCore.DoorMode.MODE_OPEN: return 'Open'; case RomiCore.DoorMode.MODE_CLOSED: return 'Closed'; case RomiCore.DoorMode.MODE_MOVING: return 'Moving'; default: return 'Unknown'; } }

这样,每个常量代表什么就一目了然了,所以我们不必参考任何其他东西来找到它的含义。

继续将其添加到您的“Door.tsx”中。它现在应该看起来像这样:

import * as RomiCore from '@osrf/romi-js-core-interfaces'; import React from 'react'; export interface DoorProps { door: RomiCore.Door; doorState?: RomiCore.DoorState; onOpenClick?(e: React.MouseEvent): void; onCloseClick?(e: React.MouseEvent): void; } export const Door = (props: DoorProps) => { const { door, doorState, onOpenClick, onCloseClick } = props; const modeString = doorState ? doorModeString(doorState.current_mode) : 'Unknown'; return ( <div> Door: {door.name} <br /> State: {modeString} <br /> <button onClick={(e) => onOpenClick && onOpenClick(e)}>Open</button> <button onClick={(e) => onCloseClick && onCloseClick(e)}>Close</button> <br /> <br /> </div> ); }; function doorModeString(doorMode: RomiCore.DoorMode): string { switch (doorMode.value) { case RomiCore.DoorMode.MODE_OPEN: return 'Open'; case RomiCore.DoorMode.MODE_CLOSED: return 'Closed'; case RomiCore.DoorMode.MODE_MOVING: return 'Moving'; default: return 'Unknown'; } } export default Door;

Great! Now we have readable door states instead of cryptic numbers.

With door states

发送门请求

正如您现在可能已经预料到的那样,我们在这里要做的就是向 RMF 发送门请求。

首先,创建一个发布者。将其添加到渲染函数的开头:

const doorRequestPub = React.useRef<RomiCore.Publisher<RomiCore.DoorRequest> | null>(null);

然后添加这个辅助函数:

const requestDoor = (door: RomiCore.Door, mode: number) => { if (doorRequestPub.current) { const request: RomiCore.DoorRequest = { door_name: door.name, request_time: RomiCore.toRosTime(new Date()), requested_mode: { value: mode }, requester_id: 'example-request', }; doorRequestPub.current.publish(request); } };

它接收一个 RomiCore.Door 和一个代表所需模式的数字,并制作一个 RomiCore.DoorRequest 消息并使用发布者发送它。通常,您必须查阅 RMF 手册或 ROS 2 定义才能确切知道您需要发送什么。同样,RomiCore 提供了输入信息,以便更轻松地填写必填字段。

最后,将其添加到传递给门组件的 props 中:

onOpenClick={() => requestDoor(door, RomiCore.DoorMode.MODE_OPEN)} onCloseClick={() => requestDoor(door, RomiCore.DoorMode.MODE_CLOSED)}

你最终的 App.tsx 应该如下所示:

import * as RomiCore from '@osrf/romi-js-core-interfaces'; import { SossTransport } from '@osrf/romi-js-soss-transport'; import * as jwt from 'jsonwebtoken'; import React from 'react'; import Door from './Door'; function App() { const [doors, setDoors] = React.useState<RomiCore.Door[]>([]); const [doorStates, setDoorStates] = React.useState<Record<string, RomiCore.DoorState>>({}); const doorRequestPub = React.useRef<RomiCore.Publisher<RomiCore.DoorRequest> | null>(null); React.useEffect(() => { (async () => { const token = jwt.sign({ user: 'example-user' }, 'rmf', { algorithm: 'HS256' }); const transport = await SossTransport.connect('example', 'wss://localhost:50001', token); const buildingMap = (await transport.call(RomiCore.getBuildingMap, {})).building_map; setDoors(buildingMap.levels.flatMap((level) => level.doors)); transport.subscribe(RomiCore.doorStates, (doorState) => setDoorStates((prev) => ({ ...prev, [doorState.door_name]: doorState })), ); doorRequestPub.current = transport.createPublisher(RomiCore.adapterDoorRequests); })(); }, []); const requestDoor = (door: RomiCore.Door, mode: number) => { if (doorRequestPub.current) { const request: RomiCore.DoorRequest = { door_name: door.name, request_time: RomiCore.toRosTime(new Date()), requested_mode: { value: mode }, requester_id: 'example-request', }; doorRequestPub.current.publish(request); } }; return ( <React.Fragment> {doors.map((door) => ( <Door door={door} doorState={doorStates[door.name]} onOpenClick={() => requestDoor(door, RomiCore.DoorMode.MODE_OPEN)} onCloseClick={() => requestDoor(door, RomiCore.DoorMode.MODE_CLOSED)} /> ))} </React.Fragment> ); } export default App;

现在尝试单击打开和关闭按钮。您应该会看到门状态正在更新。您还可以在 Gazebo 中看到门打开/关闭。 恭喜,您刚刚编写了一个简单的 RMF UI 应用程序!显然,设计还有很多不足之处,因为我们没有做任何 CSS 样式,但这超出了本教程的范围。

扩展它以提供更多功能,如电梯控制、车队状态等,遵循相同的原则。RMF 公开的所有可用主题和服务都可以在“RomiCore”中找到,您可以通过阅读手册的其余部分找到更详细的信息。这也扩展到为其他平台和框架编写 UI 应用程序;从本质上讲,您实际上只是发布和订阅 ROS 2 消息,因此您可以将相同的原则应用于其他语言和框架。

结论

我们刚刚创建了一个最小的 RMF UI 应用程序,它报告门状态并允许用户控制门。为简单起见,本教程中没有包含太多功能,但本教程应提供如何创建 RMF UI 应用程序的基本知识,不仅在 React 中,而且在您喜欢的任何框架中。

如果您想要更多 React RMF 应用程序的示例,您可以查看官方 RoMi 仪表板

附加:扩展 romi-js

在整个教程中,我们使用 romi-js 来简化与 RMF 的通信。您可能已经注意到,romi-js 实际上是一组包。这种设计使得可以轻松地使用新主题、服务甚至传输来扩展它。

添加主题和服务

主题和服务由以下接口定义:

export interface RomiTopic<Message> { readonly validate: (msg: any) => Message; readonly type: string; readonly topic: string; readonly options?: Options; } export interface RomiService<Request, Response> { readonly validateRequest: (msg: any) => Request; readonly validateResponse: (msg: any) => Response; readonly type: string; readonly service: string; readonly options?: Options; }

如果您熟悉 ROS 2,则 type 字段指定主题或服务期望的消息类型,而 topic/service 分别是主题和服务名称。有时,主题或服务需要使用不同的 QoS 选项;例如,当状态发生变化时不发布并期望后期订阅使用瞬时本地 QoS 来接收最新状态的主题。options 指定应使用的“默认”QoS 选项。这样,用户不必参考使用说明来正确发布和订阅主题。

传输使用 validate* 方法将任意对象转换为主题或服务的预期类型。它应该检查对象是否具有正确的字段以及字段是否属于正确的类型。为了确保与不同传输的兼容性,这些方法应该能够将数字数组转换为类型数组,反之亦然。

我们可以通过实现这些接口来创建自定义主题或服务。然后可以将它们传递给传输的各种方法。

export const myTopic: RomiTopic<MyMessage> = { validate: validateMyMessage(msg), // some function that valides MyMessage type: 'my_messages/msg/MyMessage', topic: 'my_topic', };

添加传输

romi-js 中的 Transport 是一个具有以下接口的类:

export interface Subscription { unsubscribe(): void; } export interface Publisher<Message> { publish(msg: Message): void; } export interface Service<Request, Response> { start(handler: (req: Request) => Promise<Response> | Response): void; stop(): void; } export interface Transport extends TransportEvents { readonly name: string; createPublisher<Message extends unknown>( topic: RomiTopic<Message>, options?: Options, ): Publisher<Message>; subscribe<Message extends unknown>( topic: RomiTopic<Message>, cb: SubscriptionCb<Message>, options?: Options, ): Subscription; call<Request extends unknown, Response extends unknown>( service: RomiService<Request, Response>, req: Request, ): Promise<Response>; createService<Request extends unknown, Response extends unknown>( service: RomiService<Request, Response>, ): Service<Request, Response>; destroy(): void; }

没有关于如何实现接口的通用指南,因为每个传输的细节都会有所不同。需要注意的一点是,返回从“any”派生的类型(例如“Publisher”)以通过 typescript 检查可能很诱人,但不建议这样做。您应该调用主题或服务中的“validate*”方法将某些内容转换为“Message”类型。

为了确保与不同主题和服务的兼容性,传输必须将数据反序列化为普通的旧数据对象。它可以使用数字数组或类型数组。“validate*”方法应该支持将它们转换为预期的类型。