简介:面向工业级低空应用场景的无人机智能调度与管理平台,凝结了团队在大型实战项目中的经验,适合开发者、科研与教学人员使用。平台支持大疆上云、PX4及Mavlink系列无人机接入,提供设备、航线、任务、媒体等核心管理功能,并基于Cesium引擎实现机队三维可视化管控,已在电网、铁路、交通、建筑、城市安防等领域大规模落地。资源包共1399个文件,大小约39.86MB,以php后端接口、js前端逻辑、css样式、png图片资源与json配置为主,同时包含sql、dockerfile及环境配置等部署文件,较完整地呈现了前端三维可视化与后端调度系统的实现结构。已有23人学习,适合希望快速搭建低空管控平台或研究无人机调度算法的技术人员。借助开放的开源版本功能,开发者可基于它开发一网统飞、低空巡检、共享无人机、物流配送、AI识别等各类场景应用,获得一套生产级、可二次扩展的无人机系统管理方案。
1. 低空无人机的调度困局:为什么需要一套智能调度平台
一个实测跑 50 架无人机巡检的现场,靠对讲机和微信群派活注定是一场事故。飞手们各自为战,两架飞机同时飞进同一块空域是常态,任务航线存在 SD 卡里互相谁也不认,最要命的是飞控告警没人看——等飞手反应过来无人机已经撞上吊塔。这套工业级低空无人机智能调度与管理平台解决的问题,就是把散装的人肉调度流程替换成生产环境可以依赖的系统化调度:空域自动避让、航线统一编排、任务状态实时追踪、异常自动告警回传。对有真实巡检、安防、智慧园区项目要交付的团队,或者想把手动飞无人机升级成无人值守作业模式的人,它基本算是一个能直接落地的开源版底座。
2. 平台架构与核心模块拆解:调度引擎、航线规划与无人机接入
2.1 调度平台的技术组成与设计取舍
这套平台在架构上并没有一开始就上微服务,而是按业务边界拆成几个可独立部署的服务。当时团队内部也争论过 K8s 还是裸金属,结果在几十架无人机的体量下,微服务拆分换来的是排障成本飞涨。生产环境优先的原则下,一个中控服务负责接入、调度、存储,一个监控服务负责遥测和告警,前端负责地图呈现和任务交互,已经能覆盖绝大部分业务场景。要扩展也不是推倒重来,而是把中控服务按模块拆出去。我一般会在设计文档里坚持一个原则:调度链路上的每一个环节都要能单独验证,否则排障时全是黑匣子。
从模块职责上看,平台核心由五块组成:无人机接入层负责屏蔽厂商差异,调度引擎负责任务编排和状态流转,航线服务负责空间约束校验,存储层负责遥测数据和业务数据的写入,前端工作台负责可视化和人工干预。生产环境真正考验的不只是某个模块本身,而是模块之间的配合——比如当一架无人机在飞行中突然失联,调度引擎是等还是切换备用机场,这取决于通信层给的状态置信度。
2.2 无人机接入层:统一协议适配
市面上无人机飞控的通信协议五花八门,有的走 RTMP 视频流同步回传遥测,有的走 MQTT 上报经纬度和电量,还有的需要通过私有 SDK 拉起航线。如果每个型号都单独接一套,调度引擎会变成一坨巨型 if-else。这套平台的常见做法是抽象一个接入接口,把起飞、降落、上传航线、查询遥测、急停这些操作统一成标准动作,不同厂商的差异全部收敛在适配器里。
public interface DroneAdapter { // 注册无人机的静态信息,返回设备唯一标识 String register(DroneProfile profile); // 上传一条已完成空间校验的航线,返回上传后的确认版本号 String uploadRoute(String deviceId, RoutePlan route); // 下发起飞指令,autoMode=true 表示按航线自动执行 boolean takeoff(String deviceId, boolean autoMode); // 下发降落指令,返回当前是否已进入降落状态机 boolean land(String deviceId, LandingReason reason); // 拉取最新的遥测快照,包含经纬度、高度、电量、飞行模式 Telemetry pullTelemetry(String deviceId); }这段接口定义看起来简单,但实际项目里要花不少功夫在语义对齐上。不同厂商的land行为差别很大,有的表示原地降落,有的表示返回起飞点降落,有的表示降落并关桨。我在实现适配器时会要求协议文档里明确区分land和returnToLand,避免调度层误判。register返回的唯一标识在整个平台内作为设备维度索引,航线、任务、遥测都挂在它下面,生产环境里用自增 ID 容易在数据迁移时搞出乱子。
2.3 航线规划与空间约束:不做校验的航线就是定时炸弹
航线规划在平台里不只是画一条线,而是要把空域约束带进去。项目里会预先导入两种空间数据:一类是禁飞区,通常用 KML 或 GeoJSON 围栏表达;另一类是限飞区,即超低空范围内允许飞行但在高度上受限的区域。调度引擎在任务创建阶段会把整条航线逐点过一遍空间校验,任何一段路径进入电子围栏,任务直接拒绝下发。
GeoJSON 做边界判断在数据量不大时很成熟,但需要注意坐标体系统一。平台默认 WGS84,转 GCJ-02 的活儿必须放在围栏导入的边界处做,不要在航线校验时临时转换,否则会出现边界上几个像素级的差异,真实场景里就是几米的偏差。
-- 在数据库中预计算航线点与禁飞区的包含关系 -- spatial data: geofence 表存放禁飞区多边形,route_point 表存放航线采样点 SELECT rp.route_id, rp.sequence_no, ST_AsText(rp.geom) AS point_coord, gf.name AS fence_name FROM route_point rp JOIN geofence gf ON ST_Intersects(rp.geom, gf.geom) WHERE rp.route_id = #{routeId} ORDER BY rp.sequence_no;这段 SQL 是航线预检的一部分,通过空间索引在 PostGIS 里快速求交。ST_Intersects判断采样点是落在禁飞区内部还是边界上。如果结果集里出现任何一条记录,整个航线包就不能下发。要注意采样点的密度,均匀分布不代表安全,复杂拐弯区域必须加密采样点,否则一条贴边飞行的航线会在两个采样点之间悄悄穿过围栏。我一般处理的逻辑是:直线段每 30 米一个采样点,拐弯处半径 20 米范围内每 5 米一个采样点。
2.4 实时遥测与任务调度主循环
任务调度的主循环在平台里是一个持续运行的定时引擎,默认每 3 秒扫描一次在飞任务,与遥测链路协同判断是否需要干预。通讯层用 MQTT 或者内部消息总线都行,关键是消息里必须带设备状态序列号,避免旧的遥测包晚到覆盖了新数据。
无人机的任务生命周期在平台里被定义成有限状态机:PENDING->READY->IN_FLIGHT->PAUSED->COMPLETED/ABORTED。调度引擎只负责状态跃迁,真正改变状态的是遥测聚合器和人工指令。举个例子,当遥测数据显示电量低于充电阈值且任务还没执行完,调度引擎会发出返航指令,状态从IN_FLIGHT进入RETURNING,这个中间态不在上面的列表里是因为它属于子状态。
// 调度主循环的简化实现 public void scan() { List<Mission> missions = missionRepository.findRunningMissions(); for (Mission mission : missions) { Telemetry latest = telemetryStore.latest(mission.getDeviceId()); if (latest.getVoltage() < mission.getConfig().getRtlVoltageThreshold()) { // 低电量触发自动返航,同时挂起当前航线后续任务 mission.setState(MissionState.RETURNING); droneAdapter.returnToLand(mission.getDeviceId()); missionRepository.save(mission); continue; } if (System.currentTimeMillis() - mission.getLastHeartbeatAt() > 15000) { // 连续15秒未收到心跳,进入失控保护流程 mission.setState(MissionState.LOST); alertService.notifySupervisor(mission); } } }这个循环本身不复杂,真正麻烦的是参数设置。rtlVoltageThreshold设高了巡检验收时会频繁返航显得不智能,设低了又会让无人机在返航途中电量耗尽。我一般建议看厂商数据的真实放电曲线,平台默认值保守一点没坏处。心跳超时 15 秒是通用值,但如果现场有严重的信号遮挡场景时可以考虑放宽到 20 秒,同时把告警通道拉宽——光在平台里弹提示远远不够,短信和电话告警必须接上,因为无人机的异常窗口期往往只有几十秒。
3. 生产环境复现:从仓库到可运行部署
3.1 环境准备与依赖清单
要把这套平台在生产环境里跑起来,基础依赖并不杂。后端以 Java 17 为基线,数据库用 PostgreSQL 16 加 PostGIS,缓存与分布式锁走 Redis 7,消息通道用 EMQX 5.x,前端是 Vue 3 构建后由 Nginx 托管。如果公司内部已有标准中间件环境,尽量复用而不是另起一套,省去的排障成本非常可观。
| 组件 | 版本建议 | 用途 |
|---|---|---|
| JDK | 17+ | 后端调度引擎与接口服务 |
| PostgreSQL | 16 + PostGIS | 航线数据、任务状态与业务数据存储 |
| Redis | 7.x | 分布式锁、遥测缓存、任务队列 |
| EMQX | 5.x | 无人机遥测消息通道与指令下发 |
| Nginx | 1.24+ | 前端静态资源与 WebSocket 反向代理 |
| Node.js | 18+ | 前端构建环境 |
这里要强调一下:PostGIS 不是可选项。航线校验中的所有空间操作都建立在 PostGIS 之上,没有它地理围栏检查就得自己写算法,在数据量上来以后性能完全是两个世界。
3.2 数据库初始化与核心表结构
拿到源码包后,第一步是初始化数据库。项目根目录下的doc/sql/init.sql包含了全部建表语句,但我在实际部署时不喜欢一股脑全执行,而是分块执行,每块检查影响行数。核心表有这几张:device_info存无人机静态信息,mission存任务主记录,route_point存航线采样点,telemetry_record存遥测流水。
-- 无人机设备表,统一管理注册信息和经纬度标定 CREATE TABLE device_info ( id BIGSERIAL PRIMARY KEY, device_code VARCHAR(64) NOT NULL UNIQUE, -- 在接入层注册时生成的唯一编码 adapter_type VARCHAR(32) NOT NULL, -- 使用的适配器类型,比如 pixhawk、dji-sdk brand VARCHAR(32), model VARCHAR(64), registered_at TIMESTAMP WITH TIME ZONE DEFAULT now() ); -- 任务主表,记录一次飞行任务的生命周期 CREATE TABLE mission ( id BIGSERIAL PRIMARY KEY, device_id BIGINT NOT NULL REFERENCES device_info(id), mission_name VARCHAR(128) NOT NULL, state VARCHAR(16) NOT NULL DEFAULT 'PENDING', -- 状态机流转字段 total_route_length DOUBLE PRECISION, -- 航线总长度,用于任务预估 expected_flight_seconds INT, started_at TIMESTAMP WITH TIME ZONE, finished_at TIMESTAMP WITH TIME ZONE, created_at TIMESTAMP WITH TIME ZONE DEFAULT now() ); -- 遥测流水表,按时间分区存储 CREATE TABLE telemetry_record ( id BIGSERIAL, device_id BIGINT NOT NULL, lng DOUBLE PRECISION NOT NULL, lat DOUBLE PRECISION NOT NULL, altitude DOUBLE PRECISION NOT NULL, voltage DOUBLE PRECISION NOT NULL, flight_mode VARCHAR(16), captured_at TIMESTAMP WITH TIME ZONE NOT NULL, PRIMARY KEY (id, captured_at) ) PARTITION BY RANGE (captured_at);device_code的唯一约束是接入层幂等性的基础,适配器重复注册同一个物理设备时应该复用原记录而不是新建。遥测表的分区键选captured_at,生产环境里这表数据膨胀极快,如果不分区,三个月后查询就开始肉眼可见地变慢。分区建议按月建,过期分区直接 detach 归档。
3.3 后端服务启动与配置项
后端服务的主配置在src/main/resources/application.yml,部署时最需要关注的是调度参数和消息通道配置,而不是默认数据源连接。项目默认的spring.datasource配置是开发环境用的,生产环境必须改掉密码策略并打开 SSL 连接。
spring: datasource: url: jdbc:postgresql://192.168.10.20:5432/drone_scheduler username: drone_app password: ${DB_PASSWORD} # 生产环境用环境变量注入,不写入仓库 app: scheduler: scan-interval-seconds: 3 # 调度主循环扫描周期 heartbeat-timeout-seconds: 15 # 心跳超时阈值 rtl-voltage-threshold: 23.5 # 低电量返航阈值(典型 6S 电池) mqtt: broker: tcp://192.168.10.30:1883 telemetry-topic-prefix: drone/telemetry command-topic-prefix: drone/command geofence: enabled: true # 生产环境必须为 true sample-distance-meters: 30 corner-sample-meters: 5scan-interval-seconds直接决定任务异常的响应速度,改小会加大数据库查询压力,改大则会让失控保护迟钝。rtl-voltage-threshold建议根据实际使用的电池型号做一次放电测试再定,24V 是保守值,23.5 更激进一些。telemetry-topic-prefix用于区分不同接入通道的遥测消息,多机场部署时这里建议按机场编号再细分一层,避免不同区域的无人机遥测串流。
3.4 前端构建与 Nginx 反向代理
前端工作台构建产物放在web/dist下,Nginx 配置里有几个关键点:一是静态资源目录要指向dist,二是/api路径要反代到后端服务,三是 WebSocket 需要单独开启upgrade头支持。生产环境最常见的问题是地图瓦片加载不出来,这个往往不是平台代码问题,而是服务器访问不了公网瓦片服务。我一般建议把瓦片源换成内网部署的瓦片服务器,离线环境也照样可用。
server { listen 80; server_name drone-platform.example.local; root /opt/drone-platform/web/dist; index index.html; location /api { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /ws { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 3600s; } location / { try_files $uri $uri/ /index.html; } }proxy_read_timeout 3600s对 WebSocket 连接至关重要。遥测推送是长连接,默认 60 秒的超时会导致连接被 Nginx 切断,无人机状态在平台上看着是正常的,实际上已经失联。try_files的降级规则解决的是前端路由刷新后 404 的问题,Vue Router 的 history 模式必须有这条兜底。
3.5 冒烟测试:验证调度链路是否打通
部署完成后不能直接建正式任务,先跑一轮冒烟测试,验证注册、航线校验、任务下发、遥测上报这条主链路是通的。用下面的 Python 脚本模拟一个虚拟设备,向平台注册并上报遥测,然后看调度引擎是否正确接收。
import paho.mqtt.client as mqtt import json import time broker = "192.168.10.30" client = mqtt.Client(client_id="smoke-test-drone") # 注册消息,模拟真实设备上电后的注册行为 register_msg = { "device_code": "TEST-001", "adapter_type": "simulator", "brand": "smoke-test" } client.connect(broker, 1883, 60) client.publish("drone/device/register", json.dumps(register_msg)) # 每 2 秒上报一次遥测,共上报 10 次 for seq in range(10): telemetry = { "device_code": "TEST-001", "lng": 113.334, "lat": 23.128, "altitude": 50.2, "voltage": 24.8, "flight_mode": "AUTO", "captured_at": int(time.time() * 1000) } client.publish("drone/telemetry/TEST-001", json.dumps(telemetry)) time.sleep(2) client.disconnect() print("smoke test payload published complete")跑完脚本后到前端工作台的设备列表确认TEST-001出现且遥测数据在持续刷新。如果设备没有出现,先看 EMQX 的订阅关系是否正确,再看后端消费组日志。flight_mode字段在真实设备上会映射为厂商自己的编号系统,适配器里要做一次枚举映射,比如AUTO对应 Pixhawk 的4、大疆的AUTO_MISSION,映射不全会导致平台收到设备但状态一直显示异常。
4. 部署与运行的常见问题排查:五个高频踩坑记录
4.1 任务下发后无人机无响应,平台仍然显示已起飞
现象:调度引擎返回起飞指令成功,平台状态置为IN_FLIGHT,但无人机实际停在原地,电机没有任何反应。
原因:查下来多半是适配器实现里的takeoff只是把指令发到了 MQTT,但无人机因为遥控器没有解锁或者禁飞区限制拒绝了指令,回执消息没有正确消费。
解决:在takeoff调用后追加一个指令回执确认,如果 5 秒内没有收到设备返回的执行结果,调度引擎要把任务状态回滚到PENDING并且标记为异常。回执主题建议设计成drone/command/ack/{deviceId},消息体里带上commandId和resultCode,这样主循环才能区分「指令在途」和「指令已执行」。
4.2 航线围栏校验误杀合法航线
现象:一条明显在限飞区边界之内、理论上合法的航线在创建时被拦截,提示经过禁飞区。
原因:GeoJSON 围栏数据导入时坐标没有统一,部分边界使用了 GCJ-02 坐标,另一部分用了 WGS84,混合在一起后边界发生了偏移,原本合法的航线点被判定进了偏移后的禁飞区。
解决:写一个围栏导入校验脚本,导入时强制检查坐标系的crs字段,不一致直接拒绝。我一般在初始化时把所有围栏统一转成 WGS84 存库,业务层不再处理坐标转换。另外要注意围栏数据本身的质量,很多原始围栏是手绘的,存在自相交多边形,PostGIS 能存进去但空间判断结果并不可靠,需要先用ST_IsValid做一次形状检查。
4.3 遥测数据高并发入库把 PostgreSQL 写满
现象:接入的无人机从 10 架增加到 20 架后,数据库的 CPU 使用率持续飙高,任务状态页面加载变慢。
原因:遥测每 2 秒上报一次,20 架无人机一天约 86 万条记录,全部走 ORM 逐条插入,索引维护和 WAL 日志开销非常大。
解决:遥测写入不走业务库的常规表,落库前先批量聚合,每 10 秒一批批量插入。同时把遥测表按天做分区,查询时默认加上时间范围条件。如果现场接入了更密集的遥测流,建议把遥测链路单独拆到 ClickHouse,只把聚合成每 5 秒的快照写回 PostgreSQL 供平台展示。
4.4 地图页面白屏,控制台报跨域错误
现象:前端能登录,但地图区域一直空白,F12 能看到地图瓦片请求被浏览器拦截。
原因:瓦片服务地址写在了前端代码里,开发环境是 http 地址,部署时没有改成反向代理地址,或者瓦片服务没有配置 CORS。
解决:地图瓦片请求最好也让 Nginx 反代到同源路径下,而不是直接让浏览器跨域访问内网瓦片服务器。平台前端配置文件里把VITE_TILE_URL改成相对路径/tiles,然后在 Nginx 加一个location /tiles指向瓦片服务地址,浏览器视角下就是同源请求,彻底绕开跨域问题。
4.5 任务被重复执行:无人机起飞了两次
现象:一次巡检任务结束后,无人机在降落后 10 分钟又自动起飞执行同一航线,平台日志显示任务被二次下发。
原因:调度主循环扫描到任务还是PENDING没有及时推进到READY,同一个missionId被重复推送到执行队列。队列消费侧没有做幂等处理,消息重试时重复触发了takeoff。
解决:任务下发前用 Redis 的SETNX做分布式锁,锁的 key 是mission:executing:{missionId},只有当锁获取成功时才允许执行起飞指令。同时记录missionId到设备端的任务快照,起飞前读取设备当前任务快照,如果任务快照里的missionId与下发的相同且状态不是COMPLETED,直接忽略这次指令。
5. 上线后必做的三件事:备份恢复、高可用验证与数据一致性校验
平台跑起来只是第一步,生产环境里真正让人睡不着觉的是数据安全和不可用窗口。我处理过的项目中,无人机调度平台有个特殊之处:遥测流水是核心证据数据,丢了没法重采,而航线数据是长期资产,丢失后重新测绘成本极高。所以上线后第一件事不是调高并发,而是设计备份与恢复策略。
备份策略我会拆成两层。业务库用pg_dump每天凌晨做全量备份,保留 7 天滚动;遥测分区表则用pg_basebackup做物理备份,归档到独立的存储节点。航线文件和围栏 GeoJSON 不走数据库,统一放在文件服务器上做版本管理,每一次人工修改都能回溯到上一版。有了备份还不够,恢复必须演练过才算数。我经历过一次真实的惨痛教训:某次升级后误操作删掉了整个mission表,那时候才发现我们每周做的备份脚本在恢复环境里从来没有执行成功过——不是连接串写错就是权限不对。从那以后我每次上线或升级前都强制走一遍「备份 → 恢复到临时库 → 校验关键表行数 → 比对航线文件哈希」的流程,这条流程走通了才允许动生产环境。
高可用方面,单机部署的调度平台在几十架无人机的规模下还算够用,但如果涉及对空域安全负责的业务,主备切换必须验证。用 Keepalived 做虚拟 IP 漂移,故障切换时间大概在 5 秒内,可以接受。需要确认的是备机的 Redis 数据和遥测缓存能无缝接上,否则切换后监控大屏上会显示一小段时间的数据空洞。
数据一致性校验是最后一关。平台运行一段时间后,任务记录、遥测记录、设备状态之间可能会出现孤岛——比如设备已经在物理上失联,但数据库里任务还是IN_FLIGHT。我习惯写一个每天凌晨运行的巡检脚本,逻辑很简单:扫描所有在飞任务,如果最后一跳遥测时间距当前时间超过 30 分钟,自动将该任务置为ABORTED并推送异常报告。同时定期抽查航线的空间数据完整性,用ST_IsValidReason检查是否有退化线段。这些校准手段看起来笨,实际撑住了很多厂商对接时的脏数据问题。
平台本身再智能,最终兜底的依然是人。我给团队定了一个不成文的规矩:任何自动化流程都要留人工复核的入口,任何异常转人工的按钮都要能在一分钟以内触达现场飞手。经过这几轮打磨,这套平台算是脱掉了「项目 demo」的壳,变成真正敢在巡检、安防、农业植保场景里全天候跑的生产工具。希望这些踩坑和验证经验能帮到你,至少让你在从开发环境走向生产环境时,能比我们少走几步弯路。
本文还有配套的精品资源,点击获取