请求成功以后,动作完成了吗?从启元 T1 SDK 看状态切换流程

本文深入分析启元T1 SDK v1.0.0.0中McAction状态切换流程,重点揭示SetMcAction返回success后GetMcAction仍处于TRANSITION的原因。通过源码追踪、八组可控场景验证,厘清响应(response)、到达(arrival)、完成(completion)三层语义差异,指出辅助类_is_active仅校验非IDLE而非必须RUNNING,强调业务需在SDK外补充严格就绪门槛(目标名称+RUNNING状态)。同时剖析路径规划、自动桥接、超时机制与失败归因等关键设计。

一条动作切换指令发出去,终端很快出现 success。接下来能不能立刻发送依赖这个动作模式的业务指令?读启元 T1 SDK 之前,我会先去看返回码;沿着官方辅助类往下追,才发现还得再问两句:这个 success 是谁给出的,它检查到了哪一层?

本文先看一组实际运行结果:SetMcAction 的业务状态为 1,辅助类也返回 success,但新的 GetMcAction 查询仍然是 TRANSITION=200。目标名称已经匹配,状态却还在切换中。把这三个信息并排放出来,单独一句“切换成功”显然不够用了。

这次沿一条具体路径读源码:从 QUADRUPED_STAND_DEFAULT 切到 BIPED_CUSTOM_UPPER。先查询起点,再看客户端如何规划中间步骤,接着跟踪每次设置响应和状态轮询。正常路径之外,再安排非法直跳、目标不存在、受理后不再推进等场景,核对它究竟在哪一步退出。

分析依据是官方 T1 SDK v1.0.0.0 的接口定义、配套《接口说明.md》,以及 examples/python/common/mc_action_switcher.py。实验直接加载原样的官方辅助类,由独立 ROS 2 场景服务构造状态,经 DDS 实际交换请求与响应。文中的状态轨迹用于验证客户端判断,不把它当作机械运动、姿态稳定或轨迹执行完成的证据。

完整程序、当轮记录及运行说明收在动作切换示例目录。先看一遍动图,再回头读这条指令经过的几个判断,会比只盯着最终日志更容易对上。

一条指令,先对齐三份状态

动作切换涉及两种服务。SetMcAction 提交命令,GetMcAction 查询当前动作信息。它们不是同一个结构的写入和读取版本:设置结果藏在 response.response 下面,查询结果则把 header 和 info 直接放在响应对象上。把上一个接口的字段路径顺手复制过来,很容易一开始就读错层级。

位置

本文使用的字段

含义

SetMcAction 请求

header、source、command.action_desc

由谁请求,以及请求哪个动作命令

SetMcAction 响应

response.header.code、response.state.value、response.task_id

公共响应码、任务状态与任务标识

GetMcAction 响应

header.code、info.action_desc、info.status.value

查询是否正常、当前动作名称与动作状态

McActionInfo 的其他信息

current_action、source、switch_ready

动作枚举、来源和切换就绪信息;辅助类未全部纳入判断

源码阅读时,我把这三个词始终分开记录:响应、到达、完成。响应表示服务给出了结果;到达表示一次查询观察到预期动作模式及相应状态;完成还取决于应用究竟要等什么。如果业务要等的是一段上肢轨迹结束,动作模式进入 RUNNING 本身并不能替代那段轨迹的结束条件。

两个名字相近的枚举尤其容易混用。CommonState 属于设置响应里的任务状态,SUCCESS 为 1,RUNNING 为 400。McActionStatus 属于动作查询,IDLE 为 0、RUNNING 为 100、TRANSITION 为 200。看到 RUNNING 这个单词就直接通过,等于把两套不同的状态空间揉到了一起。

字段所属枚举

本篇涉及的值

在哪一步检查

CommonState

SUCCESS=1;RUNNING=400

检查 SetMcAction 是否被官方辅助类接受

McActionStatus

IDLE=0;RUNNING=100;TRANSITION=200

检查 GetMcAction 返回的动作模式状态

官方接口说明写得比较明确:设置请求成功后,底层切换可能还要持续一段时间,应通过 GetMcAction 等待状态从 TRANSITION 进入 RUNNING,再继续后续业务。这个约定是阅读客户端的参照。至于辅助类是否完整落实了这个条件,需要继续读实现,不能因为它叫 switch_to 就先替它补上结论。

还有一个小细节:current_action.value 为 0,不必然意味着动作空闲。官方说明允许出现尚未映射到枚举的内部动作,此时应结合 action_desc。实验服务故意保留枚举默认值 0,同时返回有效的名称和状态,官方辅助类仍能完成路径。原因很直接:它的内部 _ActionState 只留下了名称和状态,没有用这个枚举做路径判断。

从起点到目标,中间为什么多了两步

进入 switch_to 后,辅助类并不会马上发 SetMcAction。它先检查节点、目标字符串和取消条件,等待两个服务可用,再读一次当前动作。如果查询失败,结果进入 GET_ACTION_FAILED;如果连服务都没找到,则停在 SERVICE_UNAVAILABLE。排障时先看错误归属,比一开始怀疑动作规则更省事。

查询到的当前名称若已经等于目标,程序还会检查是否 active。符合条件就直接返回,不再发送设置请求;目标名称相同但状态为 IDLE,则返回 TARGET_NOT_ACTIVE。源码里的说明是不通过兜底迁移离开这个目标状态。也就是说,这条分支没有“先随便换个动作,再切回来”的自动补救。

起点和目标不相同,才轮到 _plan_path。它使用队列做广度优先搜索,遍历客户端保存的有向规则,以 previous 记录前驱,再倒着还原路径。这里的“最短”是按规划步骤数计算,不是按真实耗时、能耗或动作风险计算。同样长度的候选路径还会受到列表遍历顺序影响,不适合据此推导机械层面的最优控制。

从本版源码常量提取,_ACTION_RULES 有 35 个源节点键、130 条直接候选边,另外还有 6 组自动到达映射和 3 个全局候选目标。这些数字描述的是这份客户端代码。它的注释提到对应 action_ruler 规则,但仅凭公开客户端,不能把提取结果写成某个固件版本的完整状态机。

图里只展开本文使用的一条路径,实线表示一次 Set 请求,虚线表示客户端的自动到达映射。最容易看错的是中间那段:第二次发送 QUADRUPED_TO_BIPED,之后等待的却是 BIPED_LOCOMOTION_WBC。它们分别是 command 和 expected_action,不能因为名字不同就认定查询错了。

步骤

实际提交的 command

随后等待的 expected_action

1

QUADRUPED_LOCOMOTION_DEFAULT

QUADRUPED_LOCOMOTION_DEFAULT

2

QUADRUPED_TO_BIPED

BIPED_LOCOMOTION_WBC

3

BIPED_CUSTOM_UPPER

BIPED_CUSTOM_UPPER

自动映射来自 _AUTOMATIC_NEXT_ACTIONS。规划器知道某些命令会启动一个过程,最终报告另一个桥接动作,所以会把“发送什么”和“等待什么”分开保存。官方注释也提醒,不要在尚未观察到桥接状态时再次发送那个桥接动作。本轮服务日志中确实只有上述三次请求,没有额外发送一次 WBC。

