连续触摸时,启元 T1 应该怎么回应?一个完整的 ROS 2 交互程序

给机器人加一句触摸问候,第一反应往往是:订阅触摸消息,然后调用一次语音服务。真正把输入连续发过去,问题才开始出现。同一条事件又来了一次怎么办?上一句还在处理,下一次触摸要不要跟上?更麻烦的是,程序已经判定超时,旧响应却在新请求进行到一半时回来了。

这篇就围绕这些交互细节,给启元 T1 写一个完整的触摸程序。背部单击发送一句问候,胸部双击换一句回应;页面同时显示事件经过了哪一步。先看跑完一组场景的过程:

动图中的数字来自 ROS 2 程序的实际回调。独立场景节点一共发来 12 个触摸输入,应用实际发出 5 次服务请求,其中 3 次得到成功响应、1 次被拒绝、1 次超时。重复、间隔过短以及忙碌阶段的输入都有记录,所以能逐条解释为什么"收到触摸"和"发出请求"不是同一个数字。

这轮验证使用官方 SDK 的 TouchState 和 PlayTts 类型,场景节点经 DDS 发布事件,独立测试服务按设定返回成功、拒绝或延迟响应。测试服务不合成、播放音频;这里观察的是事件处理与服务调用的完整过程。页面中的"服务确认"也据此命名,不把它当作播报结束。

先把 SDK 的两个接口跑通

先把实验的入口固定下来:输入是 TouchState 话题,输出是 PlayTts 服务请求,中间由一个 Python 节点决定是否放行。另一个场景节点既发布触摸,也提供可控制响应时间的服务;浏览器只读取观察快照。这里分成三个进程角色,页面不在任何一条触摸或语音决策路径上。

|-----------------|---------------------|------------------------------------|
| 位置 | 读取什么 | 写出什么 |
| scenario_lab.py | 服务请求中的 trace_id 和文本 | TouchState 消息、可控 PlayTts 响应、服务请求日志 |
| touch_app.py | 实际触摸回调与服务 future | 异步 PlayTts 请求、决策日志、只读快照 |
| 浏览器 index.html | /api/state 返回的快照 | 计数、交互阶段和最近记录,不生成触摸事件 |

我使用 Windows 上的 WSL 2 运行 Ubuntu 22.04.5,Python 为 3.10.12,ROS 2 为 Humble,消息包来自 T1 SDK v1.0.0.0。ROS 环境采用 ROS Base,通信实现为 rmw_fastrtps_cpp。这一条示例只依赖消息生成、rclpy 和 Python 标准库,不需要先安装完整桌面仿真套件。

ROS 2 的软件源和基础安装按Humble 官方说明处理。SDK 从启元开发资料页下载,解压后,SDK_DIR 要指向同时包含 aimdk_msgs 和 examples 的那一层。以下目录名可以替换成自己的位置,层级关系保持一致。

复制代码
source /opt/ros/humble/setup.bash
export SDK_DIR="$HOME/Downloads/sdk_t1-v1.0.0.0"
test -d "$SDK_DIR/aimdk_msgs"
test -d "$SDK_DIR/examples/python"

mkdir -p "$HOME/primebot_sdk_lab/ws/src"
cp -a "$SDK_DIR/aimdk_msgs" "$HOME/primebot_sdk_lab/ws/src/"
cd "$HOME/primebot_sdk_lab/ws"
colcon build --packages-select aimdk_msgs --event-handlers console_direct+
source install/setup.bash

python3 -c 'from aimdk_msgs.msg import TouchState, CommonState; from aimdk_msgs.srv import PlayTts; print("touch interfaces OK")'
ros2 interface show aimdk_msgs/msg/TouchState
ros2 interface show aimdk_msgs/srv/PlayTts

这一步必须走完消息生成。原始 .msg、.srv 是定义文件,复制进演示目录不能代替 colcon 构建;构建后还要加载工作区的 install/setup.bash,Python 才能找到生成的类及其类型支持库。后面每开一个终端,都要重新 source,不能因为另一个窗口已经能导入,就认为所有窗口都具备相同环境。

下面把实际生成的类结构打印出来。这样能同时确认"类能导入"和"字段层级符合预期",比直接开始写回调更容易定位版本差异。这里的字段清单来自这次已构建的消息包。

复制代码
from aimdk_msgs.msg import TouchState, CommonState
from aimdk_msgs.srv import PlayTts

print(TouchState.get_fields_and_field_types())
print(PlayTts.Request.get_fields_and_field_types())
print(PlayTts.Response.get_fields_and_field_types())
print(CommonState.SUCCESS, CommonState.FAILURE)

{'header': 'aimdk_msgs/MessageHeader', 'sensor_id': 'uint8', 'event_type': 'uint8'}
{'header': 'aimdk_msgs/CommonRequest', 'tts_req': 'aimdk_msgs/PlayTtsRequest'}
{'header': 'aimdk_msgs/CommonResponse', 'tts_resp': 'aimdk_msgs/PlayTtsResponse'}
1 2

TouchState 中的 header 负责消息身份,sensor_id 负责位置,event_type 负责动作类别;PlayTts 则把公共请求、公共响应与 TTS 业务字段分开。接下来写判断条件时,先确定自己处理的是哪一层信息,就不会把服务状态、触摸事件和界面状态混用。

先约定这次交互该做什么

T1 的外形很容易让人先想到动作编排,但触摸交互有自己的节奏:用户期待一次明确回应,程序却可能在很短时间内收到不止一条输入。这次没有把动作、相机和复杂对话一起塞进来,只保留两种问候,并把"何时允许下一次请求"写清楚。

编号要先查所用版本的源码。T1 SDK v1.0.0.0 的 TouchState 注释区分了机型:T 系列 sensor_id=1 对应背部,2 对应胸部;Q 系列中的 1 则表示头部。不能看到相同数字就套用同一位置。event_type=1、2 分别表示已经分类的单击、双击,应用收到的不是原始触点采样,不必再自己数两次单击来拼双击。

比如 event_type=9 在这版定义中表示向右滑动,并不是损坏的数据。程序这次只接单击与双击,所以把它归入"不支持事件";以后扩展滑动交互时,应增加相应业务映射。过滤只表达本应用选择处理什么,不能反过来说 SDK 只支持这两种事件。

|-----------------------|--------------------|
| 输入或状态 | 这份程序的处理 |
| 背部或胸部的单击 | 发送"你好,很高兴见到你。" |
| 背部或胸部的双击 | 发送"收到,我们继续互动吧。" |
| 同一条事件再次到达 | 记为 duplicate,不再次请求 |
| 同一传感器两条新事件间隔不足 180 ms | 记为 bounce,不再次请求 |
| 等待响应、预计时长保护或冷却期间 | 记录相应原因,丢弃本次输入,不排队 |
| 服务尚未就绪或事件不在支持范围内 | 保留记录,等待后续独立事件 |

这里选择的是"忙时丢弃"。如果把每一次触摸都排队,用户已经停手,机器人还有可能依次处理几条积压问候。另一种合理设计是只保留最后一次输入,但那需要明确队列、覆盖和取消规则。对于短问候,先让触发与响应一一可解释,比增加一个隐含队列更合适。

去重也没有只盯住 sequence。程序组合 frame_id、传感器编号、事件类型、序号及消息时间戳来识别同一事件,记录保留 3 秒,上限 128 条。发布节点重启、序号回绕或来源变化时,单看一个数字容易误判。这份缓存仍然只负责有限窗口内的重复抑制,超出窗口或容量以后,不提供永久幂等保证。

