核心课程 / ROS2 应用与开发 / EXP-07
EXP-07 · 第 2 章 · 设计性 · 4 学时

服务通信:指纹传感器的一问一答

话题像广播:说完就走,不管谁在听。但"验证这根手指是不是主人"必须拿到一个明确的答复才能决定开不开门——这种一对一、必须等到答案的调用,就是服务(Service)。本课用指纹验证讲透 request/response 的每一拍,并把"采图 → 生成特征 → 搜索比对"的底层三步看明白。

难度 ⭐⭐4 学时设计性关键词 service · call_async · UART实验箱 B2

目标本课学完,你应该能做到

① 说清模型
同步 RPC

能阐述"客户端发 Request → 阻塞等待 → 服务端回调处理 → 返回 Response"的同步模型,并说清它与话题(异步一对多持续流)在语义与适用场景上的本质区别。

② 掌握链路
create → call → 等

掌握 fingerprint_server 与 fingerprint_client 从 create_service/create_client、call_async 到 spin_until_future_complete 的完整调用链。

③ 独立验证
命令行触发一次

能独立运行服务端与客户端,用 ros2 service call 触发并观察响应,理解指纹"采图 → 生成特征 → 搜索比对"三步。

④ 迁移应用
正确选型

能把「UART 传感器 → 服务封装 → 同步调用」迁移到继电器控制、单次状态查询等场景,并在服务 / 话题 / 动作间做出正确选型。

本课的重点与难点 重点:服务回调里做阻塞操作会拖死整个节点——这是"同步"的代价,也是必须理解的边界。
难点:spin_until_future_complete 为什么不是"死循环等";以及服务与话题的选型边界。
和前后的关系 本课是三种通信范式的第二种。学完 EXP-06 的异步,再学这里的同步,两者的差异才立得住。

01跑出现象:一步步跑通指纹验证

先别急着敲命令——先花 30 秒看懂整套流程,再一条条跑。不然你会困惑"为什么要先录入、为什么录入时不能开服务端"。

整套流程长这样:四步,三个"身份"

指纹门禁的完整闭环是"先登记,后验证"——模组里必须先存着某根手指的模板,后面才能比对成功。所以顺序不能颠倒:

第 ① 步 · 录入(终端 P,只做一次)
同一根手指按两次 → 模组合并成一个模板 → 写进模组 Flash。不走 ROS,是独立脚本;做完这一步,模组里才有"底档"。
↓ 模板已存在模组里(断电不丢)
第 ② 步 · 启动服务端(终端 A,常驻)
服务端占住串口,注册 /verify_fingerprint 服务,然后待命等请求。它一起来,录入脚本就不能再跑了——串口只能被一个人用。
↓ 服务已在线,开始接受"下单"
第 ③ 步 · 客户端验证(终端 B)
客户端找到该服务 → 放下手指 → 发请求 → 等服务端采图、提特征、比对 → 拿到"成功 / 失败"。
↓ 也可以不写代码,直接用命令行调
第 ④ 步 · CLI 直调(终端 C,排障必备)
用 ros2 service list / type / call 绕过客户端直接调服务。能调通说明服务端没问题——排障时先 CLI、再代码。
⚠️ 为什么顺序不能乱:① 没录入就验证 → 必然 user_id=-1 未匹配(库里空的);② 服务端开着还去跑录入 → 串口被抢,录入报错。这两条是本节最常见的两个坑,先把顺序记住,后面就不用试错。
要用的终端干什么跑完能不能关
终端 P录入指纹(独立脚本,不走 ROS)能关,但必须先停服务端才能跑
终端 A服务端,占住串口等请求不能关——B 和 C 都要靠它
终端 B客户端,发请求等应答能关,每次验证跑一次
终端 CCLI 观察与直调能关,随时可开

流程清楚了,下面按 ①录入 → ②服务端 → ③客户端 → ④CLI 的顺序来。一条命令一张卡,每条都告诉你为什么敲和该看到什么。

终端 P第 ① 步 · 录入指纹⚠ 录入前先确认服务端没有在运行(两者抢同一个串口)
1进入驱动目录终端 P

录入是独立串口脚本,不走 ROS——所以直接进源码目录跑。

cd ~/ros2_ws/src/xflz_eai_car1/driver/ros_sensor/ros_sensor/ros_sensor
2录入指纹到位置 1终端 P

按终端提示,把同一根手指放上传感器两次(两次特征合并成一个模板写入 Flash)。

