服务通信:指纹传感器的一问一答
话题像广播:说完就走,不管谁在听。但"验证这根手指是不是主人"必须拿到一个明确的答复才能决定开不开门——这种一对一、必须等到答案的调用,就是服务(Service)。本课用指纹验证讲透 request/response 的每一拍,并把"采图 → 生成特征 → 搜索比对"的底层三步看明白。
目标本课学完,你应该能做到
能阐述"客户端发 Request → 阻塞等待 → 服务端回调处理 → 返回 Response"的同步模型,并说清它与话题(异步一对多持续流)在语义与适用场景上的本质区别。
掌握 fingerprint_server 与 fingerprint_client 从 create_service/create_client、call_async 到 spin_until_future_complete 的完整调用链。
能独立运行服务端与客户端,用 ros2 service call 触发并观察响应,理解指纹"采图 → 生成特征 → 搜索比对"三步。
能把「UART 传感器 → 服务封装 → 同步调用」迁移到继电器控制、单次状态查询等场景,并在服务 / 话题 / 动作间做出正确选型。
难点:
spin_until_future_complete 为什么不是"死循环等";以及服务与话题的选型边界。01跑出现象:一步步跑通指纹验证
先别急着敲命令——先花 30 秒看懂整套流程,再一条条跑。不然你会困惑"为什么要先录入、为什么录入时不能开服务端"。
整套流程长这样:四步,三个"身份"
指纹门禁的完整闭环是"先登记,后验证"——模组里必须先存着某根手指的模板,后面才能比对成功。所以顺序不能颠倒:
/verify_fingerprint 服务,然后待命等请求。它一起来,录入脚本就不能再跑了——串口只能被一个人用。ros2 service list / type / call 绕过客户端直接调服务。能调通说明服务端没问题——排障时先 CLI、再代码。user_id=-1 未匹配(库里空的);② 服务端开着还去跑录入 → 串口被抢,录入报错。这两条是本节最常见的两个坑,先把顺序记住,后面就不用试错。| 要用的终端 | 干什么 | 跑完能不能关 |
|---|---|---|
| 终端 P | 录入指纹(独立脚本,不走 ROS) | 能关,但必须先停服务端才能跑 |
| 终端 A | 服务端,占住串口等请求 | 不能关——B 和 C 都要靠它 |
| 终端 B | 客户端,发请求等应答 | 能关,每次验证跑一次 |
| 终端 C | CLI 观察与直调 | 能关,随时可开 |
流程清楚了,下面按 ①录入 → ②服务端 → ③客户端 → ④CLI 的顺序来。一条命令一张卡,每条都告诉你为什么敲和该看到什么。
录入是独立串口脚本,不走 ROS——所以直接进源码目录跑。
cd ~/ros2_ws/src/xflz_eai_car1/driver/ros_sensor/ros_sensor/ros_sensor
按终端提示,把同一根手指放上传感器两次(两次特征合并成一个模板写入 Flash)。
python3 fingerprint_enroll.py enroll -i 1
✔ 两次按压必须是同一根手指、角度尽量一致,否则报"合并模板失败"——重新录一遍即可。
✓ 采集到指纹图像 → ✓ 特征已生成 → ✓ 模板已合并 → 保存到位置 #1。只按一次不会成功——必须两次才够合并出一个模板。python3 fingerprint_enroll.py list
✔ 显示 1 就说明模板真的写进模组 Flash 了(断电不丢)。后面验证能匹配上,靠的就是这份模板。
新开终端,先回到工作空间根目录并加载环境(ros2 run 找包靠它)。
cd ~/ros2_ws source install/setup.bash ros2 run ros_sensor fingerprint_server
✔ port/baud 来自参数(可用 --ros-args -p port:=… 或环境变量 FP_PORT 覆盖)——07 节代码里看它们是怎么声明的。
客户端先等服务上线,再发请求——按提示把手录入过的那根手指放上传感器。
cd ~/ros2_ws source install/setup.bash ros2 run ros_sensor fingerprint_client
✔ 换一根没录入过的手指再跑一次:终端打印 验证失败 user_id=-1 未匹配到指纹——两次现象都留证据(05 节动手任务要用)。
等待指纹服务… → 服务已就绪 → 发送请求... → 验证成功 user_id=1 score=1151。注意第一行——客户端是先等服务上线才发请求的,这就是 wait_for_service 的作用。这条命令相当于查服务黄页——/verify_fingerprint 在不在、谁来提供,一目了然。
ros2 service list | grep verify
ros2 service type /verify_fingerprint
✔ 想看这个接口里有哪些字段,用 EXP-09 的 ros2 interface show ros_sensor_interfaces/srv/VerifyFingerprint。
调用前先把手指放好——这个 srv 的请求字段为空,所以参数必须传 "{}";不放手指的话,你会看到服务端轮询 50 次(约 10s)后回超时。
ros2 service call /verify_fingerprint ros_sensor_interfaces/srv/VerifyFingerprint "{}"
✔ 三连的价值:排障时先 CLI、再代码——CLI 能调通说明服务端没问题,问题在客户端;反之先查服务端。
02问题引入:为什么"验证一根手指"不能像浓度那样广播
你刚跑通了指纹验证。现在换个真实场景想一圈——给实验室装一道指纹门禁。要求很具体:
同事把手指按上去 → 门禁程序判断"是不是登记过的人" → 是就开锁,不是就报警。
关键在于:开锁这个动作,必须基于一个确定的答案。程序不能"大概觉得像"就开门,也不能广播一句"有人要进门"就转身去干别的——它必须拿到明确的"通过 / 不通过",才能决定下一步。
这就是它和上一课(EXP-06)的本质差别。MQ5 浓度是持续广播:每秒发一次,有没有人听都行,多一个人听也无所谓。但"验证手指"是一次性提问——问的人站着等答案,答案只属于这一次提问,问完这段对话就结束。
那要是硬用话题来做,会撞上什么?
不是没人这么试过。把"验证手指"塞进话题,你会立刻遇到四个麻烦:
📵 硬用话题做"验证手指"?四堵墙
- 得自己开两条话题:"请求话题"+"响应话题",还要自己配对——响应堆里哪条是回答我这条的?
- 广播是发给所有人的:系统里另一个程序也收到"验证请求",它也去操作指纹模组 → 串口打架
- "没人应答"没有任何机制兜底:发完就完了,等多久算失败?
- 成功/失败/超时/设备错误全靠自定义字段约定,每个节点各猜各的
✅ 用服务:这些 ROS2 都替你想好了
- 一次调用一次应答,天然可配对,响应一定回到发起者手里
- 服务名全局唯一注册(
/verify_fingerprint),不会有两个客户端抢同一个模组的局面由你设计 wait_for_service明确告诉你"服务在不在线"- 应答字段(success / user_id / message)写进
.srv接口,语义统一
03核心原理:一次 RPC 怎么跑起来(结构 · 时序 · 模组底层)
一个容易误会的地方先说破:ROS2 的服务不是"客户端直接调用服务端的函数"。把两个节点拆开看,中间隔着 DDS 和序列化——服务在底层其实是"两个方向的通道 + 一个配对约定":请求沿一条通道去,应答沿另一条通道回,ROS2 靠请求里自带的客户端标识 + 序号把应答精确配回给发起者。下面这张图就是它内部的全貌(图 7-2)。
四拍流程:把图 7-2 走一遍
create_service(类型, '名字', 回调)——向计算图宣告"我提供此服务",然后等被调。create_client + wait_for_service 循环——确认服务在线才敢发请求。call_async(Request) 立即返回 Future——发送本身不阻塞(名字里的 async 名副其实)。spin_until_future_complete 驱动执行器直到回包,future.result() 取应答。| 话题 vs 服务 | 话题(EXP-06) | 服务(本课) |
|---|---|---|
| 调用语义 | 持续数据流,一对多 | 单次请求/响应,一对一 |
| 时间性 | 异步,发完即走 | 同步,答到才回 |
| 可取消 | 否 | 否(阻塞中只能等或超时) |
| 中间反馈 | 无(值到了就是反馈) | 无(这正是动作要补的短板,EXP-08) |
| 典型场景 | MQ5 浓度持续发布 | 指纹验证、继电器控制、单次状态查询 |
时序:一问一答的四个瞬间
上面那张结构图讲的是"里子",这张时序图讲"面子"——一次完整调用压缩成 4.8 秒循环:①请求出发 → 服务端回调干活 → ②应答返回。盯着粉色小球(请求)和紫色小球(应答)各走一遍,服务的时序就掌握了。
对照图 7-2 看:① = 步骤②③(发送+等待的起点),服务端暗格 = 步骤⑥⑦,② = 应答通道回程。
ROS 层之下:指纹模组的三步与"确认码"
服务端回调里那句"采图 → 特征 → 比对"到底做了什么?再往下钻一层:指纹模组走 UART 主从协议(包头 0xEF01 + 指令 + 校验)。它是个"一问一答"的老实人——主机发一条指令,模组回一个确认码。验证一次要走三步:
确认码:状态 ≠ 错误(高频考点)
| 确认码 | 含义 | 处理策略 |
|---|---|---|
0x00 | 成功 | 进入下一步 |
0x01 | 收包有错 | 真错误:校验失败/帧格式问题,返回失败 |
0x02 | 无手指 | 不是错误,是状态——继续轮询等待 |
轮询参数的账:retries=50 次 × sleep(0.2) 秒 ≈ 10 秒等待窗口——给用户留出"看到提示 → 放手指"的反应时间。这个策略的源码就在下面的服务端 `handle_request` 里,第 2 步会逐行讲。
04实验步骤:先看懂框架,再读代码,最后跑出现象
换个顺序读代码:先看骨架(三份文件各管什么、谁调谁)→ 再读核心几段(只讲非讲不可的)→ 最后回到终端跑一遍,把代码和现象对上号。反过来"一上来逐行啃"最容易迷路——语法记住了,却说不清这段代码为什么长这样。
第 1 步 · 先看骨架:三份文件,一条调用链
你刚才跑过的所有东西,来自三份代码。别急着看细节,先记住分工——骨架立住了,后面读代码只是填肉。
Fingerprint 类:发指令帧、解析确认码。录入脚本和两个 ROS 节点共用它。create_service 注册 /verify_fingerprint;回调里调协议层走"采图→特征→比对",填好 response 返回。create_client 找到同名服务;call_async 发请求 → spin_until_future_complete 等应答 → 按 success 分支。fingerprint_enroll.py 负责会跟硬件说话,fingerprint_server.py 负责接单干活,fingerprint_client.py 负责下单等结果。录入脚本是"编外人员"——它不走 ROS,直接用协议层,所以和服务端抢串口(这就是 01 节提醒"录入前先停服务端"的原因)。~/ros2_ws/src/xflz_eai_car1/driver/ros_sensor/ros_sensor/ros_sensor/(外层 ros_sensor/ 是包,里层同名目录才是放 .py 的)。建议边读边打开源码对照。第 2 步 · 再读代码:三段非讲不可的地方
按调用链的顺序看三段。每段只抓关键几行——其余的准备代码你打开源码一眼就懂,这里不占篇幅。
① 协议层:指纹模组怎么"说话"
模组是"一问一答"的主从协议:主机发指令帧,模组回确认码。这个文件把字节级的收发全部封装成 Fingerprint 类,ROS 节点只管调用它的方法。
# 来源:fingerprint_enroll.py L19-33(指令码与确认码定义) # ── 指令码:每个都是"一条命令的编号"── CMD_GET_IMAGE = 0x01 # PS_GetImage 采集图像 CMD_GEN_CHAR = 0x02 # PS_GenChar 生成特征 CMD_SEARCH = 0x04 # PS_Search 搜索指纹库 CMD_REG_MODEL = 0x05 # PS_RegModel 合并特征生成模板(录入用) CMD_STORE_CHAR = 0x06 # PS_StoreChar 储存模板(录入用) # ── 确认码:模组对每条命令的"答复"── # 0x00 成功 0x01 收包有错 0x02 无手指(状态,不是错误!)
# 帧结构(_build_packet,L36-42):一帧命令长这样—— ┌────────┬──────────┬────────┬────────┬────────┬────────┬──────────┐ │ 包头 │ 地址 │ 包标识 │ 长度 │ 指令码 │ 参数 │ 校验和 │ │ EF01 │ FF FF FF │ 01 │ 2 字节 │ 1 字节 │ n 字节 │ 2 字节 │ │ 固定头 │ 默认地址 │ 命令包 │ 后段长度 │ 干什么 │ 附加数据 │ 防传输出错 │ └────────┴──────────┴────────┴────────┴────────┴────────┴──────────┘ # 校验和 = 对"包标识+长度+指令码+参数"逐字节求和取低 16 位。 # _send()(L56-73)负责发帧、等应答帧、校验确认码——带超时保护。
# Fingerprint 类对外方法(接口示意,实现在 L45-124) fp = Fingerprint(port, baud) # 打开串口 code = fp.get_image() # → 0x00 采到图 / 0x02 无手指 / 0x01 帧错误 code = fp.gen_char(buf_id) # 图像 → 特征 result = fp.search() # 搜索库:result.page=命中页码(未命中 -1) # 录入脚本专用:fp.reg_model() 合并两次特征 / fp.store_char() 写入 Flash
| 逐行看点 | 它在干什么 |
|---|---|
| 帧尾的校验和 | 串口线上防传输出错的唯一手段——校验不过模组直接回 0x01"收包有错" |
_send 的收发超时 | 发帧后等应答帧带超时保护——串口不像网络有重传,没应答基本就是模组掉线 |
get_image → gen_char → search | 正好是上面图 7-3 的三步——协议层把每步各封一个方法,服务端按顺序调用 |
录入四步 get_image×2 + reg_model + store_char | 录入脚本专用:两次特征 + 合并 + 写 Flash——这就是录入和验证分成两个程序的原因 |
② 服务端:接单、干活、回话
# 来源:fingerprint_server.py L19-26(节点初始化:参数声明 + 服务注册) super().__init__('fingerprint_server') self.declare_parameter('port', os.environ.get('FP_PORT', '/dev/ttyS10')) self.declare_parameter('baud', int(os.environ.get('FP_BAUD', '57600'))) self.declare_parameter('retries', 50) # 轮询次数:50×0.2s≈10s 窗口 self.srv = self.create_service( VerifyFingerprint, 'verify_fingerprint', self.handle_request) # ↑类型 ↑服务名(=客户端找它的地址) ↑回调
# 来源:fingerprint_server.py L46-87(handle_request:验证三步 + 串口保护) def handle_request(self, request, response): fp = Fingerprint(port, baud) # 打开串口 try: for _ in range(retries): # ① 轮询等手指 code = fp.get_image() if code == 0x00: break # 采到图 → 出循环 if code == 0x02: # 无手指 = 状态,继续等 time.sleep(0.2); continue response.success = False # 其它码 = 真错误 response.message = f'采集图像失败(code={code})' return response fp.gen_char(1) # ② 生成特征 result = fp.search() # ③ 搜索比对 if result.page >= 0: response.success = True response.user_id = result.page # 命中页码就是用户编号 response.message = f'匹配成功 score={result.score}' else: response.success = False; response.user_id = -1 response.message = '未匹配到指纹' finally: fp.close() # 串口必须无条件归还,否则下次打开失败(泄漏) return response # 返回的 response 即回传给客户端
| 逐行看点 | 为什么这么写 |
|---|---|
三个 declare_parameter | port/baud/retries 先声明后取用——忘声明就 get_parameter 会直接抛异常 |
0x02 → sleep(0.2) → continue | 即上面"确认码:状态 ≠ 错误"那张表的落地写法:状态与错误分流,10s 等待窗口由此而来 |
try/finally: fp.close() | 不管成功失败、哪怕中途抛异常,串口都会被关闭——否则服务端第二次调用就打不开串口 |
给 response 的每个字段赋值后 return | 服务端回调的"返回值即应答":你填什么,客户端就收到什么 |
③ 客户端:异步发送、同步等待
# 来源:fingerprint_client.py L16-40(创建客户端 → 发请求 → 等结果) super().__init__('fingerprint_client') self.cli = self.create_client(VerifyFingerprint, 'verify_fingerprint') # ↑同名服务:客户端靠这个名字找到服务端 while not self.cli.wait_for_service(timeout_sec=1.0): # ① 等上线 self.get_logger().info('等待指纹服务 /verify_fingerprint ...') req = VerifyFingerprint.Request() # ② 本 srv 请求为空 future = self.cli.call_async(req) # ③ 异步发:立即返回 Future rclpy.spin_until_future_complete(self, future) # ④ 同步等:执行器转到 future 完成 resp = future.result() if resp is None: ... # 防御:调用失败时为 None if resp.success: self.get_logger().info(f'验证成功 user_id={resp.user_id} {resp.message}') else: self.get_logger().error(f'验证失败 user_id={resp.user_id} {resp.message}')
| 逐行看点 | 为什么这么写 |
|---|---|
wait_for_service 循环 | 客户端先启动时不会崩,而是一秒一次地等——服务端起来后它自动继续 |
call_async 名字里的 async | 只保证"发送不卡";真正等到结果靠下一行 spin——两者配合才是"同步语义" |
spin_until_future_complete | 驱动执行器处理回包;future 完成后这行才返回——阻塞就阻塞在这 |
resp is None 判断 | 服务调用本身失败(如被拒绝/通信异常)时 result() 是 None,不判就直接崩 |
| 环节 | 服务端(②) | 客户端(③) |
|---|---|---|
| 建立端点 | create_service(类型, 名, 回调) | create_client(类型, 名) |
| 确认对方在线 | 不需要(被动等请求) | wait_for_service 循环 |
| 交换数据 | 回调参数 request 收,return response 回 | call_async(req) 发,future.result() 收 |
| 名字必须一致的地方 | 'verify_fingerprint' 与 VerifyFingerprint 类型——差一个字母就永远"等待中" | |
第 3 步 · 最后跑出结果:把代码和现象对上号
代码读完了,回到终端。这一步不是重复 01 节,而是让"代码里的某一行"和"屏幕上的某一行"建立一一对应——对上了,才算真读懂。
| 你在代码里看到的 | 终端里对应的现象 | 说明 |
|---|---|---|
create_service(..., 'verify_fingerprint', ...) | ros2 service list | grep verify 能看到它 | 注册了才能被"查黄页"查到;名字就是客户端的寻址依据 |
if code == 0x02: sleep(0.2); continue | 手指不放,服务端不返回,客户端一直等 | 0x02 是"状态"不是"错误"——所以它在等,而不是报错 |
for _ in range(retries)(retries=50) | 约 10 秒后客户端收到"超时,未检测到手指" | 50 × 0.2s = 10s,等待窗口的上限由这两个数相乘决定 |
response.success = True; response.user_id = result.page | 客户端打印 验证成功 user_id=1 score=1151 | 回调里填什么,客户端就收到什么——"返回值即应答" |
finally: fp.close() | 连续调用两次不报错;服务端运行中跑录入会报串口占用 | 串口是独占资源,不归还下次就打不开 |
while not wait_for_service(timeout_sec=1.0) | 先起客户端时每秒打印"等待指纹服务…" | 不是死等——服务端一起来,循环就退出继续往下走 |
验证成功 user_id=1 score=1151,说出它是服务端哪个变量赋值的、经哪个通道回到客户端、又被哪一行打印出来。说得出这条链路,这节就过了。05动手任务:改点新东西,验证你真的懂了
01 节和上面第 3 步都是"跟着看现象"。这一节的任务故意不重复那些操作——每个任务都要你改一处新东西(改参数、扩接口、改源码),然后自己判断"结果该是什么样"再去验证。能预判结果,才算真懂。
--ros-args -p retries:=10 重启服务端(10×0.2s≈2s)。先预判:不放手指时,多久收到超时?
然后验证:用秒表实测,和 2 秒对得上吗?
要记录:实测秒数 + 你算的账。
handle_request 里 if code == 0x02 那段去掉,让 0x02 落进 else 分支立即返回失败。先预判:手指放得慢一点会怎样?
然后验证:故意慢半秒放手指,看是否直接"验证失败"。
要记录:改动前后的两次结果对比。
这题是本课最核心的鲁棒性教训。
VerifyFingerprint.srv 的应答区加一行 float64 score,服务端填真实分数,客户端打印它(改动要重编 ros_sensor_interfaces)。先预判:不改客户端会怎样?
要记录:加字段前后的客户端输出,以及"为什么必须两端同时重编"。
ros2 service call 连调三次,观察每次的 score 值。先预判:同一根手指连按三次,score 会一样吗?
要记录:三次 score 和你的解释(提示:score 是"相似度",不是"是否匹配")。
fingerprint_enroll.py enroll -i 2。先预判:会成功、会排队、还是直接报错?
要记录:报错原文,并结合
try/finally: fp.close() 解释"为什么一个人在用时另一个人插不进来"。要记录:三选一的理由 + 画出节点与接口草图(提示:抓取过程要几分钟、还要能中止,想想 EXP-08 补的是什么短板)。不用真写代码。
| # | 你要改动的地方 | 要记录的证据 | 对应验收点(06 节) |
|---|---|---|---|
| 1 | 启动参数 retries:=10 | 实测超时秒数 + 你的算账 | 能算出等待窗口 = retries × sleep |
| 2 | handle_request 里 0x02 分支 | 改动前后"慢放手指"的两次结果 | 能解释"状态 ≠ 错误" |
| 3 | .srv 应答区加字段 | 加字段前后客户端输出 | 懂接口改动要两端重编 |
| 4 | 无(只用 CLI) | 三次 score 值与解释 | 会用 service call 独立验证 |
| 5 | 无(制造资源冲突) | 串口占用报错原文 | 能解释 try/finally 关串口 |
| 6 | 无(设计题) | 选型理由 + 架构草图 | 能在话题/服务/动作间选型 |
06注意事项与验收清单
- 能按顺序背出服务客户端六步:create_client → wait_for_service → Request → call_async → spin_until_future_complete → result
- 解释清楚"call_async 是异步、整体语义却同步"的分工
- 录入 + 服务端/客户端验证 + CLI 三连全部成功
- 能说出 0x00/0x01/0x02 三个确认码及各自处理
- 能完成话题/服务/动作三机制的选型对比表
- 知道串口 try/finally 归还与"先停服务端再录入"的原因
| 坑 | 症状 | 解法 |
|---|---|---|
| 服务名不一致 | 客户端一直"等待指纹服务" | 先启动服务端,ros2 service list 确认 /verify_fingerprint 存在 |
| 串口被抢占 | 打开串口失败 / 录入报错 | 录入与服务端互斥;或用户不在 dialout 组 |
| call 参数写错 | call 报参数解析错误 | 本 srv 请求为空,必须传 "{}" |
| 手指没录入 | 验证恒 user_id=-1 | 先 enroll;验证时保持相同角度力度 |
| 忘了 declare_parameter | get_parameter 报异常 | port/baud/retries 三个参数先声明后取用 |
| 两次按压手指不同 | 录入报"合并模板失败" | 重新录入,两次同一根手指、角度一致、清洁干燥 |
07本课总结与思考题
应答配对内建,专治"必须等到答案"的场景。
call_async 保执行器畅通,spin_until_future_complete 负责等到。
服务端不拼帧,只调 Fingerprint 类;确认码区分状态与错误。
list/type/call 无需客户端即可验证服务端。
② 当前 srv 请求为空,无法指定"验证哪个用户/验证通过后开门还是记考勤"——如何扩展 .srv?给出字段设计与调用流程。
③ (进阶)轮询 50 次约 10 秒后超时返回——如果想让客户端"等不及可放弃",服务能做到吗?不能的话该怎么办?(衔接 EXP-08 动作的取消能力)