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 消息。一般来说,“直接”方法更可取,但有时在目标平台上无法实现。在这些情况下,可以使用间接方法。
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.
要求
- nodejs >= 10
- rmf_core
- soss
- rmf-soss-ros2
- rmf_demos
我们不会介绍设置依赖项的过程,可以在网络上或项目主页上轻松找到设置依赖项的说明。
设置
我们将使用 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 的通信。
@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”启动它。你应该看到类似这样的内容:
太棒了!现在我们有了开始实现应用程序其余部分的基础。
获取门列表
之前我们制作了一个简单的门组件,并测试了使用硬编码值渲染它。显然这在合适的应用程序中不起作用,所以在这里我们将研究如何从 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
。如果您更改了密钥,请确保也在此处进行更改。
现在我们已经连接到 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 扇门:
监听门的状态
之前我们设法在 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;
就这样,我们现在有了门的状态!
门状态是数字,如 0
、1
和 2
。这是因为 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.
发送门请求
正如您现在可能已经预料到的那样,我们在这里要做的就是向 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
为了确保与不同主题和服务的兼容性,传输必须将数据反序列化为普通的旧数据对象。它可以使用数字数组或类型数组。“validate*”方法应该支持将它们转换为预期的类型。