本文是我在无人机的 IoT 应用架构(给机器人连上网线)上所做的尝试,在小规模场景下表现不错。大规模并没有机会测试,请谨慎参考

这个东西我也不知道有什么用,或许未来有用,留给若干年后做相同东西的人做参考吧

无人机自动系统

这个名字是我自己刚取的,原本做这个时,不叫这个名字。我觉得这个名字更贴切一些

这是一个很新的东西,没人知道他是什么,该怎么做。近几年才有人提出无人机,无人机自动机场,无人机系统放在一起全自动飞行的概念

最常见的说法是无人机管理系统。第一反应肯定是类似 CMS/ERP 一类的管理系统,当然首推以 Ruby on Rails 为首的管理系统思路。

但事实上并不是这样,管理系统要有在异常状态下可以接管无人机的能力。
要可以实时控制无人机,实时显示各种状态,无人机本身是一个复杂的系统,并且要高度追求稳定性,任何一点微小的错误都可能带来致命的后果,代码里有一个 BUG (飞机掉下来砸到人)。明天公司就直接没了

这其实并不是一个新的东西,这个其实是基于无人机的传统地面站基础上扩展的一个新的需求

传统地面站系统

能够实时显示无人机的飞行姿态、飞行轨迹、位置及参数,实现任务航线的编辑与显示,发送飞行控制指令,实时性高,操作方便。地面站系统是一个古老的东西,可以追溯到军工无人机远程控制

再来回顾一下,以一个简单的航拍机为例,通信分成三部分

  • 遥控器
  • 数传
  • 图传

传统地面站架构图

Traditional ground station architecture

一台普通的电脑加上数传再加上地面站程序就组成最基本的地面站系统。(地面站显示图像是新功能。摄像头是近几年刚装到无人机上的,而且图像也只是单纯的显示出来)

各大飞控都有自己的地面站程序:

  • ardupilot 有 mission planner(windows 专属) / apm planner (用 qt 写的,跨平台的)
  • PX4 项目有 QGC(QGroundControl)Q 是 Qt 的简写
  • 大疆的飞机使用手机版的地面站:DJI GO。而大疆的遥控器,相当于把遥控器图传数传一体化了。通过遥控器的 usb 线连接手机就相当于连接了图传和数传,通过手机的地面站去看飞机状态,控制飞机

云端地面站系统

所谓的无人机自动系统,其实就是一个放在云端的 无人机地面站

有了无人机自己机场这种东西后,无人机现场可以不需要人来控制,需要远程控制的地面站程序

由于这些东西全部接入了互联网,就产生了一个新需求,连后期的自动处理也可以一起放到系统里,就如同 CI (Continuous Integration)一样。每次飞机执行完任务,自动化把采集到的数据进行处理

举个自动处理的例子:比如飞机飞了很大一片区域,将图片拼接成一个大图。
(类似的还有很多,比如,拍一个建筑物生成三维的云图。矿山识别土堆体积,计算采矿量。水务飞到河流上采水样检测水质。带着空气传感器检测空气质量)。。。
或者之后把除了的结果放到 GIS (Geographic Information Systems,地理信息系统)里面

Cloud ground station architecture

使用的 draw.io 绘制的,源文件在这里,可以随意使用 ground_station_architecture.drawio

所以最为关键的功能是实时通信,网页手动控制端,后台自动控制端要实时保持通信,在自动执行异常时手动接管时最重要的功能。
这个和一般的互联网产品截然不同,飞机的远程控制系统不能有无法控制飞机的 BUG。实时性和长连接变成了这个系统最重要的功能

本质上其实是一个 表面上看起来像管理系统的 IM (Instant Messaging,即时通讯)系统

多用户同时使用:并发控制

或许你会看到这样的设备,当一个客户端连接上时,第二个客户端时不能连上的。两个客户端的操作会有冲突。为了避免这种情况,所以大部分硬件控制都是独占的。(最常见的就是路由器,好多路由器管理界面加了限制。同一时刻只能有一个人控制)