180 ms 的判断在去重之后执行,只比较同一传感器的新事件。它与"当前是否还有请求"分别解决两件事:前者抑制过密触发,后者限制本地活动请求数量。把它们混成一个时间开关,日志就很难解释究竟是重复、过快,还是上一轮尚未结束。

计时也分了两个来源。消息里的时间戳参与识别事件,等待期限、触发间隔和冷却窗口则使用 time.monotonic。应用不拿消息时间戳直接计算本地超时,避免把不同节点的时钟偏差混进等待逻辑。网页里从启动开始累计的秒数只是本轮观察坐标,不拿它推算用户真实触摸时刻。

先写入口判断,再安排回应

交互节点建立时,会同时创建服务客户端、触摸订阅与超时定时器。初始化里先打开日志,准备缓存和计数,再注册回调;这些成员都在同一份节点状态内,后面无需让浏览器参与协调。下面是实际程序的完整初始化方法,放在 TouchGreeter 类中。

复制代码
def __init__(self, args):
        super().__init__('t1_touch_greeter')
        self.lock = threading.RLock()
        self.started = time.monotonic()
        self.events, self.counts = deque(maxlen=48), Counter()
        self.seen = OrderedDict()
        self.last_touch = {}
        self.pending = None
        self.serial = 0
        self.guard_until = self.cooldown_until = 0.0
        self.args = args
        self.log = Path(args.output).open('w', encoding='utf-8', buffering=1)
        self.client = self.create_client(PlayTts, args.tts_service)
        qos = QoSProfile(depth=10, reliability=ReliabilityPolicy.RELIABLE)
        self.sub = self.create_subscription(TouchState, args.touch_topic, self.on_touch, qos)
        self.timer = self.create_timer(0.02, self.check_timeout)

seen 与 last_touch 刻意分开保存。seen 保存一条消息身份及它首次进入缓存的本地时刻;last_touch 保存某个传感器最近一次新事件的本地时刻。重复消息先被 seen 拦下,不会再刷新 last_touch;另一条身份不同但间隔太短的新消息,则会更新 last_touch,并被记为 bounce。连续过密的新事件因此会延续短间隔抑制。

本地状态的优先级也写成一个小函数:pending 存在时优先显示 waiting,随后才检查预计时长保护和冷却。三个时间窗口都结束,才是 ready。它只描述应用当前可以做什么;服务是否就绪另查 service_is_ready,不把服务发现混进这四个阶段。

复制代码
def phase(self, now):
        if self.pending:
            return 'waiting'
        if now < self.guard_until:
            return 'guard'
        if now < self.cooldown_until:
            return 'cooldown'
        return 'ready'

入口按同一个方向逐层返回:记录输入 → 检查位置与类型 → 去重 → 判断间隔 → 检查本地阶段 → 检查服务 → 创建请求。只要前面某层没有放行,后面的 call_async 就不会发生。这个顺序让日志中的第一个拒绝原因具备确定含义,也避免同一个输入被重复统计成多种拒绝。

|--------------------|-------------------------|
| 同一个输入可能同时满足的条件 | 按本例顺序记录什么 |
| 重复消息到来,旧请求也正在等待 | duplicate,先命中消息身份检查 |
| 新事件间隔过短,旧请求也正在等待 | bounce,先命中同传感器间隔检查 |
| 间隔足够,但预计时长保护尚未结束 | guard_drop,不再进入服务调用 |
| 本地 ready,但测试服务已经关闭 | unavailable,不创建 pending |

这些先后关系是应用策略的一部分。若业务要求"任何一次服务不可用都必须单独报警",应另设服务监控,而不是指望每条触摸事件都经过所有检查。完整 on_touch 方法放在后面的整文件中,包含这里讨论的每一个分支,没有在忙碌时偷偷排队。

把 PlayTts 的返回值读完整

先看接口,再决定页面上该显示什么。SDK 的请求把文字放在 tts_req.text,优先级、来源域和 trace_id 也在 tts_req 内;公共请求时间戳则位于 header.header.stamp。字段的两层 header 看起来有些绕,直接沿着 PlayTts.srv、CommonRequest 和 RequestHeader 逐层读,比凭示例名称猜结构稳妥。

本例将优先级设为 INTERACTION_L6,domain 设为 touch_greeting,is_interrupted 设为 false。每次调用另外分配一个本地递增 token,并把 token 与事件序号写进 trace_id。事件序号用于对照输入,本地 token 用于判断响应属于哪一次调用,两者承担的工作不同。

请求构造部分可以单独对着字段定义读。单击、双击只改变文本;domain 用于表示调用方来源;trace_id 带上本地调用编号与输入序号,便于把两份日志接起来。这里没有把 sensor_id 当优先级,也没有把触摸序号当服务的返回码。

复制代码
request = PlayTts.Request()
request.header.header.stamp = self.get_clock().now().to_msg()
request.tts_req.text = '你好,很高兴见到你。' if msg.event_type == 1 else '收到,我们继续互动吧。'
request.tts_req.priority_level.value = TtsPriorityLevel.INTERACTION_L6
request.tts_req.domain = 'touch_greeting'
request.tts_req.trace_id = f'touch-{token}-{msg.header.sequence}'
request.tts_req.is_interrupted = False
self.pending = {'token': token, 'trace': request.tts_req.trace_id,
                'sequence': msg.header.sequence, 'deadline': now + self.args.timeout}

pending 保存的是当前这次调用的 token、trace、事件序号和本地期限,正文中的服务请求日志也使用同一个 trace。后面即使源消息的 sequence 重新从 1 开始,本地递增 token 仍能区分这一进程里先后发出的调用。

成功判断同时检查三个位置:公共响应码为 0、业务状态为 CommonState.SUCCESS、tts_resp.is_success 为真。测试服务故意构造过"公共码是 0,但业务状态为失败"的返回。如果只判断调用有没有抛异常,或者只检查 code,这条失败就会被记成成功。

|-----------------------------|---------------------------------|
| 响应中的字段 | 程序如何使用 |
| header.header.code | 与 0 比较,检查公共响应码 |
| header.status.value | 与 CommonState.SUCCESS 比较,检查业务状态 |
| tts_resp.is_success | 检查 TTS 结果字段 |
| tts_resp.error_message | 服务拒绝时写入事件记录 |
| tts_resp.estimated_duration | 按毫秒换算为秒,用于本地预计时长保护 |

estimated_duration 的注释写的是"预计耗时",因此成功响应之后,程序进入预计时长保护,再保留 0.5 秒冷却。短问候场景中,预计时长保护最多取 5 秒;这是应用策略,不是 SDK 对所有文本长度的限制。长文本、连续对话或带播报状态通知的方案,应重新设计这部分节奏。

这份观察页把 waiting、guard、cooldown 和 ready 分开显示。等待的是服务响应;保护阶段参考的是返回的预计时间;冷却是应用另外设置的短间隔。三个阶段都可能暂时不接收新请求,但原因不同,不能统一写成"机器人正在说话"。

另外,程序把调用异常与业务拒绝分开记录。call_async 本身失败记为 request_error,读取 future 的结果失败记为 response_error,服务正常返回但字段不通过才是 rejected。它们都进入短暂恢复窗口,但排查入口不同:前两类要看调用和执行器,后一类先对照业务状态与错误说明。

预计时长只负责本地节奏

假设服务给出 900 ms,代码先除以 1000,得到 0.9 秒保护,再追加 0.5 秒冷却。如果返回 8000 ms,则本例会把保护裁到 5 秒;这些是按代码公式换算的例子,不是另外做出的播报时长测量。0.9 秒的等待超时与 0.9 秒的预计保护恰好同值,来源却不同,不能合并成同一个参数。