python3 fingerprint_enroll.py enroll -i 1
$ python3 fingerprint_enroll.py enroll -i 1 === 录入指纹到位置 #1 === 请将手指放到传感器上(第1次)... ✓ 采集到指纹图像 ✓ 特征1已生成 请提起手指... 请再次将同一手指放到传感器上(第2次)... ✓ 采集到指纹图像 ✓ 特征2已生成 ✓ 模板已合并 ✓ 录入成功!指纹已保存到位置 #1

✔ 两次按压必须是同一根手指、角度尽量一致,否则报"合并模板失败"——重新录一遍即可。

手指按在指纹传感器上(蓝色背光采集窗口)
实拍|手指放哪:平放在指纹模组的蓝色背光采集窗口上,指肚贴满窗口、别偏。压得太轻采不到图(模组回 0x02),压得太重或角度斜会生成错误特征。
录入脚本终端输出:两次按压后模板合并成功
实拍|录入成功的完整输出:两次 ✓ 采集到指纹图像 → ✓ 特征已生成 → ✓ 模板已合并 → 保存到位置 #1。只按一次不会成功——必须两次才够合并出一个模板。
3确认模板已入库终端 P
python3 fingerprint_enroll.py list
指纹库模板数量: 1

✔ 显示 1 就说明模板真的写进模组 Flash 了(断电不丢)。后面验证能匹配上,靠的就是这份模板。

终端 A第 ② 步 · 启动服务端保持运行,别停
4起服务端节点终端 A

新开终端,先回到工作空间根目录并加载环境(ros2 run 找包靠它)。

cd ~/ros2_ws
source install/setup.bash
ros2 run ros_sensor fingerprint_server
[INFO] 指纹服务已启动: port=/dev/ttyS10 baud=57600 # 服务端进入待命:等待 /verify_fingerprint 上的请求

✔ port/baud 来自参数(可用 --ros-args -p port:=… 或环境变量 FP_PORT 覆盖)——07 节代码里看它们是怎么声明的。

终端 B第 ③ 步 · 客户端发起验证服务端保持运行
5起客户端节点终端 B

客户端先等服务上线,再发请求——按提示把手录入过的那根手指放上传感器。

cd ~/ros2_ws
source install/setup.bash
ros2 run ros_sensor fingerprint_client
[INFO] 等待指纹服务 /verify_fingerprint ... [INFO] 指纹服务已就绪 [INFO] 发送指纹验证请求... [INFO] 验证成功 user_id=1 匹配成功 score=1151

✔ 换一根没录入过的手指再跑一次:终端打印 验证失败 user_id=-1 未匹配到指纹——两次现象都留证据(05 节动手任务要用)。

fingerprint_client 终端:验证成功 user_id=1 score=1151
实拍|验证成功的终端:等待指纹服务… → 服务已就绪 → 发送请求... → 验证成功 user_id=1 score=1151。注意第一行——客户端是先等服务上线才发请求的,这就是 wait_for_service 的作用。
终端 C第 ④ 步 · CLI 三连:绕过客户端直调服务终端 A 保持运行
6列出所有服务终端 C

这条命令相当于查服务黄页——/verify_fingerprint 在不在、谁来提供,一目了然。

ros2 service list | grep verify
/verify_fingerprint
7查服务的类型终端 C
ros2 service type /verify_fingerprint
ros_sensor_interfaces/srv/VerifyFingerprint

✔ 想看这个接口里有哪些字段,用 EXP-09 的 ros2 interface show ros_sensor_interfaces/srv/VerifyFingerprint。

8直接调用(不写一行客户端代码)终端 C

调用前先把手指放好——这个 srv 的请求字段为空,所以参数必须传 "{}";不放手指的话,你会看到服务端轮询 50 次(约 10s)后回超时。

ros2 service call /verify_fingerprint ros_sensor_interfaces/srv/VerifyFingerprint "{}"
$ ros2 service call /verify_fingerprint ros_sensor_interfaces/srv/VerifyFingerprint "{}" success: true user_id: 1 message: 匹配成功 score=1151

✔ 三连的价值:排障时先 CLI、再代码——CLI 能调通说明服务端没问题,问题在客户端;反之先查服务端。

02问题引入:为什么"验证一根手指"不能像浓度那样广播

你刚跑通了指纹验证。现在换个真实场景想一圈——给实验室装一道指纹门禁。要求很具体:

🚪 场景:谁想进门,谁就得先"问一句"
同事把手指按上去 → 门禁程序判断"是不是登记过的人" → 是就开锁,不是就报警。
关键在于:开锁这个动作,必须基于一个确定的答案。程序不能"大概觉得像"就开门,也不能广播一句"有人要进门"就转身去干别的——它必须拿到明确的"通过 / 不通过",才能决定下一步。

这就是它和上一课(EXP-06)的本质差别。MQ5 浓度是持续广播:每秒发一次,有没有人听都行,多一个人听也无所谓。但"验证手指"是一次性提问——问的人站着等答案,答案只属于这一次提问,问完这段对话就结束。

🏠 生活里这种"一问一答"到处都是:去银行柜台办卡,柜员办完把卡递给你——这笔业务只属于你,旁边排队的人不会拿到你的卡;点外卖下单,平台必须回你一句"接单成功",你才敢去等餐;打 120,接线员必须明确说"车已派出",你才敢挂电话。共同点:一个人问、一个人答,问的人必须等到答复才能做下一步决定。

那要是硬用话题来做,会撞上什么?

不是没人这么试过。把"验证手指"塞进话题,你会立刻遇到四个麻烦:

📵 硬用话题做"验证手指"?四堵墙
  • 得自己开两条话题:"请求话题"+"响应话题",还要自己配对——响应堆里哪条是回答我这条的?
  • 广播是发给所有人的:系统里另一个程序也收到"验证请求",它也去操作指纹模组 → 串口打架
  • "没人应答"没有任何机制兜底:发完就完了,等多久算失败?
  • 成功/失败/超时/设备错误全靠自定义字段约定,每个节点各猜各的
✅ 用服务:这些 ROS2 都替你想好了
  • 一次调用一次应答,天然可配对,响应一定回到发起者手里
  • 服务名全局唯一注册(/verify_fingerprint),不会有两个客户端抢同一个模组的局面由你设计
  • wait_for_service 明确告诉你"服务在不在线"
  • 应答字段(success / user_id / message)写进 .srv 接口,语义统一
话题 = 小区广播 服务 = 打客服电话 MQ5 传感器(广播站) 同一份数据同时撒给所有人 报警器 云端记录 物业大屏 听不听、谁在听,说话的人不管 持续 · 异步 · 一对多 门禁程序(客户端) ① Request:验证这根手指 ② Response:成功 user_id=1 指纹服务端(接线员) 这条线只属于这一次通话,答复必达 单次 · 同步 · 一对一 广播说完就走;电话不拿到答复,事情就办不成
图 7-1|话题像广播(发完即走,不管谁听),服务像打客服电话(一对一,必须等到明确答复)
定义 Service客户端发送 Request 后进入等待,服务端收到请求调用回调、产出 Response 回传——一次调用一次应答,天然可配对、天然知道成败。也称 RPC(远程过程调用):你像调用本机函数一样发起调用,真正干活的代码可能跑在另一台机器上。

03核心原理:一次 RPC 怎么跑起来(结构 · 时序 · 模组底层)

一个容易误会的地方先说破:ROS2 的服务不是"客户端直接调用服务端的函数"。把两个节点拆开看,中间隔着 DDS 和序列化——服务在底层其实是"两个方向的通道 + 一个配对约定":请求沿一条通道去,应答沿另一条通道回,ROS2 靠请求里自带的客户端标识 + 序号把应答精确配回给发起者。下面这张图就是它内部的全貌(图 7-2)。

客户端节点 fingerprint_client ① create_client wait_for_service 确认在线 ② call_async(Request) 立即返回 Future,不卡发送 ③ spin_until_future_complete 驱动执行器,同步等到回包 ④ future.result() 取出 Response,按 success 分支 请求自带 client_gid + 序号(配对凭证) DDS 全局数据空间 Request 通道(→) 序列化后的请求帧 Response 通道(←) 带同一序号的应答帧 两个方向各走各的通道, 配对靠序号,不靠"记性" 服务端节点 fingerprint_server ⑤ create_service 名字+类型+回调 注册进计算图 ⑥ 执行器派发回调 handle_request(request, response) ⑦ 填 response 并 return 采图 → 特征 → 比对 返回值即回传内容
图 7-2|服务的内部结构:请求、应答各走一条通道,配对靠请求自带的序号凭证——四步在客户端、三步在服务端

四拍流程:把图 7-2 走一遍