控制是否要独占?A 用户控制时 B 用户能不能控制?要加互斥锁吗?我个人的观点是 B 也可以控制。因为自动执行任务也算一个用户在控制

我们先回退到传统的无人机,无人机飞航线时是可以手动控制飞机的,紧急情况还是要依赖人的判断。或者会防止误触加锁。但时底层时支持这样的操作的,这个一个无锁的实现

物理上硬件的安全高于一切,避免炸机才是最优先考虑的

放到无人机自动系统也是一样,要 无锁的底层实现 。业务上的需求可以为了限制使用来加锁。

Flux 架构和 IoT(Internet of Things)

无人机自动系统也许把他归类到物联网(IoT)或许更贴切一些

Flux 是由 Facebook 官方提出的一套前端应用架构模式。它的核心概念是单向数据流。它不是具体的框架,而更像是一种软件开发模式。

前端同学应该对 Flux 非常熟悉(vuex,redux 等状态管理都是这个的实现)。不熟悉也没有关系。设计模式的 “中介者模式” 应该都知道,和这个有些类似,不知道也没有关系。这个基本思想本身很简单

Flux 的核心就是一个简单的约定:视图层组件不允许直接修改应用状态,只能触发 action。
应用的状态必须独立出来放到 store 里面统一管理,通过侦听 action 来执行具体的状态操作。
——尤雨溪

flux diagram

Flux 的最大特点,就是数据的 “单向流动”

这个四个部分的关系:

  • View: 视图层
  • Action(动作):视图层发出的消息(比如 mouseClick )
  • Dispatcher(派发器):用来接收 Actions、执行回调函数
  • Store(数据层):用来存放应用的状态,一旦发生变动,就提醒 Views 要更新页面

这个是本来是 Web 前端的实现,现在把这个放到一个 IoT 应用的全栈框架上,就像这样:

  • View 就变成了前端
  • Action 变成了前后端对嵌入式设备的 RPC(Remote Procedure Call)
  • Dispatcher 由具体的嵌入式设备来实现(各种功能函数,执行的各种动作)
  • Store 变成了全部从嵌入式设备的全部状态树。当然,要可以响应式的更新数据

这里使用单一状态树,用一个对象就包含了全部的应用层级状态。至此它便作为一个 “唯一数据源 (SSOT(Single source of truth))” 而存在

在这样的系统里,对应的是物理上的实体,系统中储存的状态并不能表示物体的真实状态。所以 唯一数据源就是物理上状态

由于使用单一状态树,应用的所有状态会集中到一个比较大的对象。当应用变得非常复杂时,store 就有可能变得相当臃肿。
比如 vuejs 官方的状态管理是 Vuex 将 store 分割成模块(module)。当然我们也可以分割我们的状态树(我们可以使用 MQTT 路由来分割)

MQTT(Message Queuing Telemetry Transport)

我知道你肯定有问题,用户,权限该如何分割,这里祭出我们的神器 MQTT(注:我做的这个系统和 MQTT 高度耦合)

我原来写过一篇关于 mqtt 的文章(没看过也没有关系,我用几句话简单介绍一下 MQTT 的核心特性):

  1. MQTT 是队列,使用 pub/sub (或者可以把他看成 RabbitMQ )
  2. 有 QoS 可以保证可达性
  3. Retain(驻留)。或者说是置顶(就像你新加入一个聊天室自动给你发的消息一样)
  4. 消息路由支持通配符(可以批量加入一堆聊天室(包括当前还不存在的))

比如使用设备状态: nodes/<id>/msg/<type> ⇒ nodes/1/msg/battery 但是除了 battery 还有其他的的比如 position (位置),gimbal (飞机云台,有些飞机可能没有云台)。
配合 MQTT 路由通配符就是可以直接 订阅这个 topic nodes/1/msg/+ (使用 msg 作为 prefix)