尤其要留意 guard 从成功响应到达后才开始计算。它没有已知的音频开始时间或播放结束事件,因此属于应用侧的保守触发规则。若需要在真实播报结束后立刻接收下一条触摸,应接入相应状态反馈,而不是把预计时间当成完成通知。

is_interrupted=false 表达这次请求的打断选项,本地只保留一个 pending 则是应用自己施加的限制。两者都不能代表整个系统永远只有一条语音任务;其他调用方仍有自己的请求。分析交互节奏时,要分清本地程序能保证的范围和服务端负责的调度。

最容易漏掉的,是超时以后的旧响应

假设请求 A 发出去,0.9 秒还没等到结果。程序记录超时,经过 0.35 秒恢复间隔,允许新的触摸发起请求 B。此时 A 的响应到达,如果回调不分归属,直接把 pending 清空,就会把 B 的等待状态一起抹掉。页面看起来只是闪了一下,内部却已经丢失了当前请求。

上图按本轮 JSONL 记录绘制,横轴以第一条请求为起点。事件序号 20 的测试服务延迟设为 1.8 秒,超过本地 0.9 秒期限;序号 21 随后发起。旧响应到达时,新请求仍处于等待阶段,两者确实发生了交错,而不是先后各跑一遍再拼成示意图。图中时刻用于还原这轮顺序,不作服务时延性能指标。

解决方式放在响应回调的入口:先比较回调带回的 token 与 pending 中的 token。没有当前请求,或者 token 不同,就只记录 late_reply 并返回;只有属于当前请求的回调,才能清除 pending、解析返回并推进保护阶段。这个检查要早于所有改状态的操作。

下面贴出完整响应处理方法。token 检查在最前面,确认归属后才取走 pending。业务拒绝进入 0.35 秒恢复窗口;成功则设置预计时长保护与冷却;future 取结果发生异常,会单独记录 response_error。三种结束方式都释放当前本地等待,但留下不同的诊断信息。

复制代码
def on_reply(self, token, future):
        with self.lock:
            now = time.monotonic()
            if not self.pending or self.pending['token'] != token:
                self.record('late_reply', token=token,
                            active_sequence=self.pending['sequence'] if self.pending else None)
                return
            job, self.pending = self.pending, None
            try:
                response = future.result()
                ok = (response.header.header.code == 0 and
                      response.header.status.value == CommonState.SUCCESS and
                      response.tts_resp.is_success)
                if not ok:
                    self.cooldown_until = now + 0.35
                    self.record('rejected', sequence=job['sequence'], trace=job['trace'],
                                code=response.header.header.code, state=response.header.status.value,
                                error=response.tts_resp.error_message)
                    return
                # The SDK returns an estimate, not an actual playback-completed event.
                duration = min(response.tts_resp.estimated_duration / 1000.0, 5.0)
                self.guard_until = now + duration
                self.cooldown_until = self.guard_until + 0.5
                self.record('accepted', sequence=job['sequence'], trace=job['trace'],
                            estimated_ms=response.tts_resp.estimated_duration, guard_s=duration)
            except Exception as error:
                self.cooldown_until = now + 0.35
                self.record('response_error', sequence=job['sequence'], error=str(error))

还有一个容易混淆的点:本地超时不等于远端撤销。服务可能已经接收请求并继续执行,所以本例没有把超时请求自动重发。自动补发一句问候看似方便,却可能得到两次响应。要做重试,需先确定服务端的取消或幂等机制,不能把客户端计时器当成远端执行状态。

超时检查只做本地状态转换:取走到期 pending、记下它的 sequence 与 trace,再设置恢复间隔。它没有调用服务端取消接口,也没有删除旧 future 的完成回调。正因为旧回调还可能被调用,前面的 token 判断才是必要步骤。

复制代码
def check_timeout(self):
        with self.lock:
            if self.pending and time.monotonic() >= self.pending['deadline']:
                job, self.pending = self.pending, None
                self.cooldown_until = time.monotonic() + 0.35
                self.record('timeout', sequence=job['sequence'], trace=job['trace'])

还有一种相反的情况:旧响应到达时,已经没有新 pending。这时也会记一条 late_reply,只是 active_sequence 为 null。程序既不重新创建旧任务,也不延长保护窗口。日志保留"旧结果确实到了",本地状态则继续按当前时刻计算。

下面这张运行画面正好截在旧响应到达之后:日志出现"忽略迟到响应",当前活动序号仍是 21。场景验证同时读取 pending.sequence 并断言它仍等于 21,随后再等待 21 自己的成功响应。这样既能从页面看过程,也能让程序检查状态没有被误改。

沿着同一轮日志把交错还原出来

把总数对上只是第一层检查。为了确认旧响应确实发生在新请求进行中,下面列出这轮记录中的相邻时刻;时间是应用启动后的本地累计秒数,不能拿它和另一台机器的墙钟时间直接相减。

|--------------|---------------------------------------|----------------------|
| 本地时刻 / s | 实际记录 | 此时可以检查什么 |
| 27.737 | request,seq=20,trace=touch-3-20 | 旧请求已发出,本地 token=3 |
| 28.643 | timeout,seq=20 | 0.9 秒期限已到,开始短暂恢复间隔 |
| 29.017 | request,seq=21,trace=touch-4-21 | 新请求已经进入本地等待 |
| 29.540 | late_reply,token=3,active_sequence=21 | 旧回调只记日志,未清除新 pending |
| 29.719 | accepted,seq=21 | 新请求等到自己的响应,再进入保护 |

从 27.737 到 28.643 相差约 0.906 秒,略大于 0.9 秒配置值;检查由定时器与执行器调度触发,不是硬实时中断。这里可以判断"该分支按预期触发",不能根据三位小数就宣称定时器误差有统一上界。后面的 29.540 与 29.719 则直接说明两次响应的先后关系。

业务拒绝是另一条独立路径。序号 10 的记录中,公共响应码 code=0,业务 state=2,错误说明为 SCENARIO_REJECTED;应用写出 rejected,而没有加到 accepted。下面保留的是实际 JSONL 中的两条原始事件,不用一行"调用成功"把差别抹掉。

复制代码
{"t": 27.377, "kind": "rejected", "sequence": 10, "trace": "touch-2-10", "code": 0, "state": 2, "error": "SCENARIO_REJECTED"}
{"t": 29.54, "kind": "late_reply", "token": 3, "active_sequence": 21}

完整程序:事件处理与只读观察页

前面已经核对过消息类型与关键分支,下面把它们放回完整程序。运行环境仍采用 Ubuntu 22.04、ROS 2 Humble、Python 3.10 和 T1 SDK v1.0.0.0;消息定义保持原样。源码仓库的环境说明与本文准备步骤对应,可以随代码一起保存。

创建同一目录下的三个文件:touch_app.py 负责应用和 HTTP 观察服务,index.html 负责显示,scenario_lab.py 负责独立场景。Python 程序使用标准库、rclpy 和已构建的 aimdk_msgs,没有另加网页框架。先贴应用文件,包含入口和关闭流程,可以直接保存运行。

复制代码
"""TouchState -> guarded asynchronous PlayTts requests, with a read-only observer."""
import argparse
from collections import Counter, OrderedDict, deque
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
from pathlib import Path
import threading
import time

import rclpy
from rclpy.node import Node
from rclpy.qos import QoSProfile, ReliabilityPolicy
from aimdk_msgs.msg import CommonState, TouchState, TtsPriorityLevel
from aimdk_msgs.srv import PlayTts