1
服务端注册:create_service(类型, '名字', 回调)——向计算图宣告"我提供此服务",然后等被调。
2
客户端就绪:create_client + wait_for_service 循环——确认服务在线才敢发请求。
3
异步发送:call_async(Request) 立即返回 Future——发送本身不阻塞(名字里的 async 名副其实)。
4
同步等待:spin_until_future_complete 驱动执行器直到回包,future.result() 取应答。
async 与"同步"矛盾吗不矛盾。call_async 只负责"发不卡"——如果发送时就卡住,执行器被占死,回包来了也没人处理;spin_until_future_complete 负责"等到"。两者合起来,才是"异步发送、同步语义"的标准写法:从调用者的视角看,这一行执行完,答案就在手里。
话题 vs 服务话题(EXP-06)服务(本课)
调用语义持续数据流,一对多单次请求/响应,一对一
时间性异步,发完即走同步,答到才回
可取消否否(阻塞中只能等或超时)
中间反馈无(值到了就是反馈)无(这正是动作要补的短板,EXP-08)
典型场景MQ5 浓度持续发布指纹验证、继电器控制、单次状态查询

时序:一问一答的四个瞬间

上面那张结构图讲的是"里子",这张时序图讲"面子"——一次完整调用压缩成 4.8 秒循环:①请求出发 → 服务端回调干活 → ②应答返回。盯着粉色小球(请求)和紫色小球(应答)各走一遍,服务的时序就掌握了。

client 节点 server 节点 ① call_async → /verify_fingerprint Request(带序号) handle_request() 采图→特征→比对 (可能耗时数秒) ② Response(success / user_id / message) 客户端阻塞窗口:①发出 → ②收到(期间执行器仍在为它服务,future 完成即解锁) 一发一收之间没有第三拍:没有中间反馈、不可取消——要"边干边报进度"就得升级为动作(EXP-08)

对照图 7-2 看:① = 步骤②③(发送+等待的起点),服务端暗格 = 步骤⑥⑦,② = 应答通道回程。

ROS 层之下:指纹模组的三步与"确认码"

服务端回调里那句"采图 → 特征 → 比对"到底做了什么?再往下钻一层:指纹模组走 UART 主从协议(包头 0xEF01 + 指令 + 校验)。它是个"一问一答"的老实人——主机发一条指令,模组回一个确认码。验证一次要走三步:

第 1 步 · PS_GetImage 指令码 0x01 循环轮询等手指按下 0x02 无手指 → sleep(0.2) 再试 共 retries=50 次 ≈ 10s 窗口 第 2 步 · PS_GenChar 指令码 0x02 把图像转成特征向量 指纹的"数学指纹" (不是存图片!) 第 3 步 · PS_Search 指令码 0x04 与 Flash 中模板比对 命中 → user_id(页码) + score 匹配分 录入(写 Flash,人工按两次)用独立脚本;验证(高频程序化)走 ROS 服务——职责各归其位
图 7-3|指纹验证三步:每一步都是"发指令 → 等确认码",ROS 服务端只是这三步的调用者

确认码:状态 ≠ 错误(高频考点)

确认码含义处理策略
0x00成功进入下一步
0x01收包有错真错误:校验失败/帧格式问题,返回失败
0x02无手指不是错误,是状态——继续轮询等待
🚪 类比:"门铃没人按" ≠ "门铃坏了"。0x02 是"还没人按门铃"——你该继续等;0x01 才是"门铃坏了"——该报修了。把 0x02 当失败立即返回,用户手指还没放稳,调用就"验证失败"了。把可恢复状态与真错误区分开,是传感器轮询鲁棒性的第一课。

轮询参数的账:retries=50 次 × sleep(0.2) 秒 ≈ 10 秒等待窗口——给用户留出"看到提示 → 放手指"的反应时间。这个策略的源码就在下面的服务端 `handle_request` 里,第 2 步会逐行讲。

04实验步骤:先看懂框架,再读代码,最后跑出现象

换个顺序读代码:先看骨架(三份文件各管什么、谁调谁)→ 再读核心几段(只讲非讲不可的)→ 最后回到终端跑一遍,把代码和现象对上号。反过来"一上来逐行啃"最容易迷路——语法记住了,却说不清这段代码为什么长这样。

第 1 步 · 先看骨架:三份文件,一条调用链

你刚才跑过的所有东西,来自三份代码。别急着看细节,先记住分工——骨架立住了,后面读代码只是填肉。

