三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

AFSIM 12篇 Java/Python 接入 AFSIM:TCP 客户端开发实战

AFSIM 12篇 Java/Python 接入 AFSIM:TCP 客户端开发实战

🧭 系列导航(共 15 篇):
入门篇:01 AFSIM 是什么? · 02 从零搭建环境 · 03 跑通第一个仿真 · 04 核心概念扫盲
基础篇:05 SDL 入门 · 06 Platform 详解 · 07 传感器与跟踪 · 08 武器与交战 · 09 机动与航线
进阶篇:10 通信与处理器 · 11 External Control · [12 TCP 客户端实战] · 13 Wizard 可视化
高级篇:14 C++ 插件开发 · 15 调试与最佳实践
上一篇:11 External Control | 下一篇:13 Wizard 可视化

12 Java/Python 接入 AFSIM:TCP 客户端开发实战

第 11 篇《External Control》 里我们讲了 wsf_external_control 插件会在本机 127.0.0.1:31000 起一个 TCP server。本篇就动手——用 Java 写一个 TCP 客户端连上去,把它真正"驱动"起来。我会给出可对照的代码片段、JSON 指令格式、Spring Boot 集成方式,再附一段 Python 等价实现和踩坑清单。

一、先看清通信契约

在写代码前要明确:我们连的是 127.0.0.1:31000 的 TCP server,连接建立后双向收发管道分隔的纯文本命令(真实格式为 COMMAND|key=value|…),每条消息以换行符 \n 作为分隔(行协议,简单可靠)。客户端主动拉取状态、主动下发指令;插件主动推送 track 快照(响应 DATA|…)。记住这个"行协议 + 管道文本"的基调,后面代码都围绕它(第 11 篇 已用安装实证纠正了此前"JSON"的说法)。

为什么是 127.0.0.1(环回地址)而不是某个局域网 IP?因为外部控制追求最低延迟与最高安全——控制程序通常和 AFSIM 引擎跑在同一台机器上,走环回网卡几乎零网络开销,也不会把仿真端口暴露给外部网络被人误连。如果你的架构确实需要跨机控制(例如后端部署在另一台服务器),那就得把插件监听地址改成可达 IP,并叠加防火墙与鉴权,否则等于把"开火权"敞开了大门。对绝大多数学习与原型场景,守住 127.0.0.1 是最省心也最稳妥的选择。

二、Java TCP 客户端核心代码

下面是一个最小可对照的 Java 客户端骨架:建立 Socket、拿到读写流、按行收发。