class TouchGreeter(Node):
    def __init__(self, args):
        super().__init__('t1_touch_greeter')
        self.lock = threading.RLock()
        self.started = time.monotonic()
        self.events, self.counts = deque(maxlen=48), Counter()
        self.seen = OrderedDict()
        self.last_touch = {}
        self.pending = None
        self.serial = 0
        self.guard_until = self.cooldown_until = 0.0
        self.args = args
        self.log = Path(args.output).open('w', encoding='utf-8', buffering=1)
        self.client = self.create_client(PlayTts, args.tts_service)
        qos = QoSProfile(depth=10, reliability=ReliabilityPolicy.RELIABLE)
        self.sub = self.create_subscription(TouchState, args.touch_topic, self.on_touch, qos)
        self.timer = self.create_timer(0.02, self.check_timeout)

    def record(self, kind, **fields):
        row = {'t': round(time.monotonic() - self.started, 3), 'kind': kind, **fields}
        self.events.append(row)
        self.counts[kind] += 1
        self.log.write(json.dumps(row, ensure_ascii=False) + '\n')
        self.get_logger().info(json.dumps(row, ensure_ascii=False))

    def phase(self, now):
        if self.pending:
            return 'waiting'
        if now < self.guard_until:
            return 'guard'
        if now < self.cooldown_until:
            return 'cooldown'
        return 'ready'

    def on_touch(self, msg):
        with self.lock:
            now = time.monotonic()
            fields = {'sequence': msg.header.sequence, 'sensor': msg.sensor_id,
                      'event': msg.event_type, 'case': msg.header.frame_id}
            self.record('touch', **fields)
            if msg.sensor_id not in (1, 2) or msg.event_type not in (1, 2):
                self.record('invalid', **fields)
                return
            key = (msg.header.frame_id, msg.sensor_id, msg.event_type,
                   msg.header.sequence, msg.header.stamp.sec, msg.header.stamp.nanosec)
            while self.seen and now - next(iter(self.seen.values())) > 3:
                self.seen.popitem(last=False)
            if key in self.seen:
                self.record('duplicate', **fields)
                return
            self.seen[key] = now
            if len(self.seen) > 128:
                self.seen.popitem(last=False)
            previous = self.last_touch.get(msg.sensor_id, -float('inf'))
            self.last_touch[msg.sensor_id] = now
            if now - previous < 0.18:
                self.record('bounce', **fields)
                return
            state = self.phase(now)
            if state != 'ready':
                self.record(state + '_drop', **fields)
                return
            if not self.client.service_is_ready():
                self.record('unavailable', **fields)
                return
            self.serial += 1
            token = self.serial
            request = PlayTts.Request()
            request.header.header.stamp = self.get_clock().now().to_msg()
            request.tts_req.text = '你好,很高兴见到你。' if msg.event_type == 1 else '收到,我们继续互动吧。'
            request.tts_req.priority_level.value = TtsPriorityLevel.INTERACTION_L6
            request.tts_req.domain = 'touch_greeting'
            request.tts_req.trace_id = f'touch-{token}-{msg.header.sequence}'
            request.tts_req.is_interrupted = False
            self.pending = {'token': token, 'trace': request.tts_req.trace_id,
                            'sequence': msg.header.sequence, 'deadline': now + self.args.timeout}
            try:
                future = self.client.call_async(request)
                self.record('request', trace=request.tts_req.trace_id, text=request.tts_req.text, **fields)
                future.add_done_callback(lambda result, active=token: self.on_reply(active, result))
            except Exception as error:
                self.pending = None
                self.cooldown_until = now + 0.35
                self.record('request_error', error=str(error), **fields)

    def on_reply(self, token, future):
        with self.lock:
            now = time.monotonic()
            if not self.pending or self.pending['token'] != token:
                self.record('late_reply', token=token,
                            active_sequence=self.pending['sequence'] if self.pending else None)
                return
            job, self.pending = self.pending, None
            try:
                response = future.result()
                ok = (response.header.header.code == 0 and
                      response.header.status.value == CommonState.SUCCESS and
                      response.tts_resp.is_success)
                if not ok:
                    self.cooldown_until = now + 0.35
                    self.record('rejected', sequence=job['sequence'], trace=job['trace'],
                                code=response.header.header.code, state=response.header.status.value,
                                error=response.tts_resp.error_message)
                    return
                # The SDK returns an estimate, not an actual playback-completed event.
                duration = min(response.tts_resp.estimated_duration / 1000.0, 5.0)
                self.guard_until = now + duration
                self.cooldown_until = self.guard_until + 0.5
                self.record('accepted', sequence=job['sequence'], trace=job['trace'],
                            estimated_ms=response.tts_resp.estimated_duration, guard_s=duration)
            except Exception as error:
                self.cooldown_until = now + 0.35
                self.record('response_error', sequence=job['sequence'], error=str(error))

    def check_timeout(self):
        with self.lock:
            if self.pending and time.monotonic() >= self.pending['deadline']:
                job, self.pending = self.pending, None
                self.cooldown_until = time.monotonic() + 0.35
                self.record('timeout', sequence=job['sequence'], trace=job['trace'])

    def snapshot(self):
        with self.lock:
            now = time.monotonic()
            return {'phase': self.phase(now), 'pending': dict(self.pending) if self.pending else None,
                    'elapsed': round(now - self.started, 2), 'counts': dict(self.counts),
                    'events': list(self.events), 'topic': self.args.touch_topic,
                    'service': self.args.tts_service, 'service_ready': self.client.service_is_ready()}


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument('--touch-topic', default='/aima/hal/touch/state')
    parser.add_argument('--tts-service', default='/aimdk_5Fmsgs/srv/PlayTts')
    parser.add_argument('--output', default='touch-events.jsonl')
    parser.add_argument('--port', type=int, default=8766)
    parser.add_argument('--timeout', type=float, default=0.9)
    args, ros_args = parser.parse_known_args()
    if args.timeout <= 0:
        parser.error('timeout must be positive')
    rclpy.init(args=ros_args)
    node = TouchGreeter(args)
    page = Path(__file__).with_name('index.html').read_bytes()

    class Handler(BaseHTTPRequestHandler):
        def do_GET(self):
            if self.path == '/api/state':
                payload, mime = json.dumps(node.snapshot()).encode(), 'application/json'
            elif self.path == '/':
                payload, mime = page, 'text/html; charset=utf-8'
            else:
                self.send_error(404)
                return
            self.send_response(200)
            self.send_header('Content-Type', mime)
            self.send_header('Cache-Control', 'no-store')
            self.send_header('Content-Length', str(len(payload)))
            self.end_headers()
            try:
                self.wfile.write(payload)
            except BrokenPipeError:
                pass

        def log_message(self, *_):
            pass

    server = ThreadingHTTPServer(('127.0.0.1', args.port), Handler)
    worker = threading.Thread(target=rclpy.spin, args=(node,), daemon=True)
    worker.start()
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        pass
    finally:
        server.server_close()
        if rclpy.ok():
            rclpy.shutdown()
        worker.join(timeout=2)
        node.log.close()
        node.destroy_node()


if __name__ == '__main__':
    main()

订阅回调中采用 call_async,不在回调里阻塞等待结果。20 ms 的定时器负责检查期限;网页在另一个线程读取快照。锁保护的是 pending、计数和事件记录,避免页面读取到一半更新的数据。实际触摸仍然从 ROS 2 订阅进入,网页没有模拟触摸按钮,也不负责生成结果。