① fingerprint_enroll.py · 协议层(不碰 ROS)
把字节级收发封装成 Fingerprint 类:发指令帧、解析确认码。录入脚本和两个 ROS 节点共用它。
↓ 被 import,不直接对外
② fingerprint_server.py · 服务端
用 create_service 注册 /verify_fingerprint;回调里调协议层走"采图→特征→比对",填好 response 返回。
↓ DDS 搬运请求与应答(这一层你一行都不写)
③ fingerprint_client.py · 客户端
用 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_parameterport/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 步都是"跟着看现象"。这一节的任务故意不重复那些操作——每个任务都要你改一处新东西(改参数、扩接口、改源码),然后自己判断"结果该是什么样"再去验证。能预判结果,才算真懂。

📌 先立个规矩:动手前先在纸上写下你预期的现象(哪怕只有两个字)。跑完再对照——预期错了,恰恰是最有价值的一次学习。
任务 1 · 改参数:把等待窗口砍到 2 秒
不改代码,用 --ros-args -p retries:=10 重启服务端(10×0.2s≈2s)。
先预判:不放手指时,多久收到超时?
然后验证:用秒表实测,和 2 秒对得上吗?
要记录:实测秒数 + 你算的账。
任务 2 · 改源码:让 0x02 变成真错误
把 handle_request 里 if code == 0x02 那段去掉,让 0x02 落进 else 分支立即返回失败。
先预判:手指放得慢一点会怎样?
然后验证:故意慢半秒放手指,看是否直接"验证失败"。
要记录:改动前后的两次结果对比。
这题是本课最核心的鲁棒性教训。
任务 3 · 扩接口:给 .srv 加个返回字段
在 VerifyFingerprint.srv 的应答区加一行 float64 score,服务端填真实分数,客户端打印它(改动要重编 ros_sensor_interfaces)。
先预判:不改客户端会怎样?
要记录:加字段前后的客户端输出,以及"为什么必须两端同时重编"。
任务 4 · 造第三个客户端(协议层)
不写 Python,只用 ros2 service call 连调三次,观察每次的 score 值。
先预判:同一根手指连按三次,score 会一样吗?
要记录:三次 score 和你的解释(提示:score 是"相似度",不是"是否匹配")。
任务 5 · 串口独占(排障实验)
服务端运行中,再跑 fingerprint_enroll.py enroll -i 2。
先预判:会成功、会排队、还是直接报错?
要记录:报错原文,并结合 try/finally: fp.close() 解释"为什么一个人在用时另一个人插不进来"。
任务 6 · 选型设计(综合,可交报告)
要实现"按下按钮 → 机械臂抓取 → 完成后亮灯",该用话题、服务还是动作?
要记录:三选一的理由 + 画出节点与接口草图(提示:抓取过程要几分钟、还要能中止,想想 EXP-08 补的是什么短板)。不用真写代码。
🎯 任务目的:任务 1、5 练"参数与资源的边界"(什么该做成参数、什么天生独占);任务 2、3 练"改代码/改接口"的完整闭环(改哪、重编谁、验证什么);任务 4 练"用 CLI 独立验证服务端";任务 6 练选型判断。任务 2 和任务 6 最能拉开差距。
#你要改动的地方要记录的证据对应验收点(06 节)
1启动参数 retries:=10实测超时秒数 + 你的算账能算出等待窗口 = retries × sleep
2handle_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_parameterget_parameter 报异常port/baud/retries 三个参数先声明后取用
两次按压手指不同录入报"合并模板失败"重新录入,两次同一根手指、角度一致、清洁干燥

07本课总结与思考题

语义
一对一 · 单次 · 同步

应答配对内建,专治"必须等到答案"的场景。

写法
异步发送 + 同步等待

call_async 保执行器畅通,spin_until_future_complete 负责等到。

分层
ROS 服务 ⇄ UART 协议

服务端不拼帧,只调 Fingerprint 类;确认码区分状态与错误。

调试
ros2 service 三连

list/type/call 无需客户端即可验证服务端。

思考题 ① 服务端把 0x02(无手指)当错误立即返回会怎样?结合上面的确认码表和"轮询 + sleep(0.2)"说明状态码与错误码分离对鲁棒性的意义。
② 当前 srv 请求为空,无法指定"验证哪个用户/验证通过后开门还是记考勤"——如何扩展 .srv?给出字段设计与调用流程。
③ (进阶)轮询 50 次约 10 秒后超时返回——如果想让客户端"等不及可放弃",服务能做到吗?不能的话该怎么办?(衔接 EXP-08 动作的取消能力)