按照 MQTT 的路由规则可以 nodes/+/msg/+ 全部设备的全部消息。
当然,要考虑权限问题,可以在用户的标识和 id 做限制(在 MQTT broker 里限制),让这个用户只能收到一部分 id 的消息(用户权限限制)

状态同步单向数据流到 mqtt sub 来实现,拿到消息更新到前端的 store,之后 MVVM 的数据绑定,视图更新。(有些状态改变是过程量(比如电机工作传感器值会一直变),会受到状态同步频率的限制,目前:我把限制设置在每秒每种类型的的消息最多一条)

或许你还会有疑问:刚启动时怎么办?因为没有消息,消息是响应式触发

这里再配合 MQTT 的 retain 来保存最新的状态(加个 Redis 之类的,启动时先拉一缓存的最新状态)。如果丢消息可以考虑 QoS 。有些中间的状态变化是可以丢掉的。

这套状态管理在这样的 IoT 架构中实际使用中的表现非常好

jsonrpc2 over MQTT

状态同步之不过是 Store ⇒ View 的实现,我们需要定义 Action 的实现。Action 其实就是个 RPC,所以大部分 RPC 协议都是可行的

按照 jsonrpc2 标准文档的设计,可以跑在任何的传输协议里。所以我们来让它使用 MQTT 来传输

使用 jsonrpc2 更主要的原因是容易调试,直接可以通过 mosquitto 命令行去 debug

1
2
mosquitto_pub -L mqtt://user:pass@localhost:1883/nodes/1/rpc/send \
-m '{"jsonrpc":"2.0","id":"test-1","method":"mission.create","params":[]}'

我直到现在还在质疑,jsonrpc2 over MQTT 到底是不是一个好的方案

这个地方是我设计的有问题的,会在特定的极限条件下丢消息,只能配合 mqtt v5 的新特性 请求响应 (request-response) 来解决(其实也不够严谨)

虽然 MQTT 是可以双向通信的,但我还是把 IO 分成两个 Topic: nodes/:id/rpc/send 和 nodes/:id/rpc/recv。
这样其实是因为 MQTT v3 里会 Sub 到自己 Pub 的消息

这个本身就是个权衡之后的考虑,为了可靠性要加入很多确认。但同时调试又会变得复杂

做过无人机或机器人相关的或许会对这个协议非常熟悉,MAVLink (Micro Air Vehicle Link) 协议原自 px4 项目。
后来 ardupliot 也使用了 MAVLink 作为通信协议(有些遥控器支持数据回传功能的,直接在硬件上集成这个协议,然后读取状态)。
可以通过 MAVLink 来控制一切,MAVLink 就快变成了一个标准

为什么不是 mavlink 跑在 tcp/udp 或者外面套一层 websocket ?

做出不使用 mavlink 作为主通信协议的提议的我在当时是饱受同事的质疑的(同事做机器人的人比较多)

  1. mavlink 协议比较适合跑在 udp 协议里,他在协议里面自己实现了 qos,质量控制(相当于自己实现了 tcp)。
  2. 数据结构是自己定义格式,通过自己的 generate 去生成编码和解析
  3. 这个是飞机自带的,机场是我们从头做的,机场也要再实现一遍 mavlink
  4. 这样后端的自动控制,和前端的手动控制都要和 mavlink 深度绑定
  5. mavlink 设计之初就不是为了互联网传输,为了高度实时设计的,透过互联网传输之后,这种高度实时的特性已经很难保证了
  6. mavlink 是支持方言的,可以自定义标准。这意味着状态管理还要提供一层转换来支持各种方言
  7. mavlink 除了最原始的 C 的那个库,其他的库代码质量堪忧(又不是不能用.jpg)

最重要的原因是:要跑着浏览器上 mavlink 的消息频率太高。同时控制几个飞机,浏览器就要处理不过来了。?什么用 WebWorker 来解析 。定时再同步状态吗?这太糟糕了。