规划器还会结合 _SKIP_ACTIONS 和两个桥接动作的特殊规则筛选候选。不能把原始邻接表中每个节点都简单当作可随时插入的中转站。DAMPING_DEFAULT、QUAD_LIE_DOWN 作为起点时还会先经过 PASSIVE_DEFAULT;全局目标被追加到候选列表,也是这份客户端的规划规则,不等于任意运行条件下服务端都必然允许切入。

为了区分“规划拦下”和“服务端拒绝”,实验还做了相反的一次调用:绕过辅助类,从起点直接请求最终目标。测试服务按提取的直接边规则检查,返回公共码 0、业务状态 -1001。通信正常,却没有合法直达边。对应地,给辅助类一个不存在的目标时,它在本地返回 NO_TRANSITION_PATH,服务日志里一次 Set 都没有收到。

这两种失败应该留下不同的处理入口。NO_TRANSITION_PATH 先核对目标拼写、当前状态和本地 SDK 规则;收到业务拒绝则继续核对服务端允许的条件及规则版本。把所有失败都改写成“网络异常,请重试”,既遮住了原因,也可能让同一条不合法请求不断重发。

读取规划代码还有一个顺序问题:candidate 本身就是最终目标时,程序先按这个目标建立迁移;只有它还不是目标,才进入自动到达映射等分支。因此,不能把所有出现自动映射键的地方一律替换成映射值。本文目标选择 BIPED_CUSTOM_UPPER,第二步需要经过自动桥接,恰好能把这两种信息同时显出来。

图也可以自己重新生成。示例中的 draw_graph.py 读取同一份官方文件,输出原始规则的 DOT 和 JSON,再绘制本文路径。原始规则图与本文展示图的用途不同:前者便于比对候选边是否随版本变化,后者只帮助追踪这一次指令。查看全图时,还必须回到规划器,结合全局候选、跳过集合及自动映射,才能解释实际得到的路径。

请求返回以后,辅助类究竟相信了什么

每一个规划步骤都会走 _request_action。它创建 SetMcAction.Request,填请求时间戳、source 和 command.action_desc,再调用 call_async,并在普通控制流程里驱动等待。这版代码通过动作描述字符串表达命令,没有额外把 command.action.value 填成另一个枚举;接口说明中的字符串回退机制为这种写法提供了依据。

设置响应的通过条件很窄:响应对象存在,response.response.header.code 等于 0,并且 response.response.state.value 等于 CommonState.SUCCESS。task_id 会随协议返回,但这个辅助类没有据此启动任务查询;它接下来的观察入口仍是 GetMcAction。看到响应里带任务标识,不能据此写成“客户端已经按任务追踪到了结束”。

response is not None
and response.response.header.code == 0
and response.response.state.value == CommonState.SUCCESS

为了检验这一点,第七组场景专门返回了 CommonState.RUNNING=400。它看起来也是一个“正在执行”的任务状态,但与 SUCCESS=1 不相等,原样辅助类因此返回 SET_ACTION_FAILED。这个实验只证明这版客户端怎样分类,不代表官方服务必然在普通设置成功时返回 400,更不能把测试服务设置的状态冒充固件实测。

合法请求受理后,场景服务先把动作状态设为 TRANSITION,并暂时保留旧名称;到预设推进时间,再报告新的名称和 RUNNING。这样可以清楚看到“响应已经回来、查询仍没到目标”的中间窗口。若服务一收到请求就把所有字段都改成最终结果,这一层等待逻辑很容易被正常用例掩盖。

下面取正常路径的服务端单调时钟记录。相邻行分别来自请求回调、状态推进和查询回调,不是根据理想流程手写的时间表。它们是本轮运行的观察值;场景推进配置为 0.28 秒,调度与轮询会让真正被观察到的时刻略有不同。

服务启动后的时间 / 秒

事件

关键内容

0.087

第一次请求及响应

请求四足移动;state=1;查询状态进入 TRANSITION

0.384

第一次推进

报告 QUADRUPED_LOCOMOTION_DEFAULT,status=100

0.603

第二次请求

请求 QUADRUPED_TO_BIPED

0.904

第二次推进

报告 BIPED_LOCOMOTION_WBC,status=100

1.133

第三次请求

请求 BIPED_CUSTOM_UPPER

1.424

第三次推进

报告目标名称,status=100

1.453

查询观察

辅助类读取到目标名称与 RUNNING

这里还有一个节流细节。minimum_set_action_interval 的默认值为 0.5 秒,代码在一次设置响应通过后,才计算下一次允许请求的时间。本轮三次请求的间隔分别约为 0.516 秒和 0.530 秒。这个参数用于限制请求节奏,不能把它等同于一次动作需要的时间,也不应为了让页面更快变绿就随意调小。

切换过程中每个规划步骤只提交一次 Set,之后重复的是 Get 查询。查询暂时没到目标时,辅助类没有自动重发上一个动作命令。这样的区分很有用:轮询属于观察,重发属于再次控制。接口超时或状态未就绪时,两者对系统产生的影响完全不同。

异步请求等待和状态轮询也不是一回事。等待 Set 的 future,是等这次调用的响应对象;进入 _wait_for_action 后,每一轮都会创建新的 Get 请求,是在不同时间重新观察状态。本轮第一次 Set 在服务时钟 0.087 秒已经给出响应,而直到 0.397 秒的查询才读到第一步到达。这段空隙正是业务流程不能直接跳过去的部分。

source 字段同样值得保留。它让服务记录知道请求来自哪个调用方,本例分别使用 action_probe、direct_probe 和 business_gate,便于区分整轮验证、直接调用与独立客户端。它不是权限校验,也不是让多个控制者自动互斥的锁。正式应用仍需要确定控制权的归属,避免另一个来源在两次查询之间改走了状态。

名称到了,为什么还可能停在 TRANSITION

接下来是这次源码分析最值得停下来的一处。_wait_for_action 要求查询对象存在、名称等于 expected_action,并且 _is_active 返回真。继续进入 _is_active,判断只有一行:状态不等于 IDLE。它没有把条件收紧为 RUNNING,也没有读取 switch_ready。

def _is_active(self, action: _ActionState) -> bool:
    return action.status != McActionStatus.IDLE

这与接口说明建议等待 RUNNING 的条件存在差别。TRANSITION 的值为 200,当然也不等于 IDLE 的 0。只要名称已经匹配,过渡状态也能满足这个 active 判断。前面那张“查询 200、辅助类 success”的截图,就是沿这条分支得到的结果,不是界面显示延迟造成的猜测。

对应场景从 WBC 出发,请求 BIPED_CUSTOM_UPPER。服务收到请求后立即更新名称,状态仍保持 TRANSITION,1 秒后才推进到 RUNNING。辅助类本轮约 0.060 秒返回成功,紧接着的独立查询读到名称匹配、status=200、switch_ready=false。三条证据相互对齐,说明成功发生在它真正执行的判断条件上。