记录容器与去重缓存也分开了:页面只保留最近 48 条事件,避免观察服务越跑越占内存;JSONL 则按发生顺序持续写出,方便对照完整过程。例程没有实现日志轮转,长时间运行时应给输出文件增加大小或日期轮转策略。页面裁掉旧行,不代表磁盘中的记录也被裁掉。

订阅 QoS 在这里明确选择 reliable、depth=10,场景发布器使用兼容配置。它与电池等传感器状态话题的配置不能想当然地通用。换一个发布端时,除了名称和类型,还要核对对方提供的 QoS;看得见节点并不意味着双方一定能够传消息。

入口、日志与退出分别放在哪里

main 使用 parse_known_args,把应用参数与 ROS 参数分开,再交给 rclpy.init。--touch-topic 与 --tts-service 决定节点的通信入口,--timeout 控制本地等待期限,--port 只影响观察网页。换网页端口不会自动改变场景程序使用的 8766 地址;要改变端口,需要同时调整场景脚本中的观察地址。

事件日志以行缓冲方式写出,每一条记录先更新内存计数,再写到文件。网页最多展示最近几条决策,节点内存保留最近 48 条事件;完整的实验顺序仍在 JSONL 中。三处保留数量不同,是显示窗口和存档策略不同,不能通过页面可见行数推算总事件数。

退出时,程序关闭 HTTP 服务,停止 rclpy,等待 ROS 线程收尾,再关日志、销毁节点。场景服务是另一个进程的资源,由场景程序自己关闭。这样只重启网页观察程序,不会误把场景节点一起结束;也提醒我们,重启某个进程并不等于取消另一个进程已经接收的调用。

页面只显示程序已经记录的事实

观察页每 100 ms 请求一次 /api/state,收到计数、状态和最近事件后更新界面。HTTP 服务只监听 127.0.0.1;它提供的是本地观察入口。失败计数包含拒绝、超时和调用异常,重复或忙碌输入单独保留原因,不混成服务失败。下面是完整 index.html。

