# TEST **Repository Path**: ye-three/test ## Basic Information - **Project Name**: TEST - **Description**: TEST - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-10-06 - **Last Updated**: 2026-10-10 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # serial-share 单进程独占 Windows COM 口,把同一份带全局序号(`seq`)的数据流同时喂给: - **工程师**:tkinter GUI(替代 XCOM 的日常看串口) - **AI agent**:只读 CLI,或 MCP 服务器——读的是同一份 JSONL 会话文件,另外还能请求 **会话生命周期**动作(启动界面 / 连接 / 断开 / 改配置 / 关窗口)与**一次确认过的发送** (`serial_send(..., confirm=True)`)。请求由 GUI 执行,COM 口始终只有 GUI 一个主人; AI 从不自己持有那个句柄,发送也是 GUI 进程里那唯一一把写口在做(见"给 AI agent 用")。 目的:人和 AI 就同一段日志做**可逐行对账**的客观判断。AI 说"第 3412 行有 assert", 你能立刻跳到那一行;AI 说"日志里没有 X",你能用字节账本验证是板子没发还是工具没收到。 ## 安装 运行不需要装任何依赖:宿主机已有 Python 3.14 + pyserial 3.5 + tkinter(MCP 那部分需要 `mcp` SDK,本机已装 1.30.0)。 装一个随处可用的命令 + 双击就开的快捷方式(可重复执行): ```powershell powershell -ExecutionPolicy Bypass -File tools\install.ps1 ``` 它做四件事:把 `serial-share.cmd` 渲染到 `%USERPROFILE%\bin\`、把 `%USERPROFILE%\bin` 追加进**用户级** PATH(不覆盖原值、不碰系统级)、在**开始菜单与桌面**各建一个 `serial-share` 快捷方式、最后从仓库外面跑一次只读查询自检。之后在任意目录直接用 `serial-share`,或者双击桌面图标。 **快捷方式指的是 `pythonw.exe -m serial_share gui`,不是那个 .cmd。** 差别是那个黑色控制台: `.cmd` 永远带一个控制台窗口,`start /min` 也只是把它最小化——GUI 万一崩了,traceback 就滚在 一个你看不见的黑窗里。`pythonw` 是无控制台的宿主,双击只剩窗口本身。 `PYTHONPATH` 也不用设:`-m` 会把**工作目录**放进 `sys.path`,所以快捷方式的"起始位置"必须是 仓库目录(留空则从桌面双击时 cwd = 桌面,包就找不到了)。找不到 `pythonw.exe` 的精简安装会 退回 `.cmd` + 最小化窗口,并把这件事打印出来——宁可难看,不要"双击没反应"。 ## 用法 会话日志默认落在 `E:\serial-share\logs`——**仓库外、且不在系统盘上**(28MB/小时的追加 文件不该去挤占 Windows 自己的空间)。优先级 `--logdir` > 环境变量 `SERIAL_SHARE_LOGDIR` > `core/paths.py` 里的 `DEFAULT_LOGDIR`。默认值只有那一个出处,`--help`、MCP 的参数说明与 启动器里那一行 echo 都是从它算出来的。 ```bash serial-share logdir # 问"这次到底写哪儿"(三层优先级算完的结果,并确保目录存在) ``` E: 盘不在时**启动即失败**并说清该改哪一层,不会悄悄换个地方继续写:静默换目录等于把 同一个台架的证据散在两处,半年后按老路径找不到那份 40MB 的文件,还会以为自己没采到。 换机器不需要改代码——设 `SERIAL_SHARE_LOGDIR` 或每次传 `--logdir` 就行。 ```bash serial-share # 开 GUI serial-share gui --port COM19 --baud 115200 # 指定端口开 GUI serial-share gui --port COM19 --baud 9600 --flow rtscts # 带硬件流控 serial-share restart # 优雅重启:先退掉在跑的,再起新的 serial-share logdir # 问"日志到底写在哪儿" serial-share query stats # 默认作用于"当前会话" serial-share query tail --n 200 serial-share query search --re "assert|fault|panic" --since-seq 3000 serial-share query frame --seq 3412 serial-share query since --pattern "boot complete" --timeout 10 ``` `serial-share restart` 走的是控制面那条 `quit`(不是 taskkill):会话正常收尾、残行落盘、 `session_end` 写出来,端口句柄由那个进程自己释放。**它有一个硬性的"不做"**:等不到心跳变陈旧 就**不起**第二个 GUI,而是退出码 1 把现场原样交回来。两个 GUI 同时活着会抢同一个 COM 口 (输的那个写满 `port_busy`),更糟的是它们共用同一个 `control/inbox`——请求被随机一个执行掉, 权威日志就此说不清是谁连的。所以"旧窗口为什么不应"才是那时候真正要查的问题,多起一个进程 只会把答案埋掉。 `--session` 现在可以省略:GUI 每次连接都会往日志目录写一个 `latest.json` 指针(路径 + 端口 + 波特率 + 是否在跑),`query` 和 MCP 都跟着它走,不用你告诉它文件名。显式给 `--session ` 仍然照旧生效。 ### GUI:收发两侧的界面 窗口最左边是**一列图标**(侧边栏,本机实测 42px 宽,顶部是那个 AES 标志——收起侧栏时 它也在,同时也是标题栏图标)。七颗按钮从上到下,每颗都有悬停提示: | 按钮 | 做什么 | 为什么在这儿 | |---|---|---| | `控制台` | 显示/隐藏下面「控制台」那一屏 | 它不是每次都要看,但必须一按就到;**默认收起**,把高度让给接收区 | | `共享` | 开/关"接受外部(AI/MCP)控制" | 同一个口,既能当普通串口工具用,也能当 share 串口用;关掉时按钮仍是亮的状态画在栏上 | | `设置` | 导出会话日志副本、复制会话路径、打开日志目录 | 导出的是**副本**,原文件仍是权威 | | `置顶` | 把窗口钉在所有窗口最前面 | 板子在另一个窗口后面打日志时要用;改的是 Tk 的 `-topmost`,按钮的样子跟着它 | | `关于` | 这台机器上的日志目录、当前会话、schema、共享状态 | 出问题时第一个被问的就是"日志到底在哪" | | `更多` | 只有还没做好的那三项:`网络调试助手` / `串行数据流图形化` / `SSH 连接` | 这一列**不是溢出区**。导出、复制路径、打开目录在「设置」里,清空视图与回到底部在接收区上方那两排开关里——同一件事留两个入口,下一次只会改到其中一处(2026-10-10 工程师提的那条冗余)。第一项换到占位页,后两项各说一句"卡在哪个决定上" | | `侧栏` | 收起/展开这一列 | 收起时留下 logo 与这一颗:整栏消失会让功能永久不可达,而只剩一颗没名字的方框会让人认不出这是本工具 | **换页不是开新窗**:`更多 → 网络调试助手` 把主窗口切到那一页(页里有"返回串口页"那颗按钮回 来),而不是另开一扇窗口——窗口能单独关掉(那"网络会话"还在不在跑?),也能开两扇(两个 视图看同一段账,谁先看到?),页面切换没有这两种状态。代价所在是**藏起来的只是视图,不是 采集**:串口页被换下去的时候端口仍归这个进程、日志照写、状态条照报数,回到串口页时那一段 一条不少(`test_a_live_session_keeps_logging_while_the_net_page_is_shown` 钉的就是这一条)。 状态是 `App` 的、按钮是它的视图:`_sync_rail()` 把 `-topmost`、控制面开关、pack 状态刷成 按钮的样子,而不是反过来让 Tk 属性去读按钮"现在是按下的吗"。共享模式那一条详见下面 「控制面」与"行为须知"第 14 条。 再往右是左右分栏。**左边**从上到下:串口(带刷新)、波特率、校验位(`NONE`/`EVEN`/`ODD`)、 数据位(`5`–`8`)、停止位(`1`/`1.5`/`2`)、流控制(`None` / `RTS/CTS` / `XON/XOFF`)、 `连接`/`断开` 按钮,然后是两栏只读数字—— **计数**(接收帧数 / 接收字节 / 发送帧数 / 发送字节 / 接收速率 / 视图淘汰)与 **线路状态**(接收队列峰值 / 发送队列峰值 / 驱动错误 / 最大轮询间隔)。 线路状态那四个是**本会话峰值**而不是瞬时值:9600 满速下瞬时队列深度几乎恒为 0, 看着像"链路很空",只有峰值回答"最多堆到多少字节"这个有用的问题;驱动探针不可用时 写"不可见"而**不是** 0("看不见"与"没丢"是两件事,见"行为须知")。 **右边**是接收区,上面**两排**开关(两排而不是一排:`pack(side=LEFT)` 不换行,一排摆不下 就等于右边的按钮**点不到**,不是难看):上排"怎么看"——`暂停`(停止渲染,数据仍进环形缓冲)、 `锁定滚屏`(继续渲染但不跳到底,往上翻历史时不被新数据抽走)、`时间戳`、`分包`(每行尾标 `(12B)` 这样的一项,数字是**线上字节数**、含被剥掉的行尾)、`HEX`、`清空视图`;下排"看到哪儿"—— `过滤`、`查找` + `上一个`/`下一个` + 命中计数、`跳到 seq`。 每一行都以 `[seq]` 开头,与 JSONL 文件里的 `seq` 一一对应——这是人与 AI 对账的唯一主键, **任何开关都不动它的位置**。 每行那个时刻印的是**首字节到达时刻**(`first_ts_ms`;文件里没有这个键的老帧与发出的帧退回 `ts_ms`),所以对 `gap` / `partial` 帧它比文件里的 `ts_ms` **早几十毫秒**——那是我们自己的 静默阈值,见"行为须知"第 11 条。`[seq]` 不受影响,人跳到文件里那一行仍然是确定的。 每行还带一个方向符号:**`«` 是收到,`»` 是发出**(深蓝 `#0b4f9e` 对近黑 `#1a1a1a`)。 符号进文本、也就进剪贴板与截图,颜色只是第二通道——红绿色觉异常(约占男性 8%)、 高对比主题、黑白打印都分得开"红发绿收"那套,但都分得开这两个符号。 `HEX` 勾选换的是**每一行的读法**,所以它会整屏重放(清空视图、从内存缓冲重印), 而其他开关只影响之后渲染的行。重放出来的十六进制分两种:落盘时带 `hex_bytes` 的 (解码丢过信息、由 `Framer` 存下的原字节)是权威的;其余从 `text` + `n` + `end` 反推, 凑不齐 `n` 就在同一行末尾写明"仅 text,凑不齐 n=…"——**绝不给一串看着像线上字节的 东西**。发出的帧走另一条:行尾是我们自己按 `TERMINATORS` 追加的,不需要猜。 **文本模式下,"装不进文本"的帧会在行尾补一句 `(hex a1)`**(2026-10-10 真板补的,而且 补了两次:`AA`(HEX) 是解不出 UTF-8,`11`(HEX) 却是**合法** UTF-8 的控制字节 0x11=XON—— 所以判据不是"解码坏没坏",是"这屏能不能把它显示出来")。允许出现的来源只有两个:落盘存下的 `hex_bytes`,以及发送侧那段能恒等往返的 `text.encode()`(代码里显式验那次恒等)。收到那一侧 没有 `hex_bytes` 就一个字都不补——从被剥过行尾的 `text` 反推一串看着像线上字节的东西,是往 证据里掺假。审计事件那一边同步改成 `preview=…`,见"行为须知"第 15 条。 `查找` 按**命中行**计数(一行里出现三次也只算一行),改关键字会重搜;视图头部被截断时 旧命中会被清掉而不是让游标指到别的帧上。`跳到 seq` 找不到时状态条写明"可能已被头部截断, 文件里还在"——视图会淘汰,文件不会。 **窗口的最小尺寸是算出来的,不是写死的**(`App._apply_minsize()`,本机实测串口页 **875×511**;点开控制台之后 875×544,因为那一屏比左栏高;网络页 720×521)。 理由很实在:这一屏的标签几乎全是中文,而 ttk 的 `width` 按"平均字符"给、**装不下就裁字**—— 写死一个像素数在别的 DPI/字体上要么还是太窄(看到"回到底"、"跳到 se"),要么白占屏幕。 所以宽度 = 侧栏 + **当前那一页**要的宽度 + 边距;串口页那一页 = 左栏 + 右栏(顶栏最宽那排、 接收区、控制台、多条发送取最大)+ 分栏条,网络页 = 它自己那一屏。高度取**左右两栏里更绑的 那一栏**再加发送行与状态条——这一句解释了为什么收起多条发送那一屏并不会让窗口变矮(左栏的 六个参数框加两组读数才是绑住的那一栏),而收起控制台会。 只算**此刻真的 pack 着**的那些屏:藏起来的还占着最小尺寸,人拖不动窗口,"藏起来"就 变成了"只是看不见了"。**换页也要重算**:两页都算就取到一个谁都用不上的最大值,表现是 "切到网络页窗口立刻变宽 400 像素",而人会把这读成 bug。`MIN_WINDOW_FLOOR` 只当地板用。 配套一条规矩:**中文按钮/标签一律不给 `width`**(`刷新` 曾写 `width=5`,把自然要求 87px 的 按钮压到 45px)。ttk 的 `width` 是**上限**而不是"至少多宽",文字长了就裁。判据在 `test_no_text_widget_is_narrower_than_its_own_text`:递归遍历整棵控件树,拿**同一种控件** (按钮跟按钮比,不是拿 Label 当尺子)造一个不给宽度的孪生体,要求宽度小于孪生体即会裁字; 任何 `TLabel` 设了 `wraplength` 也算——那不裁字而换行,"本会话未采集"变两行、把下一行顶歪。 唯一例外是网络页顶上那句"未实现":它是一整段说明、独占一行、右边只有一个靠右的按钮, 换行不会顶歪任何东西;不限宽的话它的 `reqwidth` 就是那一整行的像素宽(实测 948),比三列 加起来(654)还宽,一句说明于是有了决定整扇窗口最小宽度的投票权。 读数标签的真实内容是建好之后 `config(text=...)` 写进去的,所以遍历看见占位符 "—" 不算数, `TestGuiLayoutMapped` 会先把最长可信内容灌进去再量。 顶栏分**两排**是因为 `pack(side=LEFT)` 不换行:一排摆不下就等于右边的按钮**点不到**。 左栏的**计数 / 线路状态**两组并排,是为了把左栏固有高度压在 411px——一上一下叠着要 546px, 511 高的窗口只分给它 451px,"线路状态"整组就没了。 还有一条 Tk 的坑写在这里,因为它只会以"发送按钮不见了"的形式被发现:**`expand=True` 的控件 必须最后 pack**。pack 按顺序从剩余空间里切,先 pack 的分栏会把 cavity 吃光,后 pack 的 `side=BOTTOM` 项分到 0 高度——实测窗口一矮,发送行与状态条直接 unmapped。判据在 `TestGuiLayoutBudget.test_the_expanding_pane_is_packed_last`(问 `pack_slaves()` 的顺序), 真裁没裁由 `TestGuiLayoutMapped` 把窗口 map 出来量每个控件的右/下边缘。 **连接期间六个参数框全锁**:改了一半却没重连时,界面显示的就不是线上正在跑的配置, 而工程师照着屏幕读数去对账会读到一份不存在的波特率。控制器那条 `set_config` 走 `StringVar.set()`,不经控件状态,改完在**下一次** `connect` 生效。 **校验位/数据位/停止位是三个框,权威值却仍然只有一个字符串**(`hdr.fmt` 里那个 `8N1`)。 拆三个框不是为了像 XCOM,是为了**选得全**:以前一个"格式"框只列 7 项组合,`8E2`、`6N1`、 `5N1`、`7N2` 这些界面根本给不出来,而传输层与控制器校验一直认的是全部 36 种。 两个方向的同步规则:人选择时先合并回 `fmt`、**再**记账(顺序反过来,权威日志里那条 `cmd_set_config` 记的就是改动之前的格式);AI 或 CLI 改 `fmt` 时走一条 trace 把三个框对上 (它们不发选择事件,没有那条 trace 就是"AI 改了配置而界面还显示旧值")。脏值 (`--fmt junk`)不静默修正成 `8N1`:那串照旧是权威、照旧落盘,三个框保持原样。 底部是**单条发送**行:单行输入框 + `HEX` 勾选 + terminator 下拉(`CRLF` / `LF` / `无`)+ 一个实时字节数 + `发送` 按钮,最右边是 `多条发送` 那一屏的开关。 - 字节数显示的是**载荷加上传给对方的行尾**,即"将发出 N 字节";上限只数**载荷** (4096 字节,行尾另算,与 `core/tx.py` 的 `MAX_TX_BYTES` 同一个常数、同一处判据)。 超限或参数不合法时那里直接写"不可发送:…",不必按一次按钮去试。 - `HEX` 勾上后输入框里的内容按十六进制数字串解释,空格与逗号只是分隔符 (`01 02 0A` == `01,02,0A` == `01020A`);奇数位或含非十六进制字符**整条拒绝**, 不"跳过坏字符近似发送"。 - **未连接时整行是灰的**(输入框、下拉框、按钮一起禁用)——端口没开就没有可写的东西, 也不该让人对着空口排一次发送。 - 回车与按 `发送` 等价。发出去的那一行立刻以 `»` 出现在接收区,状态条写 "已发送 N 字节(seq=…,审计 seq=…)"。人按的这一次与 AI 请求的那一次在权威文件里 长得**一模一样**,只有署名前缀不同(`engineer:` 对 `mcp:`)。 ### 多条发送:一列可以勾着发的 AT 命令 调模组时发的是**一串有先后关系**的命令(`AT+CMODE=1` → `AT+CWJAP=...` → `AT+QISENDEX=...`), 单条框一次只装一条。把五条粘成一行发出去不算解决——线上就是一整块,模组认不出分隔。 所以发送行下面多了一屏(XCOM 那两页的形状):每行 `选` 勾选框、行号、内容、行尾下拉、 `HEX`、`发`、`删`;工具栏是 `添加条目` / `发送选中` / `开始循环` + `周期` 毫秒 / `保存列表` / 循环轮次读数;下面一条翻页(`本页 1/3 页,本页 5 条` + `上一页`/`下一页`/ `重新载入`)。 **页只是显示切片**,不是可以增删的容器,所以这里没有 XCOM 的"添加页码/移除此页"—— 条目是一个平铺列表,"这一页"没有独立身份,多一层容器只会多一种"我那条命令去哪了"的找法。 四条规矩,每条都是为了少一次事故: - **字节还是只从那一个写口出去**。每一条目走的都是与单条框、控制台、AI 请求同一个 `App._send_now()` → `TxChannel.send()`,所以**一批发出去 N 条 = 文件里恰好 N 对记录** (N 条 `dir:"tx"` 的 `d` + N 条 `cmd_send`,detail 里带各自的 `preview=`)。 没有第三种"批次事件"——那只会让同一次发送有两处说法。批量这个动作的留痕在**控制台** 里(`已发送 3 条:第 1 行 'AT+RST' 8 字节 seq=17/审计 18 …`),因为状态条那句 50ms 就被总账冲掉了,而文件说的是"线上跑了什么"。 - **整批预检,不过就一个字节都不出口**。发之前每条都按发送行同一套判据过一遍 (形状、`HEX` 能不能解码、载荷上限、载荷与行尾是否都为空——读的是同一批函数与 同一个 `MAX_TX_BYTES`)。有一条不合格就整批不发,并写明是第几行。理由是 AT 序列 有状态:"第 2 条发不出去而第 3、4 条照发",那头收到的是一个没人打算送的状态迁移。 中途被写口拒了(拔线、驱动报错)同样立即停,屏幕上写"第 N 行被拒,后面的没发"。 - **循环是有界、可见、可停的**。`开始循环` 就地变成 `停止循环`,工具栏一直写着 "循环中:第 N 轮,累计 M 条"。周期有地板(**50ms**,再快就是在锤模块,而发送跑在界面线程上, 读循环会被挤掉——9600 满速实测的读取节拍 p50 就是 31ms,见"行为须知"第 12 条)也有上限 (**1000 轮**,跑满自己停)。任何一轮被拒、会话断开、面板被 `多条发送` 勾选框收起、 窗口关闭——四种情况都立即停:一个看不见的循环等于没有出口。正在跑的那一轮发的是 **按下那一刻**的快照,中途改行不影响在飞的序列(要换就停了重开)。 - **清单存在日志目录里**(`/send_entries.json`,与 `status.json` 同级): 它是工程师自己的工作底稿,不该进 git,也不该跟着会话走。添加/删除/真的发出去之后 都会自动存一次,关窗时再存一次;`保存列表`/`重新载入` 给人手动的那一下。 **读不懂的那份文件一次都不许被覆写**——手改坏之后按一次"保存"就把它抹掉,是这份清单 唯一真正的丢失方式,所以坏 JSON / 形状不对 / 个别条目看不懂都会写进控制台并冻住写, 直到你修好它再 `重新载入`。 `选` 勾选框只管"发送选中/循环"这一批;行末那个 `发` 是按下去就发这一条,不看勾选状态。 一行留空 + 行尾 `CRLF` 是"只发一个换行去戳一下模组",那是一次有意的发送,所以批量规则 只把空白行挑出去,不拦单行按下去的那一次。 ### 控制台:让人用 AI 用的那同一批函数读同一份文件 接收区下面是两行控制台(输出区 + 输入框,`help` 列出全部命令)。**它默认是收起的**, 侧栏第一颗按钮切换——收起时它真的不在布局里(不是"在但高度 0"),所以最小高度也不再为 它留那一屏;点开之后它必须真的拿到高度,这两条都是几何判据,不是样式判据: ``` help status stats tail [n] search <正则> [limit] frame goto clear send [-hex] [-end nl|crlf|none] <内容> connect [端口] disconnect escape ``` 存在的理由不是"再给界面加一个输入框":`tail` / `search` / `frame` / `stats` 调的就是 `core/query.py` 里 AI 那侧调的**同一批函数**,输出的每一行都用与落盘**同一个**序列化函数 排版(`records.line_of_dict`),所以 `frame 3412` 打出来的那一行可以原样 `grep` 回文件、 原样贴进聊天。两边不存在第二份实现,也就不存在漂移——这是"人机同源对账"在界面那一侧的落点。 三件刻意的取舍: - **`wait` 不提供**。`query.since` 是阻塞轮询(上限 60 秒),在 Tk 主线程上跑等于冻住窗口, 而一个冻结的前台窗口正是这个项目要避免的东西。要等关键词就用视图的过滤框,或问 AI (MCP 那侧有 `serial_wait`,它阻塞的是自己的进程)。 - **读命令不记账**。`tail` / `search` / `stats` 什么都没改,给它们发 `e` 记录会把 "谁动了端口"淹在噪音里。`send` / `connect` / `disconnect` 一律走既有的唯一入口 (`_send_now()` → `TxChannel`),所以人敲的一次与 AI 排的一次在文件里长成同一对记录。 - **控制台的输出绝不写进接收区**。接收区是"从线上下来了什么",掺进工具自己的话就没法 当证据读。 ### `escape`:driver 卡住时的出口 拔线时 `CloseHandle` / `ClearCommError` 有可能挂死在驱动里,此时 reader 永不返回。 `disconnect` 为此准备 3 秒预算;`escape` 把预算砍到 `ESCAPE_JOIN_S = 0.2` 秒——敲这条命令 的人要的是"现在就走"。 代价**如实留在文件里**而不是被掩盖:被卡住的 reader 写不出 `session_end`,所以 `stats` 会报 `clean_exit:false`,同时文件里多出一条 `cmd_escape`。数据不丢的部分有保证—— 已经进队列的记录照旧由 writer 收尾,reader 与 writer 都是 daemon 线程,进程退出时 Windows 收回那个串口句柄,口不会被一个死进程占着。丢的只有"卡在驱动里的那部分还没读上来的字节", 而那本来就不是任何软件能保证的。 ## 给 AI agent 用(MCP) ```bash serial-share mcp # stdio MCP 服务器:只读查询 + 会话生命周期控制 + 确认过的发送 ``` 十四个工具,权力不一样大: - **六个只读查询**:`serial_sessions` / `serial_stats` / `serial_tail` / `serial_search` / `serial_frame` / `serial_wait`。只读磁盘上那份 JSONL,不碰 GUI 进程,所以 GUI 崩了 AI 照样能查。默认都作用于 `latest.json` 指向的会话;`serial_wait` 走 `query.since`,有 60 秒 硬超时上限。 - **八个控制面**:`serial_gui_status` / `serial_launch` / `serial_ports` / `serial_connect` / `serial_disconnect` / `serial_set_config` / `serial_quit` / `serial_send`。它们**不自己开端口、也从不自己写字节**,只是往 GUI 的命令队列里排请求 (`<日志目录>\control\inbox\`),执行的一直是那个 GUI 进程。COM 口始终只有一个主人, 而且那个窗口就在工程师桌面上——AI 连上之后人看到的正是同一段会话,他随时可以自己动手、 自己发。`serial_send` 是这八个里唯一会让字节离开这台机器的一个,因此它多一道 `confirm` 闸、并且只活 5 秒(见下)。 注册进 Qoder / Claude 之类的 MCP 客户端时,命令用 `python -m serial_share.mcp_server`,并把 `cwd` 设成仓库目录(或用 `serial-share mcp` 那条形同 PATH 命令)。 ### 控制面:一条典型链路 ``` serial_launch() # GUI 没开着就把它启动起来(独立进程);活着就不重复启动 serial_ports() # 有哪些口、分别是什么设备(只枚举,不打开) serial_connect("COM19", 9600) # 请求 GUI 连接;工程师的界面上下拉框会跟着变 serial_stats() / serial_tail() # 读同一段会话 ``` `serial_connect` 与 `serial_set_config` 收四个参数:`port` / `baud` / `fmt` / `flow`。 **省略任何一项都是"保持现状",不是"把它关掉"**。`flow` 取 `none` / `rtscts` / `xonxoff`,写错的值(比如少了个 c 的 `rts`)会被**拒**而不是回落成 `none`——静默回落 等于让 AI 以为开着硬件握手而端口上根本没有。板子只接了 TX/RX 两根线时不要选 `rtscts`: CTS 在板端悬空,对端会一直认为"现在不可以发",于是一个字节都收不到,那看着像固件死了。 **"没人执行"有三种,下一步完全不同**,工具把它们分开报:`gui_not_running`(心跳陈旧或 没有 → 该 `serial_launch`)、`share_disabled`(工程师在侧栏上把 `共享` 关了 → 该去问人, **不要**再起一个 GUI 抢口)、`no_reply` / `expired`(窗口在、但那一拍没轮到 → 请求已经 留在原地等到过期,改名 `.expired` 可查)。前两种都在**提交之前**就返回,所以 `control/inbox` 里什么都不会留下;`serial_send` 遇到这两种给的是 `outcome:"refused"` 加 `submitted:false`,一个字节都没出去。 `serial_connect` 的返回是 **状态前 → 命令 → 状态后**:`before` / `reply` / `after` 三份心跳 快照,加上 `port_state` 与 `events`。注意 `reply.ok:true` 只说明"会话被建立了",**端口到底通 没通看 `port_state`**(`opened` / `busy` / `lost` / `unknown`)——工具会去那份会话文件里读 `port_open` / `port_busy` / `port_lost` 这些真事件(最多 2 秒)。每一条**被执行**的命令都会在 会话 JSONL 里留下一条 `cmd_connect` 之类的事件,detail 形如 `mcp: connect port=COM19 baud=9600 fmt=8N1 flow=none`(四个参数就是那次连接**实际**用的 四个,`_config_summary()` 一处生成,人机两侧只差署名前缀);**工程师自己动手那一次也记**,而且用的是**同一个事件码**——他点"连接"、点"断开"、按窗口右上角 的 X、从下拉框里换配置,写出来的同样是 `cmd_connect` / `cmd_disconnect` / `cmd_quit` / `cmd_set_config`,只有 `detail` 的前缀换成 `engineer:`(`session_end` 那行末尾再署一句 `initiator=`)。一份只说得出"AI 要了什么"的证据文件回答不了"端口怎么变了"这个问题的一半。 引用做过的事时给的是**那条记录的 `seq`**,GUI 状态条同时会亮出"端口/会话最近一次由 mcp:connect 改动"——人和 AI 看的是同一个事实。 没有 GUI 活着时控制工具**当场拒绝**(`gui_not_running`,并说明去调 `serial_launch`), 不会把请求留在队列里等下一个界面。 ### 发送:`serial_send(payload, hex, end, confirm=True)` 这是整个工具面**唯一**会让字节离开这台机器的入口,因此它比生命周期命令多两道约束: ```python serial_send("reset\r\n", end="none", confirm=True) # 文本,不追加行尾 serial_send("01 02 0A", hex=True, end="crlf", confirm=True) # 十六进制串,空格逗号只是分隔符 ``` - **`confirm` 必须显式为 `True`**。不为真时 `error:"confirm_required"`、`submitted:false`, 队列里没有任何请求、文件里没有任何记录。它拦不住真心想发的人(只是一个参数,不是密码), 它拦的是"顺手试一下":字节出去就收不回来,而且会**改变被测对象的行为**——之后那段日志不再是 "板子自己会说什么"的证据。只有工程师明确要求这一次发送时才传它。 - **只活 5 秒**(生命周期命令 30 秒),而且 `submit()` 对它取 `min(传入值, 5.0)`:忘了传也短、 想调长也调不长。工具默认等 **8 秒**(`DEFAULT_SEND_WAIT_S`)才下结论,故意等过有效期, 好让"没人来得及发"这一种下场被**看见**、报成 `expired`,而不是一句含糊的超时。 - 载荷上限 **4096 字节**(`core/tx.py` 的 `MAX_TX_BYTES`,**只数载荷**,追加的行尾另算), 三处读的都是同一个常数。 `outcome` 取值(工具文档里把它讲成"四种",实现上还有第五个少见的那个,列全): | `outcome` | 什么时候 | 文件里留下什么 | |---|---|---| | `sent` | GUI 写了,`tx_seq` 那条 `dir:"tx"` 记录已从**会话文件里读回来**(`tx_record`) | 一对记录:`dir:"tx"` 的 `d`(`tx_seq`)+ 紧邻的 `cmd_send` 审计事件(`audit_seq`) | | `refused` | 这一下就不该发:没 `confirm` / 没连着 / 超 4096 字节 / 参数坏 / GUI 没在跑 | **一条都不留**,也不往 inbox 写请求 | | `failed` | **只有硬件写失败**(`reason:"write_failed"`)才算 | 一条都不留 | | `expired` | 5 秒里没人执行:等到 8 秒时那条请求已被改名成 `.expired`(`request_state:"expired"`),或 GUI 在执行前判出它已过期(`reason:"expired"`) | 一条都不留;inbox 里那个 `.expired` 是请求的痕迹,不是发送的痕迹 | | `not_executed` | 少见:等满了 8 秒既没有回复、那条请求又**没**被判定为过期(`request_state` 是 `pending` / `answered` / `discarded`——GUI 正忙在别的事上、或改名被 Windows 短暂握着)。同样一个字节都没出去 | 一条都不留 | **报告你发过什么时引用 `tx_seq`**(外加 `audit_seq`)。那是会话文件里那条 `dir:"tx"` 记录的 `seq`,工程师能跳到 GUI 屏幕上那一行 `»` 核对;聊天里一句"我发了 6 个字节"无法核对。 `tx_record` 是工具从那份文件里读回的原文,`record_found:false` 时(回复比文件早了几毫秒) 别把字节数当事实,先用 `serial_frame` 再读一次。 ## 数据格式 JSONL,一行一条,UTF-8。四类记录:`hdr`(会话头)/ `d`(数据帧)/ `m`(每秒记账)/ `e`(事件)。 `hdr.schema` 的版本史:**v2**(增量 A)只在每条 `m` 里**新增**一个嵌套键 `linestat`(驱动级线路状态); **v3**(增量 B1)给每条 `d` **加 `dir`**(`"rx"` / `"tx"`,缺省读作 `"rx"`)、给每条 `m` **加 `tx_bytes` / `tx_frames`**;**v4**(时序增量)给每条 `d` **按需加 `first_ts_ms` / `first_t`** (这一帧**第一个字节到达**的时刻,只在它确实晚于 `ts_ms` 时才写);**v5**(增量 C)给 `hdr` **加 `flow`**(这段会话实际用的流控 `none` / `rtscts` / `xonxoff`)。每一版都只增键、不改旧键, 所以老脚本读旧文件照常工作,v1–v4 的文件在 v5 工具里继续解析、继续 `bytes_match: true`。 读到 `schema: 1` 的文件里既没有 `linestat` 也没有 `dir`——没有 `dir` 就是"全部 RX", 没有 `linestat` 是"看不见驱动",**不是**"驱动没丢过字节";没有 `first_ts_ms` 是"文件没记到达 时刻",**不是**"这一帧没被晚切"。缺 `flow` 读作 `none`,而这一次"读作"是**诚实的**:v1–v4 的 `SerialTransport.open()` 从没传过握手参数,pyserial 默认就是无流控,所以"键不存在"与"线上没有 握手"在那批文件里本来就是同一件事。`hdr.schema` 的唯一出处是 `core/records.py` 的 `SCHEMA_VERSION`,GUI 造头时读的就是这个常数(B1 期间 GUI 头还硬编码 `2`、文件自己说不清自己在写什么,B2 起这个债还掉了:GUI 真写出的文件声明当下那个版本, `tests/test_gui_session.py` 在生产造头的那处断言 `hdr.schema == SCHEMA_VERSION == 5`。 磁盘上早于 B2 的会话仍是 1 / 2 / 3,读侧五种都容得下)。 记录模式的规范定义在设计文档里(本仓库不跟踪 `docs/`,它是开发与评审过程的工作文档): 仓库同级目录 `../serial-share-docs/superpowers/specs/2026-10-06-serial-share-design.md` 第 7 节。 **`d` 上的两个时刻**(这是 v4 的全部内容,也是最容易读错的一处): | 键 | 含义 | 什么时候用 | |---|---|---| | `ts_ms` / `t` | **切帧时刻**:`\n` 被看到、静默满 `gap_ms`、或会话收尾 flush 的那一刻。v1 起一字未改 | 记录排序、与 `m` 记账窗口对齐 | | `first_ts_ms` / `first_t` | **首字节到达时刻**,只在它早于 `ts_ms` 时才写进文件 | **量任何时延都用它** | `end:"gap"` 的帧天生晚切一个 `gap_ms`(默认 50ms),`end:"partial"` 晚切到会话收尾。真板 9600 回显实测因此把往返报成 **89–102ms**,而其中**至少 50ms 是工具自己的分帧延迟**。 `query stats` 现在直接给 `frames_late_cut`(这份会话里有多少帧被晚切)与 `max_cut_delay_ms`(最晚的一帧晚了多少),不必人手去扫 `end:"gap"`。 三条不变量(v3 起按方向分账,发送绝不混进 RX 那一本): 1. `seq` 全局单调不重复,断开重连不重置。 2. **两条**恒等式,各管一个方向: RX —— `Σ(d.n where dir=rx) == Σ(m.rx_bytes) == 串口实际读到的字节数`(`n` 含被剥掉的 `\r\n`),在 `query.stats` 里叫 `wire_bytes == meta_rx_bytes`,布尔是 **`bytes_match`**; TX —— `Σ(d.n where dir=tx) == Σ(m.tx_bytes)`(`n` 是**实际写出**的字节数,含追加的行尾), 叫 `tx_wire_bytes == tx_meta_bytes`,布尔是 **`tx_match`**。 `wire_bytes` **只数收到的字节**——把发出去的算进去,RX 那本账当场永远对不平, 而这正是发送要单独打 `dir` 标记、单独一条恒等式的全部理由。 3. JSONL 永不丢数据;内存视图淘汰时递增 `overflow`。 ## 行为须知(容易误判成 bug 的十五件事) 1. **安静的端口也会持续写盘**:只要连接着,即使一个字节都没收到,工具仍会**每秒写一条 `m` 记账记录**、无限期持续——约 8–9 MB/天。这是刻意设计:正是这条"空账"把 "板子没发"和"工具没收到"区分开(`rx_bytes: 0` 而轮询照常,即前者)。 v1 没有日志轮转,长会话请偶尔清理日志目录(`serial-share logdir` 会把它印出来)。 2. **`seq` 由数据帧和事件共享**:`query frame --seq N` 可能合法地返回一条 `e` 记录 (比如 `port_open`),而不是数据帧。这不是 bug——`seq` 是全局序号,事件也占号。 3. **`query tail --n 0` 返回整个会话**,不是返回 0 条。 4. **`Meta.overflow` 是累计值,不是每窗口增量**:对账时取最后一条 `m` 的 `overflow`, 不要跨多条 `m` 求和(求和会把同一个淘汰数重复计入)。 5. **连续且完全不打换行的流不会出帧**:分帧只有两个切口——`feed()` 遇 `\n` 切、 `tick()` 遇 ≥`gap_ms` 的静默切。固件若**既不停顿也不打换行**(`\r` 单行刷新的进度条、 一整屏不分行的 hex dump),字节会一直堆在分帧缓冲里、一帧都不出:评审实测 921600 波特下 10 秒缓冲了 921500 字节、**0 帧**。此时账目仍然是平的(`m.rx_bytes` 照记), 数据也没丢(断开时会作为一条 `partial` 帧整体落盘),但会话进行中屏幕上什么都没有。 所以"分帧不依赖换行"这句话**只对断续输出成立**。缓解办法是给分帧缓冲加字节上限强制 切帧,已推迟到 v1.1(得先决定 `end` 枚举要不要加新值:复用 `gap` 会让文件说谎)。 每行带 `\r\n` 的常规心跳日志不受影响。 6. **连接时板子可能自己重启一次**:pyserial 在 `open()` 期间会先拉起 DTR/RTS,本工具 随后才把它们清掉(`core/transport.py`)。带两晶体管自动复位电路的板子(ESP32 / CH340 那套)会在这一下里被复位,于是连接后先看到一段 ROM boot log。**这是预期行为, 不是 bug**;boot log 的字节同样计入账目,`frames` 里也会多出那几行。 7. **排队的 AI 命令会过期,不会迟到执行**:GUI 没开着的时候 `serial_connect` 之类 的请求会落进 `control/inbox/`,但它带着一个绝对过期时刻(`expires_ms`)—— 生命周期命令 **30 秒**,`send` 只有 **5 秒**(`submit()` 对它取 `min(传入值, 5.0)`, 忘了传也短、想调长也调不长:一次"晚点自己发出去"的字节是发生在一个没人看着的时刻的 不可撤销动作)。工程师之后启动 GUI 时,那条请求会被改名成 `.expired` **留在原地当证据**, 而不是被顺手执行——心跳里因此多出一个 `expired_commands`,状态条会写 "N 条外部命令已过期未执行"。看到它不是程序坏了,是"AI 伸过一次手而当时没人在"。 另外:认不出过期字段的请求也按过期处理(宁可不执行);`control/reply/` 里超过一小时的 结果文件会被下一次提交命令时顺手清扫。 8. **`linestat.errors` 是发生次数、不是字节数,`in_max` 是驱动接收队列的峰值深度**: 从 `hdr.schema: 2` 起,每条 `m` 多一个嵌套键 `linestat`。 `"errors": {"rxover": 3, "framing": 1}` 说的是"**这一窗口里有 3 轮 poll 报告 CE_RXOVER 置位、1 轮报告 CE_FRAME**"——驱动不给出那几次丢了几个字节,一次溢出可能是 1 个字节也 可能是几百个,所以把它当字节数读就是工具在说假话。`in_max` 是本窗口内 `COMSTAT.cbInQue` 的最大值,即"最多有多少字节压在驱动队列里等着被取走"。 `supported: false` 时这个键只剩 `{"supported": false, "reason": "…"}`:那段会话**看不见 驱动**(非 Windows、`schema: 1` 的老文件、假 transport、驱动调用失败), "没有错误记录"在这一种是"没在看"、在那一种是"确实在看且没发生",别混。 **`bytes_match: true` 但数据看着不对**时,就按这个顺序读 `query stats` 的四个 `linestat_*`(跨窗口聚合已经算好了:峰值取最大、次数求和,不必自己扫 `m`): 1. `linestat_supported` 为假 → 驱动层没有任何证据,下面的数字全都不存在。别把 `linestat_errors == {}` 读成"没丢过"。 2. `linestat_errors` 里有 `rxover` 或 `overrun` → **字节在进我们的 `read()` 之前就被 驱动扔了**。这时 `Σ(d.n) == Σ(m.rx_bytes)` 依然成立(等式两边一起变小), `bytes_match` 依然为真——这正是这条等式看不见的盲区,也是 `linestat` 存在的理由。 成因通常是波特率设错、读线程被饿着(看第 4 条)、或板子在复位瞬间灌进一段突发。 3. 有 `framing` 或 `parity` → 帧格式级的错(波特率/校验位不符、线路噪声、自动复位电路 抖动)。"一行里混进乱码"这类症状——例如 `HELLO-WORL` + 8 个乱码字节 + `WORLD`—— 归这一类,而不是"工具没收到"。 4. `linestat_in_max` 持续几十上百、且 `max_poll_gap_ms` 成片偏大 → 读线程被饿着, 字节在驱动队列里堆着;离第 2 条只差一次队列满。峰值本身不是丢,是**前兆**。 5. 以上全零且 `linestat_supported` 为真 → 驱动这边干净,可以回到"板子到底发没发"判据 (`rx_bytes` / `bps` / `frames`)。 **但"全零"永远不等于"线上干净"**——上面第 5 条要说的是"驱动那边没报告",不是"没发生"。 本机实测的一段真板会话就是这么说的:9600 打满的 CH340 上跑 140 秒,`frames` **9901** 条 里 **18 条是解码损坏的坏帧**(`bad_utf8: true`,带 `hex` 原文),而 `linestat_errors` 是 **`{}`**(一个错误位都没报告)、`linestat_in_max` 只有 **32**、 `bytes_match` 与 `clean_exit` 双双为真、`session_end` 那行写的是 `linestat=none in_max:32`。也就是说:**驱动报告为空只证明"驱动没说自己错过什么", 不证明那条线是干净的**。帧格式级的损坏(噪声、波特率漂移、自动复位电路抖动)可以在 `dwErrors` 一次都没置起的情况下发生——采样点之间它可能已经被 `ClearCommError` 自己 清零消费掉了;队列没满(`in_max` 才 32 字节)自然也一个 `rxover` 都没有。 看到乱码就查 `bad_utf8` 的帧数,别看到 `linestat_errors` 为空就把那段日志当无损。 数坏帧的一条命令(`--limit` 默认 200,坏帧多于 200 条时要自己放大): ```bash python -m serial_share query search --re '\ufffd' --limit 2000 ``` 以前这条在中文 Windows 上要带 `-X utf8` 才跑得动——`U+FFFD` 打不进 cp936/GBK, 不带就在 `__main__` 那句 `print` 上抛 `UnicodeEncodeError`。2026-10-10 真板上第一次 撞到它的是 `query frame`(发过一条 `A1`(HEX) 之后,退出码 1、半个 JSON 都没打出来), 现在 `main()` 一进来就把 stdout/stderr 重配成 UTF-8 + `backslashreplace`:控制台里 看到的是 `\ufffd` 那六个字符,而它恰好也是 JSON 的转义写法,所以管道下游 `json.loads` 读回来仍然是那个字符,一个字都不丢。 9. **`bytes_match` 管 RX、`tx_match` 管 TX,两条是分开的**:一个布尔平不代表另一个平, 反之也一样——`wire_bytes` **只数收到的字节**,发出去的是 `tx_wire_bytes`。 所以"这段会话有没有发过东西"是 `frames_tx` / `tx_frames` 这两个键(流侧条数与记账侧 条数,故意两个名字,对照着看才说得出"发出的每一条都记上账了吗"), 而不是把 `frames` 一个数读成两件事。 10. **会话还开着的时候 `bytes_match: false` 是设计,不是丢字节**:当前那个 1 秒记账窗口 还没落成 `m` 记录,`Σd.n` 因此比 `Σm.rx_bytes` 多出一截。多出来的那截就是 `open_window_bytes`(定义是恒等式两边的差本身 `max(0, wire_bytes - meta_rx_bytes)`, TX 侧同理是 `open_window_tx_bytes`)。实测一份正在写的会话:`wire_bytes` **82971** 对 `meta_rx_bytes` **82350**,差的 **621** 个字节全在没落盘的那个窗口里。 **不要**把它读成"工具丢了 621 字节",也**不要**改成"数最后一条 `m` 之后出现的 `d`"—— 一条 `gap` 切出来的帧可以携带属于更早那个已落盘窗口的字节,那样算会把一致的会话读成 不一致。判活会话只看 `bytes_match_note`:说"会话未结束"就是正常的,说"真不一致" (或记账侧反而比流侧多)才需要停下来查。关掉窗口后同一份文件应翻成 `bytes_match: true` + `open_window_bytes: 0` + `bytes_match_note: null`。 11. **屏幕上的时刻与文件里的 `ts_ms` 对 `gap` / `partial` 帧就是差几十毫秒,这是设计**: GUI 每一行开头那个 `HH:MM:SS.mmm` 印的是**首字节到达时刻**(`first_ts_ms`,缺这个键时 才退回 `ts_ms`),而文件里的 `ts_ms` 仍是**切帧时刻**。一条被 50ms 静默阈值切开的帧, 屏幕上写 `…12.487`、文件里写 `…12.607`,差的就是我们自己的分帧延迟。所以: **量时延请用屏幕上那一列(或文件里的 `first_ts_ms`),别拿文件的 `ts_ms` 当到达时刻**; 反过来说,**人眼对着屏幕时间去核文件里的 `ts_ms` 一定核不上**,那不是你算错了。 两边的 join 键始终是行首的 `[seq]`,它一字未动,人与 AI 的核对不受影响(spec §7 有同一 条)。同一条理由也解释了为什么 `ts_ms` 没有被"改成"到达时刻:那会让磁盘上每个旧文件被 静默重新解释,同一份证据在两个版本的工具手里给出两个时延——宁可多一个键,不改一个义。 老会话(`schema < 4`)没有这个键,屏幕上印的还是切帧时刻,与旧版逐字相同。 12. **`first_ts_ms` 值多少毫秒,这件事文件自己算得出来**:戳打在一次 `read()` 返回之后的 那一刻,而一帧的**首字节**比那一刻早——早多少?「这一帧自己的字节 + 同一次 read 里 它之后那些字节」在线上跑的时间。等一次 `feed()` 会把同一块的帧打上**同一个** `ts_ms`, 所以按 `ts_ms` 分组就能把这块的字节数算出来,不需要外部时钟。 `tools/analyze_timestamp_lateness.py` 干的就是这件事,2026-10-09 在 COM19@9600 实测: | 量 | 数值 | 哪来的 | |---|---|---| | 物理下界 | **12.5ms** | 一行 13 字节 = 12 个字节间隔;要等行尾才知道整行 | | 上界 p50 / p95 / max | 26 / 66.7 / 134.4ms | 一组通常是 2–3 行 | | 能支撑 <20ms 判断的帧 | 33.5% | 上界 ≤ 20ms | | 能支撑 <50ms 判断的帧 | 85.5% | | 两个容易读错的地方一起说清:① `max_poll_gap_ms`(状态条与左侧栏那个)是**一秒窗口内的 最大值**,不是"每次都等这么久"——同一场实测读回来的节拍是 p50=31ms / p95=79ms, 只有 4.6% 的读取等了 100ms 以上,而那一栏 p50=109ms 说的是"这一秒里最长的一次"。 ② 那 12.5ms 的下界**不是 poll 的锅**:把 `READ_TIMEOUT_S` 调到 1ms 也拿不到,因为 一帧要等它的结束条件(换行或静默阈值)出现才成立。想要字节级的真值,只有逻辑分析仪 去抓 TX 线,或者让固件在每条日志里自带设备自己的时间戳。 所以:**这个工具量到 89–102ms 的往返时延,等于说"真值在 0–135ms 之间",不能当实测值用**。 13. **按了「发送选中」而什么都没出去,多半是预检拦下了整批,不是工具坏了**:只要选中的 行里有一条不合格(`HEX` 那列有非十六进制字符、载荷超过 4096 字节、载荷与行尾都为空), 这一批就**一条都不发**,控制台写明"第几行、为什么"。这是有意的——AT 序列有状态, "前两条发出去了而第三条发不出"给对方送的是一个没人打算送的状态迁移,比全不发的 排查代价高得多。另外两种"什么都没发生"也各有各的原因:**没勾 `选` 的行与空白行不参与 批量**(勾 `选` 就是"这一行要发",空白行只是占位,把它们算进去只会得到一排 "载荷为空"的拒绝),**未连接时 `发送选中` / `开始循环` / 行末 `发` 全是灰的** (口没开着就没有可写的东西)。 反过来,`循环` 停在第 N 轮也不是丢了一步:四个出口之一(某轮被拒 / 会话断开 / 面板收起 / 跑满 1000 轮),控制台都有那一句"循环已停止:…(N 轮 / M 条发送)"。 14. **`共享` 按钮关掉之后,AI 那边拿到的是 `share_disabled`,不是超时**。这不是"连接失败", 也不是"GUI 挂了"——是**有人在看着但选择不接**,正确的下一步是去问工程师,而不是再 `serial_launch` 起一个 GUI(两个窗口会抢同一个 COM 口,还会共用同一个 `control/inbox`)。关掉期间心跳照旧每 200ms 写一次(里面就是那个 `share_enabled: false`),排进来的请求**不删、不执行**,等到过期被改名成 `.expired` 留在原地——"AI 伸过手而工程师不接"必须是查得到的事实,删队列就把 证据删了。重新打开共享之后,还在有效期内的请求会被补执行。 顺带一条容易看错的:**控制台那一屏默认是收起的**(侧栏第一颗按钮切换),所以 "控制台没了"不是回归,是这版默认;侧栏收起时留下 logo 与最下面那颗按钮,那颗就是 再展开的入口(展开后按钮会回到原来的次序——这一条是几何判据,不是样式判据)。 15. **发二进制字节时,屏幕上那个 `◆`(或那个谁也不认识的杂符)不是内容**:现在它后面会 跟着 `(hex a1)`,审计事件的预览也写成 `preview=a1`。两处都是 2026-10-10 真板上 补的——之前 `0xA1` 与 `0xB2` 在 `cmd_send` 的 detail 里印成**同一个** `◆`,而那条 detail 是"AI 说我发过什么"在权威文件里唯一的落点(`d` 记录里另有 `hex` 字段,但要两头 翻才算得清)。补第一次时只看了"UTF-8 解不解得开",于是 `11`(HEX) 仍然露馅: `0x11` 是合法 UTF-8 的控制字符(XON),解码一点都不坏,屏幕上是一个杂符、文件里是一个 裸控制字节。现在三处(帧的 `ctrl` 位、审计预览、屏幕标记)问的是同一个判据。 两个相邻的坑一起说清: - **`HEX` 勾上时行尾仍然会追加**:`A1` + `CRLF` 是 **3 字节**,不是 1。字节数标签 一直如实写着"将发出 3 字节",要只发那一个字节得把行尾选成 `无`。 - **`0x11` 不是这套 HEX 的写法**:分隔符只有空格与逗号(`11`、`11 22`、`11,22`、 `1122` 都行)。而且它**不勾 HEX** 时是能发出去的——发的是 `0`、`x`、`1`、`1` 四个 ASCII 字符,那才是真正容易误伤的一次。 ## 设计要点 - **分帧不依赖换行——但只对断续输出成立**:出现 `\n` 切帧(`end:"nl"`),或静默超过 50ms 切帧(`end:"gap"`),退出时残行落 `end:"partial"`;半行卡住、静音间隔都会变成 可见的帧。**连续且完全无换行**的流是例外,见"行为须知"第 5 条。 - **切帧时刻与到达时刻分开记(`d.first_ts_ms`,v4)**:上面那三个切口里只有 `nl` 发生在 字节到达的那一瞬间,`gap` 晚一个阈值、`partial` 晚到会话收尾。所以 `Framer` 一路维护 "缓冲里最旧那个字节的到达时刻",切帧时连同 `ts_ms`(切帧时刻)一起写出去;只在两者 **不同**时才多写这两个键,最常见的单读 `nl` 帧记录逐字保持原形、日志不涨。GUI 屏幕上 那一列显示的是**到达时刻**。理由是一个测量缺陷:真板 9600 回显被量成 89–102ms,其中至 少 50ms 是我们自己的静默阈值——量时延因此必须用 `first_ts_ms`,而 `frames_late_cut` / `max_cut_delay_ms` 就是"这份会话的时延里有多少是工具自己"那两个数。 - **权威数据是文件,不是 GUI**:GUI 崩了、卡了、被关了,证据都在。AI 的 CLI 不经过 GUI 进程。 - **每秒记账**:`rx_bytes` / `bps` / `max_poll_gap_ms` / `overflow`,用来区分"板子没发"和"工具没收到"。 - **驱动那一边也有只眼睛(`m.linestat`,v2)**:每轮 poll 调一次 `ClearCommError`,把 `cbInQue` 峰值与 `CE_RXOVER`/`CE_OVERRUN`/`CE_FRAME`/`CE_RXPARITY`/`CE_BREAK`/`CE_TXFULL` 记进这一秒的账。字节等式 `Σ(d.n) == Σ(m.rx_bytes)` 只证明**工具内部**两本账对得上, 驱动溢出时两边一起变小、等式照平;`linestat` 就是那条等式之外唯一能看见驱动的地方。 读数方法见"行为须知"第 8 条。拿不到驱动状态时它**永久且安静**地退化成 `{"supported": false, "reason": "…"}`,不影响采集,也不刷日志。 - **验收可以只靠文件机器判定**:`query stats` 里的 `session_end` 是 reader 在收尾现场 算出的总账原文(v3 起 `frames=… bytes=… tx=… overflow=…`),`clean_exit` 表示它存在。拿 `session_end` 的 `frames`/`bytes`/`tx` 与同一个 `stats` 输出的 `frames`/`wire_bytes`/`tx_wire_bytes` 比(`bytes`/`wire_bytes` 只数 RX,`tx`/`tx_wire_bytes` 只数 TX),再看 `bytes_match` 与 `tx_match` 与 `clean_exit`,全部自洽即通过——不需要 GUI 在场。 - **`since` 有硬超时上限(60s)**:到点必返回 `timeout:true`,不会把调用方挂死。控制面的 等待上限更短(`MAX_CONTROL_WAIT_S` = 25 秒),因为它小于生命周期命令的 30 秒有效期—— 等得比命令活得久没有意义。**`serial_send` 刻意反过来**:默认等 8 秒而 `send` 只活 5 秒, 为的就是让"没人来得及发"这件事真的发生一次并被报成 `expired`。 - **发送只有一个写口,而且在 GUI 进程里**:界面上的单条发送行、多条发送里的每一条目、 控制台敲的 `send`、队列里的 `send` 命令,四个入口都汇到 `core/tx.py` 的 `TxChannel.send()`——`_tx_lock` 里串行、同一次 `_order_lock` 里落 一对记录(`dir:"tx"` 的 `d` + 一条 `cmd_send`)、`_acct_lock` 里 `observe_tx`。 GUI 与 MCP 两边都**从不**直接 `transport.write()`(测试里有源码级断言,而且扫的是 **整个 `gui/` 包**而不是单个文件——新模块离"顺手 write 一下"最近,判据只钉一个文件时 它是隐形的)。 **被拒的发送一条记录都不留**——没发出去的东西不许进证据文件。 `TxChannel` 对 reader 持**弱引用**:强引用会凑成 reader↔tx 循环,把 `App`(连同它的 `tkinter.Variable`)的回收推到别的线程上,`Variable.__del__` 撞上 Tcl 直接 `Tcl_AsyncDelete` abort 整个测试进程。 - **AI 只能请求,GUI 才执行**:控制面隔着的只是日志目录里的几个 JSON 文件 (`control/inbox`、`control/reply`、`status.json`)。这样端口不必易手,人和 AI 才能继续 看同一段前台会话;GUI 没跑时不会"连接被拒",只会看见心跳变陈旧,而排队的请求 (生命周期 30 秒、`send` 5 秒)过期后就作废。 每一条**被执行**的命令都在会话里留一条 `cmd_*` 事件(`detail` 带 `source:` 前缀), **工程师自己动手的那一半同样记**(同一个事件码,前缀是 `engineer:`)——"端口怎么变的、 是谁动的"这个问题,一份只记 AI 那半边的文件只答了一半。 ## 测试 ```bash python -m unittest discover -s tests -v ``` 含无驱动回环测试(`tests/test_e2e_loopback.py`):用 `FakeTransport` 注入已知字节流, 断言 GUI 视图计数 == JSONL 帧数 == Σn == Σrx_bytes。 时延那一半另有 `TestEchoLatencyIsNotOurOwnFramingDelay`(同一文件):一条**回环口** (`EchoTransport`,写进去的字节原样从读这一侧回来,正是真板 echo demo 的工况)发一个不带 换行的字节 → 它必然被 `gap_ms` 切开 → 断言 `ts_ms - first_ts_ms >= gap_ms`,并把新旧两套 读法并排算出来(实测:旧数字 62ms,到达时刻 10ms)。这条测试钉的就是"旧时延被我们自己的 阈值虚报、`first_ts_ms` 把真值取回来"。全程 `FakeTransport`,**不开任何真实 COM 口**。 驱动级线路状态另有 `tests/test_linestate.py`:`dwErrors`/`COMSTAT` 的归一化、四条退化路径 各只试一次(断言失败之后底层调用次数不再增长)、"errors 是次数不是字节"。全程假 `ClearCommError`,**不开任何真实 COM 口**。 `tests/test_gui_session.py` 需要图形环境(tkinter);拿不到 `Tk()` 时整条 SkipTest, 不会让套件变红。它同样只用 `FakeTransport`,**不会打开任何真实 COM 口**。 `tests/test_gui_view.py` 是增量 C 拆出来的那一半:行格式、HEX 重建的权威判据、流控词汇表 如今住在 `gui/view.py` / `gui/panel.py` 里,**不需要真会话、不需要 transport** 就能测; 只有 `ReceiveView` / `SettingsPanel` 那两组要 `Tk()`,拿不到同样整条跳过。 `tests/test_gui_console.py` 测增量 D 的命令语义,读命令**连 Tk 都不需要**——一个只有 `session_path` 的对象就够(会话文件用 `tests/session_builder.make_session` 造,走生产同一条 落盘路径)。这一层钉的是本功能的全部理由:控制台打印的那一行与磁盘上的那一行**逐字相同**。 `tests/test_gui_session.py` 里的 `TestGuiConsoleWiring` / `TestGuiEscapeHatch` 再接真 App: `send` 只碰那一个写口、输出绝不漏进接收区、布局真的挂上了(`Text` 不 pack 照样能 `get`,所以这条只能问几何管理器),以及 `escape` 在 reader 卡死时用的是被砍短的预算。 `tests/test_gui_sendlist.py` 测多条发送,同样分两层:**纯函数不需要 Tk**(条目文件的 读/写与"读不懂就不许覆写"、预检的四条判据、周期地板、带 seq 的结果排版),**控件只要一个 桩 host**(`SendList` 只用得到 `logdir` / `reader` / `root` / `_send_now` / `status` / `console` 六样,所以翻页保内容、勾选与空白行的取舍、循环的四个出口都能在没有串口、没有 会话线程的情况下钉死)。真会话那一半在 `tests/test_gui_session.py` 的 `TestGuiMultiSend`: 一批发出去 = 文件里恰好 N 对记录且**没有第三种"批次"事件**、预检不过时口上一个字节都没写、 被拒那一轮之后不再接着发、断开/收起面板/关窗三种出口都把循环停掉且清单落盘、 人发完之后心跳的 `source_of_last_change` 是 `engineer`(上一次动手的是 AI 也不许留着不改)。 布局那三条判据也一起管这一屏:`test_no_text_widget_is_narrower_than_its_own_text` 现在把 多条发送那一栏算进递归遍历,`TestGuiLayoutMapped` 把它的工具栏、一条条目行与翻页条一起 在**最小尺寸**下量右/下边缘,`TestGuiMultiSend.test_showing_the_multi_panel_puts_it_back_at_the_bottom` 则钉住"收起再展开要回到原来的 pack 位置"(排到队尾就是分到 0 高度,那是"发送按钮不见了" 那一类只有几何管理器看得见的事)。 `tests/test_gui_rail.py` 测侧栏与三个弹窗,`SideRail` **不需要真 `App`**(只要一个父控件 与一个回调字典),所以按钮顺序、收起后那颗还在不在、图标有没有被引用着、弹窗文案与 "没会话时菜单里少哪两项"都能在这一层钉死。里面三条判据值得单说: **图标对象必须被 Python 侧引用着**(`tk.PhotoImage` 与 `tk.BooleanVar` 同一条 GC 陷阱, 而 `cget("image")` 在图像被回收之后仍然非空——所以判据问的是"侧栏自己还握着几个图像"); **收起不许把唯一的出口收掉**;**同类弹窗不许叠**。`TestGuiSideRail`(在 `test_gui_session.py` 里)再接真 `App`:图钉改的是 Tk 的 `-topmost` 而不是一个布尔、 共享关掉之后请求留在 inbox 里不被执行而心跳照旧写、重新打开会**补执行**那条还没过期的 请求、一次切换恰好一条 `cmd_share` 账(空闲时一条都不记),以及 `mcp_server` 那边 `share_disabled` 与 `gui_not_running` 是分开的两种下场(后者会诱导调用方去 `serial_launch`, 那正是抢口的动作)。图标由 `tools/make_icons.py` 离线生成(**只有重新生成时才需要 Pillow**,运行时仍然只用 stdlib)。 还有一组判据是 2026-10-10 真板上一条 `A1`(HEX) 换来的,分散在三个文件里: `test_tx.py` 钉"两个不同的二进制载荷不许留下同一条 `preview`"(`` 前缀也在判据里, 否则文本 `a1` 与字节 `0xA1` 会撞成一句话)、`test_gui_view.py` 钉"文本模式那行补一句 `(hex …)`,但没有 `hex_bytes` 时一个字都不许编造",`test_cli.py` 的 `TestCliStdioEncoding` 钉"GBK 控制台上读到 `U+FFFD` 不再把 `query` 崩掉"——最后这条 **只能开子进程测**:其余 CLI 测试用 `redirect_stdout(StringIO)` 接管输出,而 StringIO 没有编码这回事,测不到那个崩溃。 控制面另有两组:`tests/test_control.py` 测文件队列协议(原子写、TTL 过期改名、结果清扫、心跳降级、`send` 那五种命令的形状与两档期限),`tests/test_mcp_control.py` 测 MCP 的 **八个**控制工具(含 `serial_send` 的 `confirm` 闸与 `sent`/`refused`/`failed`/`expired` 几路下场)。后者的 `TestConnectPortStateWithGui` 用无头 GUI + `FakeTransport` 跑完整链路 (AI 排请求 → GUI 执行 → reader 写 `port_open`/`port_busy` → 工具读回来报 `port_state`)。 两处纪律:**不启动真 GUI**(`_spawn_gui` / `Popen` 全部换替身)、**不开真端口**。 发送侧另有 `tests/test_tx.py`:`TxChannel` 对着**真 reader** 测——一次接受的发送恰好落 一对记录、`n` 等于实际写出的字节数(含追加的行尾)、五种拒绝/失败**一条记录都不留**, 以及最危险的那条:**发送不许毁掉 RX 恒等式**。GUI 那半边由 `tests/test_gui_session.py` 的 `TestGuiSendBox` / `TestGuiSendCommand` 钉住 (未连接时整行禁用、字节数预览、`engineer:` 与 `mcp:` 各留恰好一条审计事件、 GUI 从不自己调 `transport.write()`)。 ## 仍然不做 **AI 可以控制会话生命周期**(启动界面、连接、断开、改配置、关窗口——见上文"控制面"), **也可以在工程师明确要求这一次发送时让 GUI 往外发字节**(`serial_send(..., confirm=True)`, 见上文"发送")——这两条都是已经落地的能力,不再是"不做"清单。 **仍然不做的两件事**: - **碰 DTR / RTS 那两条硬件控制线、把目标板按进复位**。一拉 RTS,ESP32 / CH340 那套两晶体管 自动复位电路就把板子按进复位态,那是"改变被测对象"里最不可挽回的一种。本工具唯一一次写 这两根线是在 `transport.open()` 里把它们**清成 `False`**(不拉),不由任何命令触发。 唯一的例外是工程师或 AI 明确选了 `RTS/CTS`:那时 RTS 是流控信号线,由 pyserial 的握手逻辑 持有,工具不去清它——清了等于一边声称开着硬件握手、一边亲手把对端静音(板子的日志会当场 停发,看着像固件死了)。`XON/XOFF` 是带内的,不碰这两根线。 - **让 MCP 进程自己持有串口**。端口始终在 GUI 手里;`grep -rn "serial\.Serial(" serial_share/` 只有一个命中(`serial_share/core/transport.py`),控制层里连 `SerialTransport` / `core.transport` / `.write(` / `dtr` / `rts` 这些字样都不出现,而且 `tests/test_mcp_server.py::test_server_never_opens_a_port` 就是拿源码扫这条的。 到这儿为止,"工程师在前台看串口、AI 在旁边一起判断"这件事的两端都齐了:界面能读能发能锁参数, MCP 侧能读、能控会话生命周期、能在工程师明确要求时发一次字节。 **仍然不做的那几项都是大类**,不是补漏:编译烧录集成(那是另一套工具链的边界)、 多端口同时监听(会改变"端口只有一个主人"这条前提)、HTTP / WebSocket 接口 (会把控制面从"本机文件 ACL 就是边界"变成需要鉴权的服务)。 **排队的三件事**(2026-10-10 提,就是「更多」菜单里的全部三项):网络调试助手(NetAssist 那一类 TCP Server / TCP Client / UDP)、串行数据流图形化(SerialPlot 那一类)、SSH 连接。第一项已经有**占位页**(`更多 → 网络调试助手` 把主窗口切过去,页里那颗"返回串口页"回来;形状能看、协议↔字段会联动,所有会产生网络动作的控件都是禁用的,默认绑定 `127.0.0.1`),设计写在 `serial-share-docs/superpowers/specs/2026-10-10-net-assistant-design.md`——§8 那四个问题已经拍板(同一时刻只有一段活动会话 / TCP Server 一次一个连接 / 默认 `127.0.0.1` / 收到的字节全落盘、先不做白名单)。三条共同的前提是**网络会话也要享受同一套证据**(同一份 JSONL、同一个 `seq`、同两条按方向的字节恒等式),否则装 NetAssist 就够了,不必动这个工具。控制面(让 AI 开网络会话、发一包)在设计里被排在读通之后,因为它要先回答"谁能连进来往我的权威日志里喂字节"。 XCOM 那两页里也有几样**故意没搬**的,理由各不一样:`协议传输`(YMODEM 之类)要的是 一个有状态的文件协议引擎,与"这段会话是什么"的记账是另一个数量级的复杂度; `关联数字键盘`是给手输数值用的快捷键,而这里真正会敲的是复制粘贴; `添加页码/移除此页`那套页面容器——本工具的"页"只是显示切片(见"多条发送"),条目本身 是一个平铺列表,多一层容器只会多一种找法。