为了保留这个差别,验证程序没有修改 SDK,也没有把 _is_active 替换成另一个函数。原始结果照常保存,随后由我们自己的外围门槛继续查询,直到“目标名称相同且状态为 RUNNING”才记为 ready。这样既能说明官方代码当前怎样工作,也能清楚指出应用层补充了哪一步。

last['action'] == target and last['status'] == McActionStatus.RUNNING

这个门槛只解决本篇定义的“目标动作模式已运行”。如果后续业务还依赖 switch_ready,就需要根据相应接口约定再加入它;本文记录了该字段,但没有把它的意义擅自扩展成所有动作的完成条件。需要等待具体运动任务结束时,也应寻找那个任务对应的反馈,而不是继续堆叠动作模式字符串。

另一个边界是初始查询已经处在目标名称。状态为 IDLE 时,官方辅助类明确拒绝;状态为 RUNNING 时,不发 Set 直接返回;由于使用同一个 active 条件,TRANSITION 也可能被直接接受。不能只在发生过设置请求的路径上加检查,而忘了“已经在目标”这个捷径分支。本文独立客户端把严格门槛统一放在辅助类成功返回之后。

这也解释了为什么日志最好同时保存 helper_success 与 running_gate。前者忠实反映官方辅助类返回,后者反映当前应用的放行条件。如果只用一个名为 success 的布尔值覆盖两者,过几天再看故障记录,就很难知道当时到底是 SDK 通过了,还是我们自己的二次查询也通过了。

这里的结论也要限定在证据范围内。提前更新名称是场景服务特意构造的输入,用来检验客户端条件;实验没有证明某个固件一定按这种次序更新字段。能够确定的是:在名称相同、状态为 TRANSITION 的合法响应结构下,这版辅助类会通过当前条件。至于某次实际控制是否遇到这个窗口,应查看那一次服务查询记录。

严格门槛给出的是一次查询时刻的观察结果,它也不会把之后的状态固定住。若后续任务启动前存在其他控制者,仍可能发生状态再次改变。本文保持单一写入来源,是为了让每条轨迹有明确原因;将这个示例接入更大的系统时,需要由业务调度器维护控制顺序,而不能把一次查询当成永久有效的授权。

时间到了退出,但“总超时”有边界

等待动作到达用到了 total_timeout。这个名称容易让人理解成从函数入口开始的硬期限,可源码把 deadline 放在服务发现、初次查询和路径规划之后。因此,20 秒默认值并不包含前面的所有准备时间。官方命令行入口又把它设置为 30 秒,辅助类默认和入口实际传参要分别说明。

选项

辅助类默认值

实际作用位置

service_wait_timeout

5.0 秒

两个服务的发现等待;本版 GetMcAction 的单次等待也使用它

service_call_timeout

3.0 秒

SetMcAction 响应等待

poll_interval

0.5 秒

查询之间及部分查询重试之间的间隔

minimum_set_action_interval

0.5 秒

设置响应通过后,约束下一次设置请求的时间

total_timeout

20.0 秒

准备阶段之后的整段路径执行期限;官方入口传入 30 秒

再往下看,Get 查询内部最多尝试三次,响应未完成或抛出异常时可能继续尝试;已经拿到有效响应但公共码异常,则直接返回失败。它等待 future 时用的是 service_wait_timeout,并没有按动作 deadline 的剩余时间裁剪。外层虽然检查了 deadline,内部调用仍可能越过它,所以不能承诺整个函数在恰好某一毫秒必然返回。

查询失败发生的位置也会影响最后的错误名称。最初读取当前动作失败,switch_to 可以直接返回 GET_ACTION_FAILED;已经进入等待到达的循环后,某一轮查询没有读到状态,通常还会继续等,最后可能表现为 TIMEOUT。看到超时不能立即断言“底层一直处于过渡状态”,也可能是有效查询没有持续到达。本篇不推进场景的服务记录确实反复返回旧名称和 200,所以才有依据把原因限定为状态没有推进。

排查时可以按三步还原:先确认服务有没有收到 Set,再看它给出的公共码和任务状态,最后检查期限内每次 Get 的名称与动作状态。只有最后一条查询而没有前面的记录,无法判断请求是被拒绝、查询出现间断,还是迁移确实停住。本文同时保留客户端与服务端记录,就是为了让这几个解释能相互区分。

实验把发现与单次调用等待都设为 0.4 秒,轮询间隔为 0.1 秒。普通场景的路径期限为 5 秒,“受理但不推进”场景则为 0.8 秒。后者只回设置成功,之后一直报告旧名称和 TRANSITION,本轮在约 0.802 秒得到 TIMEOUT。这个数证明了此场景的退出行为,不应写成 SDK 的通用延迟指标。

超时退出也不是取消动作。这个辅助类结束的是本地等待流程;本文的外围查询门槛在单次响应超时后移除本地 pending future,同样不能借此撤销服务端执行。因此没有自动重发、自动切回默认状态或断言机器人已经停止。应用若要恢复,应重新查询当前状态,再按明确的控制策略处理。

取消条件 should_cancel 也应按源码中的检查点理解,它会在若干阶段被查询,而不是给所有阻塞等待都装上即时中断。把一个同步切换过程直接塞进同一节点的订阅回调,还可能让执行器的回调调度变复杂。本篇从普通控制流程调用它;需要在长期运行节点中使用时,应重新安排执行器、回调组及任务生命周期。

我还用原样的 examples/python/set_mc_action.py 做了两次独立核对:一次沿合法路径,一次指定不存在的目标。前者打印 Switch succeeded,后者打印 Switch failed,两次进程退出码却都是 0。沿 execute 的 action 分支看,失败提示之后仍可能返回真,main 再据此选择退出码。脚本执行结束,并不能代替读取辅助类结果和查询状态。

另一个容易漏掉的地方是结果中的 current_action。辅助类在初次查询后填一次,再在每个已完成的规划步骤后更新。如果等待下一步超时,这个字段更接近“上一次确认到达的动作”,并不持续反映超时那一刻所有变化。因此本例在辅助类返回后再做独立查询,并把 observed_on_return 单独保存,没有拿结果对象里的旧字段代替现场状态。

正常目标:日志含 Switch succeeded,退出码 0
不存在的目标:日志含 Switch failed,退出码仍为 0
本文 switch_client.py:严格门槛通过才返回 0,其余返回 2

把八个分支放到同一轮里核对

独立场景服务只实现这篇实验需要的协议行为。直接迁移校验来自读取到的客户端规则,返回字段使用官方消息类型;它不复制厂商控制器内部实现,也不包含机械模型。每组使用独立服务名称,运行结束只终止自己创建的场景进程,避免上一组状态混进下一组。

场景

Set 次数

本轮结果

核对重点

合法多步路径

3

success;最终查询 100

命令与自动到达状态分别匹配

绕过规划直接跳转

1

code=0,state=-1001

响应正常不代表业务允许直达

不存在的目标

0

no_transition_path

本地规划失败,没有写入

受理后保持旧名称

1

timeout;查询 200

响应成功之后仍要观察

目标名称相同,状态 IDLE

0

target_not_active

不通过兜底迁移绕开检查

目标名称相同,仍为 TRANSITION

1

辅助类 success;返回时 200

外围门槛继续等到 100

设置任务状态为 RUNNING=400