复制代码
<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>T1 触摸交互记录</title>
<style>
*{box-sizing:border-box}body{margin:0;background:#eef1f5;color:#152536;font:15px/1.55 system-ui,"Microsoft YaHei",sans-serif}main{max-width:1240px;margin:auto;padding:24px 32px}.top{display:flex;align-items:center;justify-content:space-between}.tag{color:#596b80;font:12px/1.4 Consolas,monospace;letter-spacing:2px}h1{font-size:32px;margin:7px 0 4px}header p{margin:0;color:#607083}.lab{padding:7px 14px;border-radius:20px;background:#dce8fd;color:#265594;font-size:13px}.layout{display:grid;grid-template-columns:290px 1fr;gap:22px;margin-top:18px}.control{background:#162b40;color:#f3f7fb;border-radius:20px;padding:25px}.state{font-size:27px;font-weight:750;margin:12px 0 4px}.description{min-height:64px;color:#bdcada;font-size:14px}.lamp{display:inline-block;width:10px;height:10px;background:#63cdb3;border-radius:50%;margin-right:9px}.sensors{margin-top:22px;border-top:1px solid #385064;padding-top:16px}.sensor{padding:13px 0;border-bottom:1px solid #385064}.sensor strong{font-size:17px}.sensor small{display:block;color:#aabccf;margin-top:3px}.rules{color:#aabccf;font-size:12px;margin-top:20px}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}.stat{background:white;padding:15px 17px;border-radius:15px;border:1px solid #e1e6ed}.stat label{font-size:12px;color:#6e7b8a}.number{font-size:29px;font-weight:750;font-variant-numeric:tabular-nums}.trace{margin-top:18px;background:white;border-radius:18px;border:1px solid #e1e6ed;padding:19px 22px}.heading{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}h2{font-size:18px;margin:0}.time{font:12px Consolas,monospace;color:#6d7f92}.events{height:285px;overflow:hidden}.event{display:grid;grid-template-columns:67px 105px 1fr;gap:8px;align-items:center;padding:9px 0;border-top:1px solid #edf0f4}.stamp{font:12px Consolas,monospace;color:#7b8999}.pill{font-size:12px;color:#4c647c;background:#eef3f8;border-radius:6px;padding:3px 7px;text-align:center}.request,.accepted{color:#1c775b;background:#e8f5ee}.timeout,.rejected{color:#a35224;background:#fff0df}.late_reply{color:#8950a6;background:#f3eafa}.detail{font-size:13px;color:#354b61;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.notice{margin-top:17px;color:#69798c;font-size:12px}.statusline{margin-top:20px;padding-top:13px;border-top:1px solid #d5dde7;color:#65788d;font:12px/1.8 Consolas,monospace}
</style>
<main>
<header class="top"><div><div class="tag">PRIMEBOT T1 / TOUCH INTERACTION</div><h1>收到触摸,先判断再回应</h1><p>去重 · 请求保护 · 超时恢复</p></div><span class="lab">ROS 2 场景测试 · TTS 测试服务</span></header>
<div class="layout"><aside class="control"><div><span class="lamp"></span>交互程序状态</div><div class="state" id="phase">连接观察页</div><div class="description" id="desc">等待读取 ROS 2 程序的实际运行记录</div><div class="sensors"><div class="sensor"><strong>01 背部传感器</strong><small>sensor_id = 1</small></div><div class="sensor"><strong>02 胸部传感器</strong><small>sensor_id = 2</small></div></div><div class="rules">同一事件保留 3 秒去重记录<br>同一传感器触发间隔至少 180 ms<br>每次只等待一个服务响应</div></aside>
<div><div class="stats"><div class="stat"><label>收到触摸</label><div class="number" id="touch">0</div></div><div class="stat"><label>发出请求</label><div class="number" id="request">0</div></div><div class="stat"><label>服务确认</label><div class="number" id="accepted">0</div></div><div class="stat"><label>失败 / 超时</label><div class="number" id="errors">0</div></div></div><section class="trace"><div class="heading"><h2>事件经过了哪一步</h2><span class="time" id="clock">---</span></div><div class="events" id="events"></div></section><div class="notice">记录来自 TouchState 回调与 PlayTts 响应;服务确认和预计时长不代表实际播报已经完成。</div></div></div><footer class="statusline" id="path">等待本地观察服务...</footer>
</main>
<script>
const el=id=>document.getElementById(id);
const labels={touch:'收到事件',duplicate:'重复事件',bounce:'间隔过短',waiting_drop:'请求进行中',guard_drop:'时长保护',cooldown_drop:'冷却期间',invalid:'不支持事件',unavailable:'服务未就绪',request:'发出请求',accepted:'服务确认',rejected:'服务拒绝',timeout:'等待超时',late_reply:'忽略迟到响应',request_error:'发送异常',response_error:'响应异常'};
const states={ready:['可以接收','下一次有效触摸可以发起问候请求。'],waiting:['等待服务响应','请求正在进行,新触摸不会再追加请求。'],guard:['预计时长保护','根据服务返回的预计时长,暂缓再次触发。'],cooldown:['冷却间隔','保留短暂间隔,随后恢复接收。']};
async function update(){try{const s=await(await fetch('/api/state',{cache:'no-store'})).json();const state=!s.service_ready&&s.phase==='ready'?['等待服务','TTS 测试服务未就绪,触摸事件会记录为未就绪。']:states[s.phase];el('phase').textContent=state[0];el('desc').textContent=state[1];for(const k of ['touch','request','accepted'])el(k).textContent=s.counts[k]||0;el('errors').textContent=(s.counts.timeout||0)+(s.counts.rejected||0)+(s.counts.request_error||0)+(s.counts.response_error||0);el('clock').textContent=s.elapsed.toFixed(1)+' s';el('path').textContent=s.topic+'  →  '+s.service;const rows=s.events.filter(x=>x.kind!=='touch').slice(-7).reverse();el('events').replaceChildren(...rows.map(r=>{const row=document.createElement('div');row.className='event';const t=document.createElement('span');t.className='stamp';t.textContent=r.t.toFixed(3)+' s';const badge=document.createElement('span');badge.className='pill '+r.kind;badge.textContent=labels[r.kind]||r.kind;const d=document.createElement('span');d.className='detail';d.textContent=r.kind==='late_reply'?'旧请求 #'+r.token+';当前请求序号 '+(r.active_sequence??'无'):r.sequence!==undefined?'seq '+r.sequence+(r.trace?' · '+r.trace:'')+(r.error?' · '+r.error:''):r.error||'';row.append(t,badge,d);return row}));}catch(e){el('phase').textContent='观察服务已中断';el('desc').textContent='请检查本地观察服务与端口。'}}
update();setInterval(update,100);
</script>
</html>

浏览器刷新频率不参与事件判断。即使一次刷新跨过两个短暂状态,JSONL 中仍然保存回调记录;反过来,画面保持某个数值,也不证明又收到了新消息。因此复核数量时看报告与日志,观察交互节奏时看页面,这两个入口各有用途。

左侧状态还有一个显示优先级:本地 phase 已经是 ready,但 service_ready 为 false 时,页面写"等待服务"。这比单独显示"可以接收"更贴近实际入口条件。程序并没有通过这句文案创建连接;真正发起请求之前,on_touch 仍会再次检查服务,避免把旧快照当成即时保证。

HTTP 连接失败时,页面改成"观察服务已中断",不继续装作计数仍在更新。它与"测试服务未就绪"不是同一个故障:前者连本地快照都读不到,后者仍可读取节点状态,只是 ROS 服务当前不可用。对应的排查位置也因此不同。

让正常、拒绝和迟到响应都走一遍

为了让边界可重复出现,场景程序同时提供独立的触摸发布器和可控 TTS 服务。它按请求中的 trace_id 对应事件序号,分别延迟 0.4 秒、0.15 秒、1.8 秒和 0.7 秒;序号 10 另外返回业务失败。延迟是主动注入的场景条件,不是官方服务的性能数据。

服务采用可重入回调组与四线程执行器。旧请求 20 故意停留 1.8 秒时,新请求 21 仍能进入服务;如果把服务做成单线程串行阻塞,后一条请求只会排在前一条之后,就无法验证这次特意要观察的交错。应用程序仍然坚持每次只有一个本地 pending,远端未结束的旧调用通过 token 隔离。

场景的推进以观察状态和断言为主,短暂停顿只用于制造指定间隔。服务未就绪分支也真正销毁、重新创建测试服务,等待发现状态变化后再发送事件。下面的完整文件同时完成输入、响应与结果核对。

复制代码
"""Publish real TouchState messages and exercise a controlled PlayTts test server."""
import argparse
import json
from pathlib import Path
import threading
import time
from urllib.request import urlopen

import rclpy
from rclpy.callback_groups import ReentrantCallbackGroup
from rclpy.executors import MultiThreadedExecutor
from rclpy.node import Node
from rclpy.qos import QoSProfile, ReliabilityPolicy
from aimdk_msgs.msg import CommonState, TouchState
from aimdk_msgs.srv import PlayTts


def snapshot():
    with urlopen('http://127.0.0.1:8766/api/state', timeout=2) as response:
        return json.load(response)


def wait_for(predicate, label, limit=6):
    end = time.monotonic() + limit
    while time.monotonic() < end:
        state = snapshot()
        if predicate(state):
            return state
        time.sleep(0.03)
    raise AssertionError((label, snapshot()))


class ScenarioLab(Node):
    def __init__(self, output):
        super().__init__('t1_touch_scenario_lab')
        self.group = ReentrantCallbackGroup()
        self.server = self.create_server()
        self.pub = self.create_publisher(TouchState, '/primebot_lab/touch/state',
                                        QoSProfile(depth=10, reliability=ReliabilityPolicy.RELIABLE))
        self.lock = threading.Lock()
        self.requests = []
        self.log = Path(output).open('w', encoding='utf-8', buffering=1)

    def create_server(self):
        return self.create_service(PlayTts, '/primebot_lab/touch/play_tts',
                                   self.reply, callback_group=self.group)

    def reply(self, request, response):
        seq = int(request.tts_req.trace_id.rsplit('-', 1)[-1])
        delay = {1: 0.4, 10: 0.15, 20: 1.8, 21: 0.7}.get(seq, 0.25)
        row = {'sequence': seq, 'trace': request.tts_req.trace_id,
               'text': request.tts_req.text, 'delay_s': delay,
               'priority': request.tts_req.priority_level.value,
               'domain': request.tts_req.domain, 'interrupted': request.tts_req.is_interrupted}
        with self.lock:
            self.requests.append(row)
            self.log.write(json.dumps(row, ensure_ascii=False) + '\n')
        time.sleep(delay)
        response.header.header.stamp = self.get_clock().now().to_msg()
        response.header.header.code = 0
        response.header.status.value = CommonState.FAILURE if seq == 10 else CommonState.SUCCESS
        response.tts_resp.text = request.tts_req.text
        response.tts_resp.trace_id = request.tts_req.trace_id
        response.tts_resp.domain = request.tts_req.domain
        response.tts_resp.is_success = seq != 10
        response.tts_resp.error_message = 'SCENARIO_REJECTED' if seq == 10 else ''
        response.tts_resp.estimated_duration = 900
        return response

    def touch(self, seq, sensor=1, event=1, case='normal'):
        msg = TouchState()
        msg.header.stamp = self.get_clock().now().to_msg()
        msg.header.sequence = seq
        msg.header.frame_id = case
        msg.sensor_id, msg.event_type = sensor, event
        self.pub.publish(msg)
        return msg


def run(lab):
    wait_for(lambda s: s['service_ready'], 'service discovery')
    deadline = time.monotonic() + 5
    while lab.pub.get_subscription_count() < 1 and time.monotonic() < deadline:
        time.sleep(0.05)
    assert lab.pub.get_subscription_count() >= 1
    first = lab.touch(1)
    wait_for(lambda s: s['counts'].get('request') == 1, 'first request')
    lab.pub.publish(first)
    wait_for(lambda s: s['counts'].get('duplicate') == 1, 'duplicate suppression')
    lab.touch(2, case='short_gap')
    wait_for(lambda s: s['counts'].get('bounce') == 1, '180ms suppression')
    time.sleep(0.23)
    lab.touch(3, case='busy')
    wait_for(lambda s: s['counts'].get('waiting_drop') == 1, 'pending request suppression')
    wait_for(lambda s: s['phase'] == 'guard', 'estimated-duration guard')
    time.sleep(0.2)
    lab.touch(4, case='guard')
    wait_for(lambda s: s['counts'].get('guard_drop') == 1, 'guard suppression')
    wait_for(lambda s: s['phase'] == 'cooldown', 'cooldown entry')
    lab.touch(5, case='cooldown')
    wait_for(lambda s: s['counts'].get('cooldown_drop') == 1, 'cooldown suppression')
    wait_for(lambda s: s['phase'] == 'ready', 'normal recovery')
    lab.touch(10, case='reject')
    wait_for(lambda s: s['counts'].get('rejected') == 1, 'service rejection')
    wait_for(lambda s: s['phase'] == 'ready', 'rejection recovery')
    lab.touch(20, case='timeout')
    wait_for(lambda s: s['counts'].get('timeout') == 1, 'request timeout')
    wait_for(lambda s: s['phase'] == 'ready', 'timeout recovery')
    lab.touch(21, sensor=2, event=2, case='recovery')
    state = wait_for(lambda s: s['counts'].get('late_reply') == 1, 'late response')
    assert state['pending']['sequence'] == 21, 'Old response cleared the new request'
    wait_for(lambda s: s['counts'].get('accepted') == 2, 'new request accepted')
    wait_for(lambda s: s['phase'] == 'ready', 'new request recovery')
    lab.touch(25, event=9, case='unsupported')
    wait_for(lambda s: s['counts'].get('invalid') == 1, 'unsupported event')
    lab.destroy_service(lab.server)
    wait_for(lambda s: not s['service_ready'], 'service removal')
    lab.touch(26, case='unavailable')
    wait_for(lambda s: s['counts'].get('unavailable') == 1, 'unavailable service')
    lab.server = lab.create_server()
    wait_for(lambda s: s['service_ready'], 'service restored')
    time.sleep(0.2)
    lab.touch(30, sensor=2, case='restored')
    wait_for(lambda s: s['counts'].get('accepted') == 3, 'restored service accepted')
    final = wait_for(lambda s: s['phase'] == 'ready', 'final ready')
    expected = {'touch': 12, 'request': 5, 'accepted': 3, 'duplicate': 1, 'bounce': 1,
                'waiting_drop': 1, 'guard_drop': 1, 'cooldown_drop': 1, 'rejected': 1,
                'timeout': 1, 'late_reply': 1, 'invalid': 1, 'unavailable': 1}
    for key, value in expected.items():
        assert final['counts'].get(key) == value, (key, final['counts'])
    assert [row['sequence'] for row in lab.requests] == [1, 10, 20, 21, 30]
    assert all(row['priority'] == 6 and not row['interrupted'] for row in lab.requests)
    return {'passed': True, 'expectedCounts': expected, 'actualCounts': final['counts'],
            'serviceRequestSequences': [row['sequence'] for row in lab.requests],
            'lateReplyDidNotClearNewRequest': True, 'finalPhase': final['phase'],
            'source': 'TouchState publisher + PlayTts scenario server over ROS 2',
            'snapshot': final}


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument('--output', default='service-requests.jsonl')
    parser.add_argument('--report', default='verification.json')
    args = parser.parse_args()
    rclpy.init()
    lab = ScenarioLab(args.output)
    executor = MultiThreadedExecutor(num_threads=4)
    executor.add_node(lab)
    worker = threading.Thread(target=executor.spin, daemon=True)
    worker.start()
    try:
        report = run(lab)
        Path(args.report).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding='utf-8')
        print(json.dumps({k: v for k, v in report.items() if k != 'snapshot'}, ensure_ascii=False), flush=True)
    finally:
        executor.shutdown()
        worker.join(timeout=2)
        lab.log.close()
        lab.destroy_node()
        if rclpy.ok():
            rclpy.shutdown()