基于以上原因,选择了自己造通信的方式,自己做了一层 MAVLink 到自有协议的转换层。只传输需要传输的消息

如何调试和自动测试

我们使用了物理实体的状态作为唯一数据源,为了稳定性,单元测试,集成测试是必须的。

所以,我们需要一个模拟器,模拟物理上存在的硬件接口,各种飞控都自带模拟器,只需要自己实现一个硬件设备的模拟器就好(当然只是 IO 模拟,对物理世界建模来模拟这个太没必要了)

可定制流程处理和协同作业

先来看一段 TED 视频

像这样的协同作业有两种:蜂群或蚂蚁一样的完全自主协同作业,或者固定的中心调度协同作业

完全自主协同作业目前是不可能的,主要是规模问题。无人机的数量短期根本达不到需要自主协同的规模(分布式那套东西完全就不用考虑)。所以只能是指挥中心告知具体的设备去执行工作

该如何描述这样的一种控制流?

  • 有限状态机(状态机,FSM)
  • 有向无环图 DAG
  • 佩特里网 Petri Net

我们一个一个来分析:

有限状态机 Finite State Machine(FSM)

有限状态机可以有方向,可以有环。但不能描述并发。这个需求一定会有并行,比如飞机和机场的协同工作一定会有并行的状态。(物理世界是一定有并行的)

workflow FSM

有向无环图 Directed Acyclic Graph(DAG)

我们可以使用有向无环图(DAG)来加入并行。但是作为一个物理实体控制,当然是要有环的

任务的 DAG 图

workflow DAG

由于不能有环,所以启动飞机失败后没有办法再启动飞机

DAG 可以很容易的描述依赖关系,比如 Gitlab CI 的 DAG 图

Gitlab DAG

佩特里网 Petri Net(place/transition (PT) net)

workflow PTN

使用的 draw.io 绘制的,源文件在这里,可以随意使用 workflow.drawio

有限状态机 和 有向无环图 的问题可以通过 Petri Net 来解决。这样,并行和有环就都解决了。但还有问题,飞机支持的挂载不同,执行的动作可能是不确定的。需要编辑任务提供个输入框。

但是还不够,可能要加入自己定义的函数和判断条件。需要在各函数里面嵌入各函数扩展。比如:对起飞条件的判断。Petri Net 可以描述流程,但还要嵌入算法。所以还需要一种扩展性更强的方案

图灵完备

只要图灵机可以被实现,就可以用来解决任何可计算问题

可以祭出老祖宗留下来的神器:图灵完备。把各种功能都做成 API,然后用户自行上传脚本来描述工作流程。
这其实也不是什么新东西。你看 nginx 都可以用 lua 扩展。嵌入 lua 当作流程控制的并不再少数(你看游戏领域)。
对于控制硬件动作使用 lua 描述动作也不少应用

如果再把 API 做成异步的,就可以支持并行的动作和协同控制

嵌入一个脚本语言。经过一番调研底层使用 golang 可行的方案大概有:lua ,lisp,JavaScript,starlark(python 的方言)。或者直接传 AST(Abstract Syntax Tree)

我最终还是选择了 lua 。先一个一个来看

  • starlark 不兼容 python ,甚至没有异常处理。还会给人用 python 的错觉,也用不了 python 的库(还有我个人不喜欢缩进作为语法的一部分,因为我作为这个系统的设计者的品味所以不用)
  • lisp 我真怕写脚本的人理解不明白(我自己都还不会用)
  • JavaScript 我纠结了好久,原因是因为 JavaScript 那几个神奇的判断 null == undefined 之类的那种

不过我现在有时还在思考,使用 JavaScript 或许是一个更好的方案。
因为,实际使用中有很多是异步(lua coroutine 作为流程控制,太不直观 。还是自己造事件循环模仿 JavaScript 或许更好些)。
还有支持流程运行中用户会发信号进来,这个需求是在设计上完全没有考虑过的。这只能说是在开发之前对于使用场景的预估有偏差导致的