1

set_action_failed

区分任务状态与动作状态

初始已是目标且 RUNNING

0

success;查询 100

不重复发送相同设置

八组断言全部通过,服务日志合计记录 7 次 Set 请求、47 次 Get 查询。这里的“通过”包括正确识别预设失败:超时就应当得到 timeout,非法直跳就应当被拒绝。它不表示八条控制命令都成功,更不是八段真实机械动作的完成率。

这 47 次查询包含辅助类自身轮询、返回后的观察查询,以及提前改名场景的严格门槛查询,不应全部算成官方辅助类的请求量。服务日志按真实到达的请求计数,报告按实验角色保存结果,两者回答的问题不同。页面每 100 毫秒刷新一次读取的是观察器 HTTP 状态,也不会因此额外发送一轮 GetMcAction。

记录分成两个时间来源。每份服务 JSONL 的 t 从该服务启动起计算,适合核对本组请求与状态推进;trace.jsonl 的 t 从观察器启动起计算,适合串起整轮流程。观察器转发服务事件时另外保留 server_t,避免把两个起点不同的时间值直接相减。网页显示后者的整轮轨迹,前面的细节表则使用原服务记录。

为了让结果可以逐项检查,verification.json 保存了规划步骤、实际请求列表、辅助类结果、返回后的独立查询,以及外围门槛结果。程序还核对实验前后官方辅助文件保持不变。这里使用私有方法 _plan_path 只是读取本版源码的规划结果,不把它包装为稳定对外接口;真正的切换仍调用公开的 switch_to。

完整程序:官方逻辑原样加载,业务条件放在外面

下面给出五个完整文件。sdk_source.py 负责定位外部 SDK,用 AST 读取规则常量,再按原样加载辅助类;action_server.py 负责产生可控协议状态;switch_client.py 是可以单独调用的业务客户端;run_lab.py 跑前面八个场景;index.html 只读展示实际事件。把它们放在同一个目录即可,不需要把官方 SDK 源码复制进项目。

加载器在执行模块前先将其加入 sys.modules,这是为了让官方文件里的 dataclass 正常解析所属模块。读取规则时只对指定常量做 literal_eval,没有用 eval 执行任意字符串。若后续 SDK 改变常量布局,程序会明确退出,提醒重新核对源码,而不是悄悄使用一份过期的硬编码图。

"""Load the external official T1 switcher unchanged; read graph constants from its AST."""
import ast
import hashlib
import importlib.util
from pathlib import Path
import sys


def source_file(sdk_dir):
    file = Path(sdk_dir).expanduser().resolve() / 'examples/python/common/mc_action_switcher.py'
    if not file.is_file():
        raise FileNotFoundError('SDK must contain examples/python/common/mc_action_switcher.py')
    return file


def read_graph(sdk_dir):
    tree = ast.parse(source_file(sdk_dir).read_text(encoding='utf-8'))
    names = {'_ACTION_RULES', '_SKIP_ACTIONS', '_AUTOMATIC_NEXT_ACTIONS', '_GLOBAL_DESTINATIONS'}
    values = {}
    for node in tree.body:
        target = node.target if isinstance(node, ast.AnnAssign) else node.targets[0] if isinstance(node, ast.Assign) else None
        if isinstance(target, ast.Name) and target.id in names:
            values[target.id] = ast.literal_eval(node.value)
    if set(values) != names:
        raise ValueError('The SDK graph layout differs from the version used by this example')
    return values


def load_switcher(sdk_dir):
    file = source_file(sdk_dir)
    name = 'primebot_official_action_switcher'
    if name not in sys.modules:
        spec = importlib.util.spec_from_file_location(name, file)
        module = importlib.util.module_from_spec(spec)
        sys.modules[name] = module
        spec.loader.exec_module(module)
    return sys.modules[name]


def source_digest(sdk_dir):
    return hashlib.sha256(source_file(sdk_dir).read_bytes()).hexdigest()

场景服务同时提供设置与查询,使用可重入回调组和多线程执行器,并用锁保护状态。推进由单调时钟和定时器控制,查询也会补做一次到期推进,避免把界面刷新周期误当作状态变化周期。transition_name 模式提前改变名称,stuck 模式不安排后续推进,正好分别触发本文讨论的两个关键分支。

"""Controlled SetMcAction/GetMcAction service; this models protocol states, not mechanics."""
import argparse
import json
from pathlib import Path
import threading
import time

import rclpy
from rclpy.callback_groups import ReentrantCallbackGroup
from rclpy.executors import MultiThreadedExecutor
from rclpy.node import Node
from aimdk_msgs.msg import CommonState, McActionStatus
from aimdk_msgs.srv import GetMcAction, SetMcAction
from sdk_source import read_graph


class ActionScenario(Node):
    def __init__(self, args):
        super().__init__('t1_action_scenario')
        graph = read_graph(args.sdk_dir)
        self.rules, self.automatic = graph['_ACTION_RULES'], graph['_AUTOMATIC_NEXT_ACTIONS']
        self.global_destinations = graph['_GLOBAL_DESTINATIONS']
        self.args, self.action = args, args.initial
        self.status = McActionStatus.IDLE if args.mode == 'idle' else McActionStatus.RUNNING
        self.lock = threading.RLock()
        self.pending, self.task_id = None, 0
        self.started = time.monotonic()
        self.log = Path(args.output).open('w', encoding='utf-8', buffering=1)
        group = ReentrantCallbackGroup()
        self.setter = self.create_service(SetMcAction, args.namespace + '/SetMcAction', self.set_action, callback_group=group)
        self.getter = self.create_service(GetMcAction, args.namespace + '/GetMcAction', self.get_action, callback_group=group)
        self.timer = self.create_timer(0.02, self.advance, callback_group=group)
        self.record('server_ready', action=self.action, status=self.status, mode=args.mode)

    def record(self, event, **data):
        with self.lock:
            self.log.write(json.dumps({'t': round(time.monotonic()-self.started, 3), 'event': event, **data})+'\n')

    def advance(self):
        with self.lock:
            if self.pending and time.monotonic() >= self.pending['deadline']:
                self.action = self.pending['arrival']
                self.status = McActionStatus.RUNNING
                self.pending = None
                self.record('active', action=self.action, status=self.status)

    def get_action(self, request, response):
        self.advance()
        with self.lock:
            response.header.stamp = self.get_clock().now().to_msg()
            response.header.code = 0
            response.info.action_desc = self.action
            response.info.status.value = self.status
            response.info.source = 'scenario_service'
            response.info.switch_ready = self.status == McActionStatus.RUNNING
            self.record('query', action=self.action, status=self.status,
                        enum_value=response.info.current_action.value, switch_ready=response.info.switch_ready)
        return response

    def set_action(self, request, response):
        with self.lock:
            command = request.command.action_desc
            self.record('request', action=self.action, command=command, source=request.source)
            response.response.header.stamp = self.get_clock().now().to_msg()
            response.response.header.code = 0
            self.task_id += 1
            response.response.task_id = self.task_id
            if command not in self.rules.get(self.action, []) and command not in self.global_destinations:
                # -1001 is the business-state value described in the SDK interface guide.
                response.response.state.value = -1001
                self.record('rejected', command=command, code=0, state=-1001)
                return response
            if self.args.mode == 'task_running':
                response.response.state.value = CommonState.RUNNING
                self.record('ack', command=command, code=0, state=CommonState.RUNNING)
                return response
            response.response.state.value = CommonState.SUCCESS
            arrival = self.automatic.get(command, command)
            if self.args.mode == 'transition_name':
                self.action = arrival
            self.status = McActionStatus.TRANSITION
            if self.args.mode != 'stuck':
                self.pending = {'arrival': arrival, 'deadline': time.monotonic() + self.args.duration}
            self.record('ack', command=command, expected=arrival, code=0, state=1,
                        action=self.action, status=self.status)
        return response


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument('--sdk-dir', required=True)
    parser.add_argument('--namespace', required=True)
    parser.add_argument('--initial', required=True)
    parser.add_argument('--mode', choices=['normal', 'stuck', 'idle', 'transition_name', 'task_running'], default='normal')
    parser.add_argument('--duration', type=float, default=0.28)
    parser.add_argument('--output', required=True)
    args = parser.parse_args()
    rclpy.init()
    node = ActionScenario(args)
    executor = MultiThreadedExecutor(num_threads=3)
    executor.add_node(node)
    try:
        executor.spin()
    except KeyboardInterrupt:
        pass
    finally:
        executor.shutdown(timeout_sec=1)
        node.log.close()
        node.destroy_node()
        if rclpy.ok():
            rclpy.shutdown()