if __name__ == '__main__':
    main()

把十二个输入逐条对上

场景不只测试"能得到一次回应"。它先等待服务发现和订阅端匹配,再依次发送下列输入。第二行重新发布的是第一行那一份消息,时间戳、序号等身份完全相同;第三行才创建身份不同的新事件,因此能分别验证 duplicate 与 bounce。

|--------|---------------------|--------------------------------------|--------------|
| 次序 | 消息与条件 | 应出现的决策 | 是否追加服务请求 |
| 1 | seq=1,背部单击 | request,之后 accepted | 是 |
| 2 | 重新发布相同的 seq=1 消息 | duplicate | 否 |
| 3 | seq=2,同传感器短间隔新消息 | bounce | 否 |
| 4 | seq=3,首个响应尚未返回 | waiting_drop | 否 |
| 5 | seq=4,预计时长保护内 | guard_drop | 否 |
| 6 | seq=5,冷却内 | cooldown_drop | 否 |
| 7 | seq=10,服务返回业务失败 | request,之后 rejected | 是 |
| 8 | seq=20,服务故意延迟 1.8 秒 | request,之后 timeout | 是 |
| 9 | seq=21,恢复后的胸部双击 | request,旧回调 late_reply,随后本次 accepted | 是 |
| 10 | seq=25,event_type=9 | invalid | 否 |
| 11 | seq=26,服务销毁后发送 | unavailable | 否 |
| 12 | seq=30,服务重建后的胸部单击 | request,之后 accepted | 是 |

"忙碌"几行都使用新的消息身份,发送时机由前一个状态决定,避免被去重分支提前挡住。拒绝、超时之后,场景会等待 ready 再发下一条;迟到路径则刻意让旧服务调用继续执行,再确认新 pending 还存在。每一行都要真正走到目标分支,总数才有解释力。

最后并没有只断言 accepted 等于 3,还检查服务端收到的序号必须依次为 1、10、20、21、30,且优先级为 6、打断选项为 false。因为总请求数为 5,也可能是错误地放行了另一条触摸;核对顺序与字段,才能区分"数量碰巧一样"和"目标输入确实被处理"。

启动与复现

先获取源码。SDK 与模型从官方渠道下载,仓库中不重复打包它们。以下进入第二份示例目录;运行命令在 Ubuntu 终端中执行。

复制代码
git clone https://atomgit.com/aasd23/qiyuan.git
cd qiyuan/projects/02-t1-touch-interaction

source /opt/ros/humble/setup.bash
source "$HOME/primebot_sdk_lab/ws/install/setup.bash"
export RMW_IMPLEMENTATION=rmw_fastrtps_cpp
export ROS_DOMAIN_ID=74
export ROS_LOCALHOST_ONLY=1
mkdir -p results

python3 touch_app.py \
  --touch-topic /primebot_lab/touch/state \
  --tts-service /primebot_lab/touch/play_tts \
  --output results/app-events.jsonl

工作区位置按实际构建位置调整。打开 http://127.0.0.1:8766,然后在第二个终端加载相同的 ROS 2、SDK 环境和 domain 配置,在同一源码目录执行场景。

复制代码
source /opt/ros/humble/setup.bash
source "$HOME/primebot_sdk_lab/ws/install/setup.bash"
export RMW_IMPLEMENTATION=rmw_fastrtps_cpp
export ROS_DOMAIN_ID=74
export ROS_LOCALHOST_ONLY=1

python3 scenario_lab.py \
  --output results/service-requests.jsonl \
  --report results/verification.json

应用默认话题及服务名称参考所用 SDK 的 get_touch_state.py、volume_control.py;本轮启动参数将它们切到 /primebot_lab 下的专用名称,两个进程使用相同 ROS_DOMAIN_ID=74,并限制本机发现。这样,测试服务和事件来源都明确,不会把另一组实验的消息算进结果。

一轮结束后,scenario_lab.py 会关闭它创建的测试服务,网页可能从 ready 的交互状态显示为"等待服务",这是服务进程结束后的发现变化。重新验证前应先停止、重新启动 touch_app.py,让计数从零开始;否则第二轮会累加到旧计数,断言自然不成立。

如果一开始没有进入场景,先按层检查。import aimdk_msgs 失败,先回到构建和 source;页面打不开,检查 touch_app.py 是否退出及 8766 端口;页面能打开但一直等待服务,再看两个终端的 domain、服务名和场景进程。网页不是 DDS 发现工具,能访问本地 HTTP 只能证明观察服务启动了。