javapublicclassAFSIMClient{privatefinalStringhost="127.0.0.1";privatefinalintport=31000;privateSocketsocket;privateBufferedReaderin;privateBufferedWriterout;publicvoidconnect()throwsIOException{socket=newSocket(host,port);// 连接插件 TCP serverin=newBufferedReader(newInputStreamReader(socket.getInputStream(),StandardCharsets.UTF_8));out=newBufferedWriter(newOutputStreamWriter(socket.getOutputStream(),StandardCharsets.UTF_8));System.out.println("已连接 AFSIM 外部控制端口 "+host+":"+port);}// 发送一行 JSON 指令(以 \n 结尾)publicvoidsend(Stringjson)throwsIOException{out.write(json);out.write("\n");out.flush();}// 读取插件推送的一行(track / 状态)publicStringreceive()throwsIOException{returnin.readLine();}}

注意:务必用 StandardCharsets.UTF_8 统一编码,否则中文平台名或日志可能出现乱码(详见第四节坑点)。

三、COMMAND|key=value 指令封装(真实线协议)

⚠️ 重要纠正:本系列前文(含 第 11 篇)曾把协议示意成 JSON(fly_to / fire_weapon)。但 wsf_external_control 插件的真实线协议是管道分隔纯文本:每条指令形如 COMMAND|key=value|key=value,以换行结束;响应为 OK|… / ERROR|… / DATA|…。下面代码全部改用真实格式。

结合真实项目里的 UAV_01(RECON_STRIKE_UAV)与 Enemy_Ship(ENEMY_DDG),一条让无人机抵近并打击的指令长这样:

text MOVE_TO|platform=UAV_01|lat=32.85|lon=120.50|alt=8000|speed=220FIRE|platform=UAV_01|weapon=fox3|target=Enemy_Ship|qty=1

插件收到后,会分别翻译成 第 11 篇 讲的 FlyToEvent 与 FireWeaponEvent,入队由仿真执行。封装成方法很自然:

javapublicvoidmoveTo(Stringplatform,doublelat,doublelon,doublealt,doublespeed)throwsIOException{Stringcmd=String.format("MOVE_TO|platform=%s|lat=%f|lon=%f|alt=%f|speed=%f",platform,lat,lon,alt,speed);send(cmd);}publicvoidfireWeapon(Stringplatform,Stringweapon,Stringtarget)throwsIOException{Stringcmd=String.format("FIRE|platform=%s|weapon=%s|target=%s|qty=1",platform,weapon,target);send(cmd);}

有两个易错点值得提前说:第一,坐标单位与原点必须和插件端约定一致(经纬度用十进制度或度分秒串,取决于插件解析逻辑),否则无人机会"飞到一个莫名其妙的点";第二,target 填的是平台名字符串(如 “Enemy_Ship”),插件会去 WsfTrackList 里查这个名字对应的 track 再开火,名字拼错就会"找不到目标"。在真实项目里,我们通常会先发 GET_STATUS / LIST_PLATFORMS 确认目标存在且在我方射程内,再下发 FIRE,避免盲目开火。

四、与 Spring Boot 集成

真实项目里,客户端不会裸跑,而是作为 Spring Boot 的一个服务 bean 管理连接生命周期,并通过 Controller 暴露给前端。仿真适配器项目架构如下图所示

核心类就是 ExternalControlService:

java@ServicepublicclassExternalControlService{privateAFSIMClientclient;@PostConstructpublicvoidinit()throwsIOException{client=newAFSIMClient();client.connect();// 应用启动即连 127.0.0.1:31000startReceiveLoop();// 后台线程持续拉取 track}privatevoidstartReceiveLoop(){newThread(()->{try{while(true){Stringline=client.receive();// 插件推送的 track/状态if(line==null)break;handleTrack(line);// 更新态势、触发决策}}catch(IOExceptione){/* 重连逻辑 */}}).start();}publicvoidorderStrike()throwsIOException{client.flyTo("UAV_01",120000,85000,5000);client.fireWeapon("UAV_01","Enemy_Ship");}}

再用一个 Controller 把能力暴露出去:

java@RestController@RequestMapping("/afsim")publicclassExternalControlController{@AutowiredprivateExternalControlServicesvc;@PostMapping("/strike")publicStringstrike()throwsIOException{svc.orderStrike();return"指令已下发";}}

这样,前端点一下按钮,后端就通过 TCP 把指令送进仿真——一个完整的"感知-决策-行动"闭环就搭好了。

五、Python 等价客户端

如果你更习惯 Python(比如做算法验证),等价实现非常短:

pythonimportsocketdefmain():s=socket.create_connection(("127.0.0.1",31000))# 真实协议:管道分隔纯文本,以 \n 结尾s.sendall(b"MOVE_TO|platform=UAV_01|lat=32.85|lon=120.50|alt=8000|speed=220\n")print(s.recv(4096).decode("utf-8"))# 形如 OK|... 或 DATA|...s.close()if__name__=="__main__":main()

可见 Python 与 Java 的差异只在语法,通信契约完全一致——同一套 COMMAND|key=value 管道文本协议,谁都能连。

在真实项目里,我们的客户端遵循一个简单但稳健的"先读后写"模式:启动后先持续 receive() 插件推送的 track 快照,把它解析成内存里的态势对象;业务线程(如 Controller 收到前端请求)再基于这份最新态势决定要不要下发 MOVE_TO / FIRE。也就是说,读是常驻后台线程,写是按需触发,两者通过同一个 Socket 协作。多线程共用一个输出流时要加锁,避免两条指令黏在一起被插件当一条解析而报错。这个小细节,是多数初学者第一次联调时最容易被绊倒的地方。

六、常见坑清单

  • 连接被拒(Connection refused):99% 是 wsf_external_control 插件没启动,或仿真还没跑到监听那一步。确认 AFSIM 场景已加载该插件、仿真正在运行,再确认端口确实是 31000 而非被改过。别急着改代码,先看 Wizard 运行日志有没有"external control listening"字样。
  • 编码乱码:务必客户端与插件两端统一 UTF-8;Java 不要依赖平台默认编码。Windows 上 new OutputStreamWriter(socket.getOutputStream()) 不指明字符集时会用系统默认(GBK),与插件的 UTF-8 一碰面中文就乱码——始终显式传 StandardCharsets.UTF_8。
  • 心跳/超时:长时间无交互,某些环境会断连。可在空闲时周期性发一个轻量 ping 消息保活,断连后做指数退避重连,尽量不要用死循环无间隔重连把 CPU 打满。
  • 消息边界:用 \n 行分隔,读取端要用 readLine 或按行切分,别一次性读半条命令。若一次 recv 拿到两条消息,要按 \n 拆开逐条解析——每条都是形如 COMMAND|key=value|… 的纯文本,黏在一起会被插件当成一条错指令。
  • 指令未生效:确认平台名拼写与仿真内一致(UAV_01 不是 uav_01),且目标在射程/航程内。插件不会替你校验"能不能打到",发错只会被静默忽略或报错。
  • 多线程写竞争:如上一节所说,后台读线程与业务写线程共用 Socket 输出流,务必加锁串行化写操作,否则两条指令黏连会导致解析失败。

小结

  • 外部控制是 TCP 行协议 + 管道文本:客户端连 127.0.0.1:31000,按 \n 分隔收发形如 COMMAND|key=value|… 的纯文本消息(不是 JSON)。
  • Java 客户端核心是 Socket + BufferedReader/Writer,统一 UTF-8;指令以 MOVE_TO / FIRE 等 COMMAND|… 文本行下发,响应以 OK| / ERROR| / DATA| 文本行回传。
  • Spring Boot 中由 ExternalControlService 管理连接与接收循环,经 Controller 暴露给业务/前端。
  • Python 客户端与 Java 同契约,几行即可连上,适合算法快速验证。
  • 踩坑重点:连接被拒=插件未启动、编码统一、心跳保活、行边界、平台名大小写。想扩展协议或自己写插件,见 第 14 篇《自定义 C++ 插件开发》。

下期预告

代码闭环已经打通,但"我到底有没有连对、指令有没有生效、态势长啥样",光看日志不够直观。下一篇 《13 可视化利器 Wizard:快速调试与态势呈现》 带你用 AFSIM 自带的 Wizard 把场景树、track、外部控制连接状态一目了然地呈现出来,调试效率直接拉满。点赞收藏关注,咱们下期见!

← 返回列表