if __name__ == '__main__':
    main()

独立客户端只在官方辅助类返回成功后运行严格门槛。查询超时移除的是本地 pending 请求;最后一个成功查询的名称、状态和 switch_ready 会留在输出中。即使后面需要更严格的就绪策略,也可以在这一层清楚地增加,而不必先改掉官方辅助类,失去对照基础。

"""Business gate around the external official helper: require target + RUNNING."""
import argparse
import json
from pathlib import Path
import time

import rclpy
from rclpy.node import Node
from aimdk_msgs.msg import McActionStatus
from aimdk_msgs.srv import GetMcAction
from sdk_source import load_switcher


def wait_running(node, client, target, timeout, call_timeout):
    deadline = time.monotonic() + timeout
    last = None
    while rclpy.ok() and time.monotonic() < deadline:
        request = GetMcAction.Request()
        request.request.header.stamp = node.get_clock().now().to_msg()
        future = client.call_async(request)
        rclpy.spin_until_future_complete(node, future,
            timeout_sec=min(call_timeout, max(0.0, deadline-time.monotonic())))
        if not future.done():
            client.remove_pending_request(future)
            future.cancel()
        else:
            try:
                response = future.result()
            except Exception:
                response = None
            if response is not None and response.header.code == 0:
                last = {'action': response.info.action_desc, 'status': response.info.status.value,
                        'switch_ready': response.info.switch_ready}
                if last['action'] == target and last['status'] == McActionStatus.RUNNING:
                    return {'ready': True, 'last': last}
        time.sleep(min(0.1, max(0.0, deadline-time.monotonic())))
    return {'ready': False, 'last': last}


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument('--sdk-dir', type=Path, required=True)
    parser.add_argument('--target', required=True)
    parser.add_argument('--namespace', required=True)
    parser.add_argument('--switch-timeout', type=float, default=5.0)
    parser.add_argument('--guard-timeout', type=float, default=2.0)
    args = parser.parse_args()
    if args.switch_timeout <= 0 or args.guard_timeout <= 0:
        parser.error('Timeouts must be positive')
    sdk = load_switcher(args.sdk_dir)
    rclpy.init()
    node = Node('t1_switch_business_gate')
    try:
        base = args.namespace.rstrip('/')
        switcher = sdk.McActionSwitcher(node, base+'/SetMcAction', base+'/GetMcAction')
        client = node.create_client(GetMcAction, base+'/GetMcAction')
        options = sdk.McActionSwitchOptions(source='business_gate', service_wait_timeout=0.4,
            service_call_timeout=0.4, poll_interval=0.1, minimum_set_action_interval=0.5,
            total_timeout=args.switch_timeout)
        result = switcher.switch_to(args.target, options)
        guard = wait_running(node, client, args.target, args.guard_timeout, 0.4) if result.success else None
        ready = result.success and guard is not None and guard['ready']
        print(json.dumps({'helper_success': result.success, 'helper_error': result.error.value,
            'helper_message': result.message, 'running_gate': guard, 'ready': ready}, ensure_ascii=False))
        return 0 if ready else 2
    finally:
        node.destroy_node()
        if rclpy.ok():rclpy.shutdown()


if __name__ == '__main__':raise SystemExit(main())

八组运行器还会从服务端日志检查请求数量,防止客户端打印了预期文字却实际上重复下发。发生断言失败时,报告保留错误并让网页显示异常;它不会把失败改写成绿色结果。运行结束后观察网页继续提供结果,便于检查,再由 Ctrl+C 结束。

"""Run the unmodified official action switcher against independent ROS 2 scenarios."""
import argparse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import os
from pathlib import Path
import subprocess
import sys
import threading
import time

import rclpy
from rclpy.node import Node
from aimdk_msgs.msg import CommonState, McActionStatus
from aimdk_msgs.srv import GetMcAction, SetMcAction
from sdk_source import load_switcher, read_graph, source_digest

STAND = 'QUADRUPED_STAND_DEFAULT'
BASE = 'QUADRUPED_LOCOMOTION_DEFAULT'
WBC = 'BIPED_LOCOMOTION_WBC'
CUSTOM = 'BIPED_CUSTOM_UPPER'
CASES = [
    ('route', '沿合法路径切换', STAND, CUSTOM, 'normal'),
    ('direct', '绕过规划直接跳转', STAND, CUSTOM, 'normal'),
    ('no_path', '不存在的目标', STAND, 'NOT_A_T1_ACTION', 'normal'),
    ('stuck', '已受理但未到达', WBC, CUSTOM, 'stuck'),
    ('idle', '目标名称相同但 IDLE', CUSTOM, CUSTOM, 'idle'),
    ('transition', '名称相同仍在 TRANSITION', WBC, CUSTOM, 'transition_name'),
    ('task_running', '任务响应返回 RUNNING=400', WBC, CUSTOM, 'task_running'),
    ('already', '目标已处于 RUNNING', CUSTOM, CUSTOM, 'normal'),
]


class Observer:
    def __init__(self, output):
        self.lock, self.started = threading.RLock(), time.monotonic()
        self.current, self.events, self.rows = None, [], []
        self.finished, self.passed, self.error = False, False, None
        self.log = (output/'trace.jsonl').open('w', encoding='utf-8', buffering=1)

    def emit(self, event, **values):
        with self.lock:
            row = {'t': round(time.monotonic()-self.started, 3), 'case': self.current, 'event': event, **values}
            self.events.append(row)
            self.log.write(json.dumps(row, ensure_ascii=False)+'\n')

    def snapshot(self):
        with self.lock:
            return {'current': self.current, 'events': self.events[-80:], 'rows': list(self.rows),
                    'finished': self.finished, 'passed': self.passed, 'error': self.error, 'total': len(CASES)}