|-----------------------|-----------------------|
| 检查项 | 本轮结果 |
| 收到输入 / 发出服务请求 | 12 / 5 |
| 实际服务收到的事件序号 | 1、10、20、21、30 |
| 成功响应 / 业务拒绝 / 本地超时 | 3 / 1 / 1 |
| 重复 / 间隔过短 | 各 1 次,均未追加请求 |
| 等待中 / 预计时长保护 / 冷却期间输入 | 各 1 次,均未追加请求 |
| 不支持事件 / 服务未就绪 | 各 1 次,均保留对应记录 |
| 旧响应到达时的活动请求 | 仍为序号 21,随后收到 21 自身的响应 |

results/app-events.jsonl 记录应用的接收与决策,results/service-requests.jsonl 记录服务实际收到的调用;两边对照,才能区分"应用打算发"和"服务已经收到"。verification.json 再保存最终数量、请求序列和迟到响应断言。本轮全部通过,仓库 evidence 目录保留同一组记录,演示动图也取自这一轮运行。

例如本轮序号 10 在约 27.225 秒发出,27.377 秒记录 rejected;日志保留 code=0、state=2 和 SCENARIO_REJECTED。序号 20 则先出现 timeout,随后才出现 token=3 的 late_reply,后者还带着 active_sequence=21。这几条字段比单独打印一句"测试成功"更有用,能直接回到对应分支检查判断。

验证范围也有明确边界:这轮覆盖了既定输入序列、服务移除恢复和一次迟到交错,没有把所有传感器事件、任意消息速率或所有异常返回组合都穷举。要调整缓存长度、超时或保护时长,先修改场景条件,再核对服务实际请求列表;只看最后总数相同,可能漏掉错误地放行了某一个输入。

遇到空白或卡住,按哪一层去查

第一次启动时最容易把不同层的问题统称为"SDK 没通"。我更习惯先确定哪一步已经成立:Python 能导入类型,说明生成类与当前环境基本可用;节点和端点出现在 ROS 图里,说明发现已有结果;服务实际收到 trace,才说明这条调用到达;浏览器显示出来,则是更后面的一层。

|------------------------------------|--------------------------------------------------------|---------------------------|
| 现象 | 先检查 | 本例中对应的位置 |
| 提示找不到 aimdk_msgs 或类型支持 | 当前终端是否 source 正确 install/setup.bash,是否使用构建时的 Python 环境 | 环境与消息生成 |
| 看得见话题但没有触摸记录 | 实际发布端、消息类型、domain 与 QoS 是否匹配 | TouchState 订阅回调 |
| 收到触摸但记录为 waiting_drop 或 guard_drop | 上一条 request、accepted 与本地阶段是否对应 | 应用准入条件 |
| 记录为 unavailable | 测试服务进程是否存在,名称与 domain 是否一致 | 客户端服务发现 |
| code=0 仍记录 rejected | 业务状态与 is_success,不能只看公共码 | on_reply 的三项判断 |
| 页面打不开或观察中断 | 应用进程、HTTP 端口、本地地址与日志 | ThreadingHTTPServer 与网页读取 |
| 第二轮数量不对 | 是否重新启动应用清零,是否有同话题的其他发布器 | 实验隔离与进程生命周期 |

可以在同样加载过 ROS 环境的终端执行下面这组检查。topic info 的详细输出用于看实际端点与 QoS,service type 用于看发现到的接口类型;这些信息有助于缩小范围,但不能替代一次成功调用。只看服务名称出现在列表里,不足以判断当前请求已经被服务端处理。

复制代码
ros2 node list
ros2 topic info /primebot_lab/touch/state --verbose
ros2 service type /primebot_lab/touch/play_tts
ros2 interface show aimdk_msgs/srv/PlayTts

curl --noproxy '*' http://127.0.0.1:8766/api/state

如果 curl 能拿到快照,但浏览器没有更新,优先看网页读取和显示;如果快照中 counts.touch 始终不存在,先回到发布订阅,不必反复刷新浏览器。若 touch 在增加、request 不增加,就读每条对应的 duplicate、bounce 或各类 drop。这些字段已经说明应用停在哪个条件,先依据记录检查,比延长所有等待时间更有效。

这版应用的触摸订阅采用 reliable,场景发布器也提供 reliable。接入其他发布端时,若它只提供 best effort,这个订阅要求可能无法满足,表现为有端点却收不到数据。应根据实际发布端与业务要求调整兼容配置,不能靠把网页刷新间隔改小来补救;两个设置属于完全不同的层。

不要把长请求一律解释成网络丢失。这里序号 20 的 1.8 秒延迟是测试服务主动安排的,最终响应确实到达;先核对服务侧请求日志和应用的 timeout、late_reply,再决定是否需要检查发现、通信或服务内部处理。增加 timeout 可能让同一场景不再超时,却没有解决旧结果归属的问题。

一个小交互,先把节奏做明确

做到这里,触摸问候的难点已经不在"能不能调用一个服务",而在下一次输入到来时,上一轮到底处于什么阶段。事件身份、短间隔、活动请求、预计时长和恢复窗口,各自用独立条件表达;拒绝或超时以后,也能从记录中看见为什么恢复、恢复到了哪一步。

这套实现适合短问候入口。需要更复杂的交互时,可以在事件决策层增加不同部位的业务映射,在服务层补上真正的播放状态反馈,再决定是否保留最后一次输入。若要引入取消、重试或跨进程恢复,应先把请求身份和远端执行语义补齐,而不是简单延长一个定时器。

我更愿意把"正常响应"和"异常结束"一起当作交互的一部分。重复输入没有制造重复请求,服务拒绝没有被包装成成功,旧响应没有清除新任务------这些看起来不如一个动作演示显眼,却决定了用户连续摸几下之后,程序还能不能按自己的规则继续工作。

完整代码、运行步骤与本轮记录已收录在触摸交互示例目录;关节与 BMS 示例位于同一系列源码仓库。

接口依据:启元 T1 SDK v1.0.0.0 中的 TouchState、PlayTts、PlayTtsResponse、CommonState 定义及配套 Python 示例。开发资料见启元开发资料包。

开发者社区:启元机器人开发者社区 · 一站式具身智能研发平台

相关推荐
沫璃染墨18 小时前
《Qt从零入门系列(十五):Qt线程安全与锁机制详解——从QMutex到RAII与读写锁实战》
开发语言·c++·qt·安全·交互·线程·qt6.3
Ai-_Man1 天前
Microsoft Copilot对话数据怎么导入到另外一个Microsoft Copilot账号里去?AI导出鸭
开发语言·javascript·人工智能·microsoft·chatgpt·ecmascript·copilot
镜子AI2 天前
镜子AI工程教程:用Python实现关键词布局检查器与语义改写查重(附完整代码)
人工智能·python·microsoft
SuperHeroWu73 天前
ArkGenie 的技术原理:从一段 SSE 到一个可交互 Surface
开发语言·php·交互
lialaka3 天前
一块白布染出一朵花:端到端具身智能交互把一间扎染坊搬进屏幕
交互
鬼手点金3 天前
带工具调用的多轮交互:Agent 的核心循环
服务器·人工智能·windows·深度学习·microsoft·机器学习·交互
Navicat中国4 天前
Navicat 18 支持 Azure OpenAI 接入,合规 AI 赋能 SQL 开发
数据库·人工智能·microsoft·ai·azure·navicat·数据
张张123y4 天前
2026 Agent 框架怎么选?拆 3 层底层逻辑(运行循环 / 记忆 / 工具协议)+ 一张决策表
linux·ubuntu·microsoft·langgraph·mcp·crewai·agent 框架
霍格沃兹测试学院-小舟畅学4 天前
Playwright CLI:语义快照与局部检索,AI 如何找到正确的页面元素?
人工智能·microsoft