def query(node, client, timeout=0.4):
    request = GetMcAction.Request()
    request.request.header.stamp = node.get_clock().now().to_msg()
    future = client.call_async(request)
    rclpy.spin_until_future_complete(node, future, timeout_sec=timeout)
    if not future.done() or future.result() is None:
        raise RuntimeError('Observer query did not return')
    response = future.result()
    if response.header.code != 0:
        raise RuntimeError('Observer query returned a nonzero code')
    return {'action': response.info.action_desc, 'status': response.info.status.value,
            'enum_value': response.info.current_action.value, 'switch_ready': response.info.switch_ready}


def running_guard(node, client, target, observer, timeout=2.0):
    deadline = time.monotonic()+timeout
    while time.monotonic() < deadline:
        state = query(node, client)
        observer.emit('guard_query', **state)
        if state['action'] == target and state['status'] == McActionStatus.RUNNING:
            return {'ready': True, **state}
        time.sleep(0.1)
    return {'ready': False, **state}


def tail_server(path, observer, stop):
    consumed = 0
    while not stop.is_set():
        if path.exists():
            lines = path.read_text(encoding='utf-8').splitlines()
            for line in lines[consumed:]:
                try:
                    row = json.loads(line)
                except json.JSONDecodeError:
                    break
                observer.emit('server_'+row.pop('event'), server_t=row.pop('t'), **row)
                consumed += 1
        stop.wait(0.025)


def run(observer, args):
    sdk = load_switcher(args.sdk_dir)
    digest = source_digest(args.sdk_dir)
    rclpy.init()
    try:
        for key, label, initial, target, mode in CASES:
            with observer.lock:
                observer.current = key
            observer.emit('case_start', label=label, initial=initial, target=target)
            namespace = '/primebot_lab/action/'+key
            server_log = args.output/(key+'-server.jsonl')
            if server_log.exists():
                raise RuntimeError('Use a fresh output directory for each run')
            stream = (args.output/(key+'-process.log')).open('w')
            process = subprocess.Popen([sys.executable, '-u', str(Path(__file__).with_name('action_server.py')),
                                        '--sdk-dir', str(args.sdk_dir), '--namespace', namespace,
                                        '--initial', initial, '--mode', mode,
                                        '--duration', '1.0' if mode == 'transition_name' else '0.28',
                                        '--output', str(server_log)], stdout=stream, stderr=subprocess.STDOUT, env=os.environ.copy())
            node, follower = None, None
            stop = threading.Event()
            try:
                deadline = time.monotonic()+8
                while not server_log.exists() or not server_log.stat().st_size:
                    if process.poll() is not None or time.monotonic() >= deadline:
                        raise RuntimeError('Scenario service did not start')
                    time.sleep(0.03)
                follower = threading.Thread(target=tail_server, args=(server_log, observer, stop), daemon=True)
                follower.start()
                node = Node('t1_action_probe')
                switcher = sdk.McActionSwitcher(node, namespace+'/SetMcAction', namespace+'/GetMcAction')
                observer_client = node.create_client(GetMcAction, namespace+'/GetMcAction')
                assert observer_client.wait_for_service(timeout_sec=1.5)
                planned = [{'command': step.command, 'expected': step.expected_action} for step in switcher._plan_path(initial, target)] if initial != target else []
                observer.emit('plan', path=planned)
                options = sdk.McActionSwitchOptions(source='action_probe', service_wait_timeout=0.4,
                    service_call_timeout=0.4, poll_interval=0.1, minimum_set_action_interval=0.5,
                    total_timeout=0.8 if mode == 'stuck' else 5.0)
                started = time.monotonic()
                if key == 'direct':
                    client = node.create_client(SetMcAction, namespace+'/SetMcAction')
                    assert client.wait_for_service(timeout_sec=1)
                    request = SetMcAction.Request()
                    request.header.stamp = node.get_clock().now().to_msg()
                    request.source = 'direct_probe'
                    request.command.action_desc = target
                    future = client.call_async(request)
                    rclpy.spin_until_future_complete(node, future, timeout_sec=0.4)
                    assert future.done()
                    response = future.result()
                    result = {'success': False, 'error': 'direct_rejected', 'code': response.response.header.code,
                              'state': response.response.state.value, 'target_action': target}
                    assert result['code'] == 0 and result['state'] == -1001
                else:
                    returned = switcher.switch_to(target, options)
                    result = {'success': returned.success, 'error': returned.error.value,
                              'current_action': returned.current_action, 'target_action': returned.target_action,
                              'message': returned.message}
                elapsed = round(time.monotonic()-started, 3)
                observer.emit('helper_return', result=result, elapsed=elapsed)
                state = query(node, observer_client)
                observer.emit('post_query', **state)
                guard = running_guard(node, observer_client, target, observer) if key == 'transition' else None
                rows = [json.loads(line) for line in server_log.read_text(encoding='utf-8').splitlines()]
                requests = [row['command'] for row in rows if row['event'] == 'request']
                if key == 'route':
                    assert result['success'] and state['status'] == 100
                    assert requests == [BASE, 'QUADRUPED_TO_BIPED', CUSTOM]
                    assert WBC not in requests
                    acks = [row['t'] for row in rows if row['event'] == 'ack']
                    assert all(b-a >= 0.49 for a,b in zip(acks, acks[1:]))
                elif key == 'direct':
                    assert requests == [CUSTOM] and state['action'] == initial
                elif key == 'no_path':
                    assert result['error'] == 'no_transition_path' and not requests
                elif key == 'stuck':
                    assert result['error'] == 'timeout' and requests == [CUSTOM]
                    assert state['action'] == initial and state['status'] == 200
                elif key == 'idle':
                    assert result['error'] == 'target_not_active' and not requests and state['status'] == 0
                elif key == 'transition':
                    assert result['success'] and state['status'] == 200 and not state['switch_ready']
                    assert guard['ready'] and guard['status'] == 100
                elif key == 'task_running':
                    assert result['error'] == 'set_action_failed' and requests == [CUSTOM]
                elif key == 'already':
                    assert result['success'] and not requests and state['status'] == 100
                with observer.lock:
                    observer.rows.append({'key': key, 'label': label, 'initial': initial, 'target': target,
                        'plan': planned, 'result': result, 'elapsed': elapsed, 'observed_on_return': state,
                        'running_guard': guard, 'requests': requests, 'assertions_passed': True})
                observer.emit('case_done', result=result, observed=state)
                time.sleep(args.hold)
            finally:
                stop.set()
                if follower:follower.join(timeout=1)
                if node is not None:node.destroy_node()
                if process.poll() is None:
                    process.terminate()
                    process.wait(timeout=3)
                stream.close()
        assert source_digest(args.sdk_dir) == digest, 'Official source changed during the test'
        with observer.lock:observer.passed = True
    except Exception as error:
        observer.error = repr(error)
        observer.emit('lab_error', error=repr(error))
    finally:
        if rclpy.ok():rclpy.shutdown()
        with observer.lock:observer.finished = True
        report = observer.snapshot()
        report.update({'official_source_unchanged': source_digest(args.sdk_dir) == digest,
                       'sdk_version': '1.0.0.0', 'official_source': 'examples/python/common/mc_action_switcher.py'})
        (args.output/'verification.json').write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding='utf-8')
        print(json.dumps({'passed': observer.passed, 'cases': len(observer.rows), 'error': observer.error}), flush=True)


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument('--sdk-dir', type=Path, required=True)
    parser.add_argument('--output', type=Path, default=Path('results'))
    parser.add_argument('--port', type=int, default=8768)
    parser.add_argument('--hold', type=float, default=0.8)
    parser.add_argument('--capture-ready', type=Path)
    args = parser.parse_args()
    args.sdk_dir = args.sdk_dir.expanduser().resolve()
    args.output = args.output.resolve()
    args.output.mkdir(parents=True, exist_ok=True)
    observer = Observer(args.output)
    page = Path(__file__).with_name('index.html').read_bytes()

    class Handler(BaseHTTPRequestHandler):
        def do_GET(self):
            if self.path == '/api/state':data,mime=json.dumps(observer.snapshot(),ensure_ascii=False).encode(),'application/json'
            elif self.path == '/':data,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(data)))
            self.end_headers()
            try:self.wfile.write(data)
            except BrokenPipeError:pass
        def log_message(self,*_):pass

    def scheduled():
        if args.capture_ready:
            while not args.capture_ready.exists():time.sleep(0.05)
        run(observer,args)

    server=ThreadingHTTPServer(('127.0.0.1',args.port),Handler)
    worker=threading.Thread(target=scheduled,daemon=True);worker.start()
    try:server.serve_forever()
    except KeyboardInterrupt:pass
    finally:
        server.server_close();worker.join(timeout=4);observer.log.close()


if __name__ == '__main__':main()

网页的三张卡片分别读取设置响应、查询状态和辅助类返回,不做“收到设置成功就把查询卡改成 RUNNING”的推断。它通过本地 HTTP 接口读取记录,因此浏览器只是观察窗口,关掉页面不会取消 ROS 2 已经发出的请求。

保留这套观察界面,是为了让错误分支也看得见。发生 timeout 时,中间卡仍显示旧动作和 TRANSITION,右边卡显示辅助类失败;进入提前改名场景时,中间卡短暂为 TRANSITION,右边卡却已经 success。随后外围门槛继续查询到 RUNNING,原来的辅助类结果不会被覆盖。这比最终只显示一个绿色提示更便于解释执行过程。

<!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:#101c2a;color:#dce5ee;font:14px/1.6 system-ui,"Microsoft YaHei",sans-serif}main{max-width:1250px;margin:auto;padding:26px 30px}.top{display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid #35485e;padding-bottom:18px}.over{color:#7fa9cf;font:12px Consolas,monospace;letter-spacing:2px}h1{font-size:30px;color:#f2f6fa;margin:4px 0}.tag{font-size:12px;color:#9cb1c5;border:1px solid #3c5874;border-radius:20px;padding:7px 15px}.grid{display:grid;grid-template-columns:255px 1fr;gap:20px;margin-top:22px}nav,section{border:1px solid #30455a;border-radius:10px;background:#162638;padding:20px}h2{font-size:16px;margin:0 0 14px}.case{padding:12px 0;border-bottom:1px solid #2b4055;font-size:13px;display:flex;justify-content:space-between;gap:9px}.case.active{color:#6de0cd}.case small{color:#91a1b5;white-space:nowrap}.scope{font-size:12px;color:#819bb4;line-height:1.9;margin-top:18px}.headline{display:flex;justify-content:space-between;align-items:center;color:#b8cadb;margin-bottom:17px}.track{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}.card{background:#20374d;border-radius:7px;padding:16px}.card label{font-size:11px;color:#91b0ca}.value{font-size:23px;font-weight:700;margin-top:9px}.desc{font:11px/1.6 Consolas,monospace;color:#bad0e0;overflow-wrap:anywhere;min-height:44px;margin-top:8px}.status{margin-top:20px;padding:13px 16px;background:#203448;border-left:4px solid #5bc5b7;min-height:74px}.status b{display:block;color:#66dfc9}.timeline{margin-top:20px;padding-top:15px;border-top:1px solid #30475e}.event{display:grid;grid-template-columns:65px 100px 1fr;gap:9px;border-bottom:1px solid #283d51;padding:8px 0;font-size:12px}.event span:first-child{font:11px Consolas,monospace;color:#7fa2c2}.event .detail{font:11px/1.6 Consolas,monospace;overflow-wrap:anywhere;color:#bad0e0}.note{color:#9ab1c8;font-size:12px;margin-top:14px}.pill{color:#6ed5c5}#cases{min-height:390px}
</style><main>
<header class="top"><div><div class="over">PRIMEBOT / ACTION TRACE</div><h1>请求受理之后,状态走到了哪一步?</h1></div><span class="tag">官方辅助类 · 独立 ROS 2 场景</span></header>
<div class="grid"><nav><h2 id="progress">0 / 8 场景</h2><div id="cases"></div><div class="scope">服务响应 state<br>与动作查询 status 分开显示。<br><br>测试通过表示识别符合预期,<br>不表示物理动作完成。</div></nav>
<section><div class="headline"><b id="current">等待开始</b><span id="elapsed">—</span></div><div class="track"><div class="card"><label>SetMcAction 请求结果</label><div class="value" id="ack">—</div><div class="desc" id="command">尚未发送</div></div><div class="card"><label>GetMcAction 查询状态</label><div class="value" id="status">—</div><div class="desc" id="action">等待读取</div></div><div class="card"><label>官方辅助类返回</label><div class="value" id="result">等待中</div><div class="desc" id="error">尚未结束</div></div></div><div class="status"><b id="gate">先查询,再规划</b><span id="hint">观察实际请求、自动衔接与轮询结果。</span></div><div class="timeline"><h2>这条指令经过了哪些步骤</h2><div id="events"></div></div><div class="note" id="footer">状态来自测试服务;辅助类源码保持原样。RUNNING 表示本例查询的动作模式状态。</div></section></div></main>
<script>
const el=id=>document.getElementById(id);const labels={route:'沿合法路径切换',direct:'绕过规划直接跳转',no_path:'不存在的目标',stuck:'已受理但未到达',idle:'目标名称相同但 IDLE',transition:'名称相同仍在 TRANSITION',task_running:'任务响应 RUNNING=400',already:'目标已处于 RUNNING'};const names={0:'IDLE / 0',100:'RUNNING / 100',200:'TRANSITION / 200'};
async function update(){try{const s=await(await fetch('/api/state',{cache:'no-store'})).json();el('progress').textContent=s.rows.length+' / '+s.total+' 场景';el('current').textContent=labels[s.current]||'等待开始';const ev=s.events.filter(r=>r.case===s.current);const row=s.rows.find(r=>r.key===s.current);const ack=ev.findLast(r=>['server_ack','server_rejected'].includes(r.event));const query=ev.findLast(r=>['server_query','post_query','guard_query'].includes(r.event));const returned=ev.findLast(r=>r.event==='helper_return');el('ack').textContent=ack?'state = '+ack.state:'—';el('command').textContent=ack?.command||'尚未发送';el('status').textContent=query?names[query.status]||query.status:'—';el('action').textContent=query?.action||'等待读取';el('result').textContent=returned?(returned.result.success?'success':'未通过'):'等待中';el('error').textContent=returned?.result.error||'尚未结束';el('elapsed').textContent=returned?returned.elapsed.toFixed(3)+' s / 辅助调用耗时':'';el('gate').textContent=s.current==='transition'?'辅助类 success 与 RUNNING 业务门槛分别记录':row?'本场景断言通过':'沿接口逐步追踪';el('hint').textContent=row?.running_guard?'辅助类返回时 status=200;外围门槛继续查询到 status=100。':returned?.result.message||'请求返回、名称匹配与动作状态是不同信息。';el('cases').replaceChildren(...Object.entries(labels).map(([k,name])=>{const d=document.createElement('div');d.className='case'+(s.current===k?' active':'');const a=document.createElement('span'),b=document.createElement('small');a.textContent=name;b.textContent=s.rows.some(r=>r.key===k)?'已核对':s.current===k?'进行中':'待运行';d.append(a,b);return d}));el('events').replaceChildren(...ev.filter(r=>!['server_ready'].includes(r.event)).slice(-8).map(r=>{const d=document.createElement('div');d.className='event';const a=document.createElement('span'),b=document.createElement('span'),c=document.createElement('span');a.textContent=r.t.toFixed(3)+' s';b.textContent=r.event.replace('server_','');b.className='pill';c.className='detail';c.textContent=r.command?r.command+(r.expected?' → '+r.expected:''):r.action?r.action+' / status='+r.status:r.path?r.path.map(x=>x.command).join(' → '):r.result?r.result.error:r.target||r.error||'';d.append(a,b,c);return d}));el('footer').textContent=s.finished?(s.passed?'8 组断言通过。辅助类保持原样;测试服务只构造协议状态,不模拟机械运动。':s.error):'状态来自测试服务;辅助类源码保持原样。RUNNING 表示本例查询的动作模式状态。'}catch(e){el('footer').textContent='观察服务连接中断'}}update();setInterval(update,100);
</script></html>

按这个顺序复现,不靠一句 success 验收

本轮环境为 Ubuntu 22.04、ROS 2 Humble、Python 3.10 和 T1 SDK v1.0.0.0,消息包已经构建并加载。基础步骤可直接参考仓库的环境说明,SDK 从官方下载页获取。这里不重新抄一遍安装流程,重点放在当前工程的运行条件与判断结果。

git clone https://atomgit.com/aasd23/qiyuan.git
cd qiyuan/projects/04-t1-action-switching

source /opt/ros/humble/setup.bash
source "$HOME/primebot_sdk_lab/ws/install/setup.bash"
export PRIMEBOT_SDK_DIR="$HOME/Downloads/sdk_t1-v1.0.0.0"
export ROS_DOMAIN_ID=78
export ROS_LOCALHOST_ONLY=1
export RMW_IMPLEMENTATION=rmw_fastrtps_cpp

python3 run_lab.py --sdk-dir "$PRIMEBOT_SDK_DIR" --output results

PRIMEBOT_SDK_DIR 指向解压后含 examples/python 的 SDK 根目录,请按本地实际位置设置。打开 http://127.0.0.1:8768 可以看到八组场景依次推进。结束后先检查 results/verification.json 中 passed 为 true、rows 为八项,再逐项检查 result 与 observed_on_return。重新跑时使用新的输出目录,例如 results-next;运行器会拒绝覆盖已经存在的场景日志。

只验证一个目标时,可以单独开两个终端,分别加载同样的 ROS 与 SDK 环境,使用相同 domain。在终端 A 启动场景服务,终端 B 运行客户端。下面用提前出现目标名称的模式,专门观察外围门槛是否继续等待 RUNNING。

# 终端 A
mkdir -p results-manual
python3 action_server.py --sdk-dir "$PRIMEBOT_SDK_DIR" \
  --namespace /primebot_lab/action/manual \
  --initial BIPED_LOCOMOTION_WBC --mode transition_name \
  --duration 1.0 --output results-manual/server.jsonl

# 终端 B
python3 switch_client.py --sdk-dir "$PRIMEBOT_SDK_DIR" \
  --namespace /primebot_lab/action/manual \
  --target BIPED_CUSTOM_UPPER
echo $?

客户端最后输出 JSON,其中 helper_success 表示原样辅助类结果,running_gate 表示后续严格查询,ready 才是本例允许继续业务的结论。正常时 running_gate.last.status 为 100、退出码为 0。将服务模式改为 stuck,并缩短 switch-timeout,可以得到 helper_error=timeout、ready=false、退出码 2。本轮也独立跑过正常、提前改名和不推进三种客户端场景。

要用官方命令行入口交叉核对,可把两个默认服务名重映射到本地场景服务。这样运行的是 SDK 自带脚本,服务仍是受控实验节点。不要删掉重映射后随手执行同一命令,否则调用目标就与本文实验不同了。

python3 "$PRIMEBOT_SDK_DIR/examples/python/set_mc_action.py" --ros-args \
  -p type:=action -p action_desc:=BIPED_CUSTOM_UPPER \
  -r /aimdk_5Fmsgs/srv/SetMcAction:=/primebot_lab/action/manual/SetMcAction \
  -r /aimdk_5Fmsgs/srv/GetMcAction:=/primebot_lab/action/manual/GetMcAction

最后回到标题里的问题。一次请求成功,首先说明它满足了某个响应层的条件;辅助类成功,说明它满足了这版实现的到达判断;本文业务门槛通过,说明一次有效查询观察到了目标名称与 RUNNING。至于具体动作是否完成,还得回到那个动作的反馈定义。把这些结论分别留下,后续控制流程才能知道自己正在依赖哪一种证据。

读完这一条路径,留下的三个检查点

第一,保留 command 与 expected_action 两个概念。自动桥接路径里它们可以不同,重复发送预期到达状态反而可能干扰流程。路径规划得到的是一组需要执行和观察的步骤,不能只拿最终目标字符串覆盖中间过程。

第二,判断条件要看实现。官方说明建议等待 RUNNING,而本版辅助类用非 IDLE 判断 active;受控测试已经复现名称先到时提前返回的情况。应用可以在外围补上严格条件,并同时保存原始结果,方便后续 SDK 更新时重新对照。

第三,失败应当落到具体阶段。找不到服务、读不到当前状态、没有规划路径、设置被拒绝和等待超时,处理方式各不相同。退出码、响应码、任务状态与动作状态也应分别记录。这样得到的记录才足够解释“为什么这一次没有继续”,而不只是多打印一行 success 或 failed。

源码与配套记录:启元开发实验总仓库;官方接口与示例参考T1 SDK及开发资料包。本篇针对 v1.0.0.0 的客户端行为展开,升级版本后,应重新核对 active 条件、自动到达映射及超时参数的使用位置。

更多开发资料与交流入口:启元机器人开发者社区。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

程序猿追

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值