Răsfoiți Sursa

docs(spec): D2-02 调试页跨进程借串口设计——会话式借用+通用command分发+MJPEG预览

operate/control 拆分后调试页够不着 control 进程内的 HAL lease,本设计细化
主架构 §8 阶段2 留的「调试借串口」大改面(spec §160/§178 标"未细化"):
- 会话式借用(sessionId)+ 通用 /debug/command(op枚举)分发,非每操作一端点
- 安全地基:租约+心跳+control端超时自动回收,扛 operate 崩溃/丢消息/长时调试
- 红线电机钳位放 control 端;相机实时预览改 MJPEG 流(替代贴窗口句柄)
- 借用边界沿用现状(点初始化借、卸载/返回还);是 D3-04 删死栈的前置

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
huangjie 2 zile în urmă
părinte
comite
d5afcba74a

+ 260 - 0
项目文档/需求文档/specs/2026-06-23-D2-02-调试页命令代理-design.md

@@ -0,0 +1,260 @@
+# D2-02 · operate 调试页跨进程借串口(命令代理 + MJPEG 预览) · 架构设计
+
+> 立项:2026-06-23(经 brainstorming 逐项澄清,关键决策已与用户确认)。
+> 范围:只动 operate 调试链路与 control 本地 HTTP 服务;control 业务逻辑/采集逻辑零改动。
+> 上游依赖:`specs/2026-06-22-operate-control-双进程拆分-design.md`(双进程主架构,本设计是其阶段2「调试借串口」的细化)。
+> 现状基线:`操作端逻辑与配置全景.md`(operate 调试面)+ `control-逻辑与配置全景.md`(HAL/借用闸门)。
+> 本设计是 D3-04(删 operate 死串口栈)的**前置**——见 §11。
+
+---
+
+## 1. 背景与问题
+
+operate/control 双进程拆分后,采集逻辑(串口 ComBin、相机 Camera)整体搬进 control 进程,由 `HardwareAccessLayer`(HAL)单例唯一持有。operate 调试页(工程师调下位机参数用)现在的工作方式是**同进程借用**:
+
+```
+HardwareAccessLayer.Instance.GetHouseGate(houseSn).Acquire(OperateDebug)
+   → 拿到 lease(lease.Serial / lease.Camera = control 进程内的物理句柄)
+   → 连续调 lease.Serial.*Wait() / lease.Camera.* 干十几步
+   → lease.Dispose() 归还
+```
+
+拆分后 `HardwareAccessLayer.Instance` 这个单例**在 control 进程里**,operate 进程根本够不着。所以调试页的全部硬件操作(电机/EEPROM/阀门/LED/读数/抓图/实时预览)都断了,必须改成**跨进程**:operate 经 control 的本地 HTTP 喊话,control 在自己进程内代为执行。
+
+这正是 `2026-06-22` 主设计 §8 阶段2 留的「调试借串口」大改面,spec §160/§178 标注为"未细化"。本文档把它细化到可实现。
+
+---
+
+## 2. 目标与范围
+
+### 2.1 目标
+- operate 调试页在双进程下恢复全部能力:借用某舱 → 连续操作(电机/EEPROM/阀门/LED/读数/抓图)→ 看实时预览 → 归还恢复采集。
+- 跨进程借用要**安全**:operate 崩溃/卡死/消息丢失都不能让某舱采集被永久卡住(地基,见 §5)。
+- 红线电机(水平/垂直 Z)运动范围**在 control 端**强制钳位,operate 端不可信。
+
+### 2.2 范围内
+- control 新增一组 `/debug/*` 本地 HTTP 端点(会话式借用 + 通用命令分发 + MJPEG 预览)。
+- control 新增"调试会话"管理(持 lease、心跳续约、超时自动回收)。
+- operate 新增 `DebugSessionClient`(封装 HTTP)+ `MjpegStreamClient`(预览解码);改造 2 个调试 ViewModel + 调试页 View 的预览方式。
+
+### 2.3 范围外(本轮明确不做)
+- **不加业务护栏**:有活体培养时长时间调试某舱(该舱换气/控温会停),**不加二次确认/警告**——与合并前老系统行为一致,仅靠监控页保证可见性(见 §5.4)。
+- 不动 front、不动 control 采集/对焦/换气/上传业务逻辑。
+- 不动自动对焦(AutoFocus)的借用路径(它在 control 进程内,本就同进程借用,无需跨进程)。
+
+---
+
+## 3. 整体形态:会话式借用 + 通用命令分发
+
+### 3.1 三个关键决策(均有理由,非拍脑袋)
+
+| # | 决策 | 为什么 |
+|---|---|---|
+| 1 | **通用 `command` 端点 + op 枚举**,不做"每操作一个 REST 端点" | ~30 个操作若各开端点 = 端点爆炸,operate/control 两边逐个维护;通用分发只在 control 一处 switch,加操作只加枚举值 |
+| 2 | **有状态会话(sessionId)**,不是每命令重新 Acquire | 调试本质是"借一次、连续干很多步、最后还"(对应现 `ComHouseInit`→一串操作→`ComHouseUnit`);每命令重新 Acquire 会反复暂停/恢复采集、丢掉独占语义 |
+| 3 | **红线电机钳位放 control 端分发器** | control 是唯一硬件持有者、最贴近设备;operate 端钳位可被绕过、不可信 |
+
+### 3.2 整体数据流
+
+```
+operate 调试页                              control 进程
+─────────────                              ──────────────
+[初始化]按钮 → DebugSessionClient
+    POST /debug/acquire {houseSn}  ───────► gate.Acquire(OperateDebug) 拿 lease
+                                            存入会话表,返回 {sessionId}
+                          ◄──────────────── (这一刻 control 暂停该舱采集 MarkPause)
+    每 2~3s 心跳
+    POST /debug/heartbeat {sessionId} ─────► 刷新该会话 TTL
+
+[读温度]/[电机前进]/… 各操作
+    POST /debug/command              ─────► 校验 sessionId → 分发到
+        {sessionId, op, args}               lease.Serial.* / lease.Camera.*
+                          ◄──────────────── (红线 op 先钳位)返回结果
+
+实时预览(开预览时)
+    GET /debug/preview/stream         ────► 抓帧循环:lease 相机 GrabStable→JPEG
+        ?sessionId=                          按 multipart/x-mixed-replace 持续推帧
+                          ◄════════════════ (operate 解码贴 WPF Image)
+
+[卸载]/[返回]按钮
+    POST /debug/release {sessionId}  ─────► lease.Dispose() → 恢复该舱采集
+```
+
+---
+
+## 4. 本地 HTTP 接口契约(新增 `/debug/*`)
+
+挂在现有 `ControlHttpServer`(127.0.0.1:port,`switch(path)` 路由,构造函数注入 handler)上,沿用现有 JSON 风格。
+
+| 方法 | 路径 | 入参(JSON body / query) | 返回 | 用途 |
+|---|---|---|---|---|
+| POST | `/debug/acquire` | `{houseSn}` | `{ok, sessionId, error?}` | 借用某舱(= 现 ComHouseInit 的 Acquire) |
+| POST | `/debug/command` | `{sessionId, op, args{}}` | `{ok, result?, error?, code?}` | 在会话 lease 上执行一个操作 |
+| GET | `/debug/preview/stream` | `?sessionId=`(query) | `multipart/x-mixed-replace`(JPEG 帧流) | 实时预览 |
+| POST | `/debug/heartbeat` | `{sessionId}` | `{ok}` | 续约(防失联误回收) |
+| POST | `/debug/release` | `{sessionId}` | `{ok}` | 归还(= 现 ComHouseUnit),**幂等** |
+
+约定:
+- 全部 `127.0.0.1` only(现 `ControlHttpServer` 已限定 prefix),拒绝外部。
+- `op` 是约定字符串枚举(见 §7);`args` 是该 op 的参数对象(如 `{value:100}`、`{well:3,hor:70800}`)。
+- 错误码 `code`:`SESSION_EXPIRED`(会话失效)、`OUT_OF_RANGE`(电机越界)、`BUSY`(舱被他人占用)、`NO_HANDLE`(本舱无串口/相机句柄)、`HARDWARE_ERROR`(下发失败)。
+- operate 侧复用现有 `HttpHelper`/`HttpService` 基础设施发请求。
+
+---
+
+## 5. 会话生命周期与失效恢复(安全地基)
+
+> 核心原则:**绝不指望 operate 主动归还,control 自己会把借出去的会话收回来。** 主动归还只是"快路径"(立刻恢复采集),真正的安全保证来自 control 端的超时自动回收。
+
+### 5.1 租约 + 心跳 + 自动回收
+- 借用不是"借了就一直算数",而是一份**有期限的租约**。
+- operate 持有会话期间,必须**周期性心跳**(建议每 2~3s 一次 `/debug/heartbeat`)。
+- control 后台有**看门狗线程**,某会话超过 TTL(建议 10s,= 心跳间隔的 3~4 倍,容忍偶发卡顿不误杀)没收到心跳 → 自动 `lease.Dispose()` 归还、恢复该舱采集。
+- **自动回收与正常归还走完全同一条路**(`lease.Dispose → MarkResume → 恢复采集`),恢复逻辑只有一份,不存在两种路径不一致的 bug。
+
+### 5.2 场景一:operate 意外关闭(崩溃/被杀/断电),根本没还
+- operate 进程没了 → 心跳停 → control 看门狗 TTL 后自动归还 → 采集恢复。**采集绝不会被永久卡住。**
+- **更快的信号**:MJPEG 预览是长连接,operate 崩了 TCP 立即断,control 往流里写下一帧时抛异常 → 立即判定会话死亡、立即回收(开了预览时,比等 TTL 更快)。只调电机没开预览时,靠心跳 TTL 兜底。
+
+### 5.3 场景二:operate 还了,但 control 没收到那条归还
+- 走 `127.0.0.1` 本机回环,几乎不丢包,极罕见;但设计照样扛:
+  1. **release 幂等 + operate 可重试**:operate 发了 release 没收到回执就再发;control 对"已还/不认识的 sessionId"的 release 直接回 ok。
+  2. **就算 operate 彻底放弃**,心跳也停了 → 看门狗 TTL 后照样自动回收。**最终采集一定恢复。**
+
+### 5.4 长时间调试(工程师调几小时)
+- **TTL 惩罚的是"失联",不是"操作时间长"**。只要 operate 活着、心跳在续,会话一直续约——调几小时、一整天都**绝不会**被超时回收。
+- 长时调试真正的副作用是:**该舱采集让路几小时**(拍照/对焦/换气停)。这是调试功能**固有语义**(合并前老系统、合并后单进程都如此,`Acquire(OperateDebug)` 本就触发该舱暂停),双进程只是把它跨进程化、行为一致。
+- 不加护栏(§2.3 决策);靠监控页(阶段2 /status 补"串口借用/占用状态")显示如「舱5:调试中(工程师已借用 2小时15分)」,让旁人不误判该舱"坏了"。
+
+### 5.5 失效后 operate 又发命令(避免抢串口)
+- operate 没死、只是卡过头(超 TTL),control 已回收并恢复采集,operate 缓过来又发 command:
+  - 每条 command 带 sessionId,control 一看已失效 → 回 `code=SESSION_EXPIRED`。
+  - operate 收到 → 弹"调试会话已超时,请重新进入调试" → 必须重新 acquire。
+- 失效之后 operate 任何操作都被挡,**不可能与采集抢同一串口**。
+
+### 5.6 借用边界(何时 acquire / 何时 release)
+现状(`HouseDebugPageView.xaml.cs`)的触发点,跨进程后一字不改:
+
+| 工程师动作 | 现状 | 跨进程后 |
+|---|---|---|
+| 进调试页、选舱、选CCD | `House_Checked`/`CCD_Checked` 只记 `CurrentHouseId`,**不借** | 不变,**不 acquire** |
+| 点【初始化】 | `Start_Click → ComHouseInit()` = Acquire | `acquire` 拿 sessionId + 起心跳 + 连 MJPEG |
+| 各操作按钮 | 各 `_Click → vm.Xxx()` | `command` |
+| 点【卸载】 | `End_Click → ComHouseUnit()` | `release`(停心跳/停流/恢复采集) |
+| 点【返回】 | `Return_Click → CloseVideo + ComHouseUnit()` | `release` |
+
+- **借用从点【初始化】开始**,不是打开页面或选舱时。选舱阶段没碰硬件、没暂停采集。
+- **换舱必须先【卸载】再重选**(初始化后选舱按钮被禁用)→ 天生不会同时占两个舱(先还 A 才能借 B)。
+
+---
+
+## 6. ComHouseInit 的"一串初始化"怎么跨进程
+
+现 `ComHouseInit` 借到 lease 后,在同进程里连续做:握手 → 开灯 → 水平复位 → 移到 well1 → 垂直复位 → 移到清晰位 → 读温压门换气时间。跨进程后**不是把这一串拆成 N 个 command 来回 N 趟**(慢且易被打断),两种落地都可接受、实现期定:
+
+- **方案A(推荐)**:control 端 `acquire` 成功后,在持锁的会话里**就地跑完这串初始化序列**(control 内一个 `DebugInit(houseSn, 初始化所需配置)`),把读到的温压门/电机位等结果随 acquire 响应一并返回。operate 一次拿齐。
+- 方案B:acquire 只借用,operate 再发一个 `op=Init` 的 command 触发这串序列。
+
+两者都把"连续独占的初始化"放在 control 一侧一次跑完,差别只是触发位置。初始化需要的配置(well 水平位、清晰位、motorDelay 等)现在来自 operate 侧服务器拉取的 `tLSetting`/`houseWellSettingList`/`ccdPhoto` ——这些**继续由 operate 持有**,acquire/command 时作为 args 传给 control(control 不连这套配置服务)。
+
+---
+
+## 7. 操作清单(`/debug/command` 的 op 集合)
+
+### 7.1 A 类:舱调试(`HouseDebugPageViewModel`,舱1-10,串口+相机)
+| 分组 | op | args | 底层(lease) |
+|---|---|---|---|
+| 读数 | `ReadTemp` / `ReadPressure` / `ReadDoor` / `ReadVentTime` | — | `Serial.TemperatureWait()` 等 |
+| 握手 | `ShakeHands` | — | `Serial.ShakeHandsWait()` |
+| 电机(红线·钳位) | `HorizontalReset` / `HorizontalForward` / `HorizontalBackward` / `HorizontalMoveTo` / `VerticalReset` / `VerticalForward` / `VerticalBackward` / `VerticalMoveTo` | `{value}` 或 `{pos}` + `motorDelay` | `Serial.Horizontal*Wait` / `Vertical*Wait` |
+| EEPROM 写 | `WriteOpenIntakeTime` / `WriteOpenVentTime` / `WriteScanStep` / `WriteWellHorizontalPos` | `{value}` / `{well,hor}` | `Serial.Write*Wait` |
+| 阀/LED/换气 | `OpenLed`/`CloseLed` / `OpenIntake`/`CloseIntake` / `OpenExhaust`/`CloseExhaust` / `HouseAeration` / `HouseVent` | — | `Serial.*Wait` |
+| 相机单帧抓图链 | `CameraInit` / `SetOpMode` / `GrabRaw` / `RawToRgb` / `SavePic` | `{fullName,width,height}` | `Camera.*` |
+
+### 7.2 B 类:缓冲瓶调试(`BufferDebugViewModel`,舱11,仅串口无相机)
+读温度1/2 + 缓冲瓶压力 + 读灯亮度;写EEPROM进气时间 / 写灯亮度 / 补气 / 排气 / 缓冲瓶状态。(op 命名与 A 类同风格,实现期按 `BufferDebugViewModel` 现有方法逐一登记。)
+
+> 注:`SavePic` 落盘路径在哪个进程——抓图由 control 相机执行,**图片落在 control 进程能写的盘**;operate 调试页若需展示/取图,经预览流看或后续约定取图端点。实现期确认调试页对抓拍图的实际用法(多为现场看,不强依赖落盘位置)。
+
+---
+
+## 8. operate 改造面
+
+| 文件 | 改动 |
+|---|---|
+| `ViewModel/HouseDebugPageViewModel.cs` | 删 `HardwareAccessLayer.Instance.GetHouseGate().Acquire` + `lease.Serial/Camera` 直调;改为持有 `DebugSessionClient`,各方法体换成 `client.Command(op,args)`。**OperationLogger.Run 埋点留在 operate 侧不变**(谁点谁记) |
+| `ViewModel/BufferDebugViewModel.cs` | 同上(缓冲瓶 op) |
+| `View/HouseDebugPageView.xaml.cs` | `OpenVideo/CloseVideo` 从 `StartPreview(贴窗口句柄)` 换成连/断 `MjpegStreamClient`;`Start_Click/End_Click/Return_Click` 改调 client.Acquire/Release |
+| `View/BufferDebugView.xaml.cs` | 同上(无预览部分) |
+| `ViewModel/DebugCalibrationAdapter.cs` | 标定结果 UI 数据模型,不碰硬件——**仅当它引用了将删的死栈类型时跟着换类型**,逻辑不动 |
+| 新增 `DebugSessionClient` | 封 acquire/command/release/heartbeat 的 HTTP + 心跳定时器 + 会话失效回调(弹"请重新进入调试") |
+| 新增 `MjpegStreamClient` | `HttpWebRequest` 读 `multipart/x-mixed-replace`,按 boundary 切帧,每帧 decode 成 `BitmapImage` 绑 WPF `Image`;断开/异常自停 |
+
+**完成后,operate 调试链路不再编译期引用 ComBin/Camera 死栈 → D3-04 解锁可删(见 §11)。**
+
+---
+
+## 9. MJPEG 实时预览
+
+- **control 端** `GET /debug/preview/stream?sessionId=`:校验会话有效 → 起一个抓帧循环线程,在该会话 lease 的相机上设实时/单帧模式 → 循环 `GrabStable()` → JPEG 编码 → 按 `multipart/x-mixed-replace; boundary=...` 持续写帧(`--boundary\r\nContent-Type: image/jpeg\r\nContent-Length: N\r\n\r\n<bytes>\r\n`)。
+- **抓帧推流与 `SavePic` 抓图同用一台相机**,靠现有 `ICameraGate`(全进程相机锁)串行化:不冲突,偶尔互等可接受(人手操作不密集)。
+- **停止**:operate 关预览/崩溃 → 写流抛异常 → 循环自停 + 该会话标记可回收(§5.2 快信号)。
+- **operate 端** `MjpegStreamClient`:读 response stream → 状态机切 boundary → 每段 JPEG `BitmapImage` 解码 → 贴 WPF `Image`。替代 `StartPreview/StopPreview` 整套贴窗口句柄逻辑。
+- 帧率:目标"人眼看清画面调焦",非高帧率视频;受 JPEG 编码 + 相机锁限制,几~十几 fps 足够。
+
+---
+
+## 10. 红线电机安全钳位
+
+- 钳位**在 control 端 command 分发器**对 `Horizontal*`/`Vertical*MoveTo`/`Forward`/`Backward` 的目标绝对位置生效:
+  - 水平钳 `[0, 220000]`(实测 well 位 70800~205800)。
+  - 垂直 Z 钳 `[0, 125000]`(实测焦面 86000~92000)。
+- 越界 → **拒绝执行**,返回 `code=OUT_OF_RANGE` + 记日志,不下发下位机。
+- 相对运动(Forward/Backward 加减量)按"当前位置 + 增量"算出目标位再钳位。当前位置由 control 端会话状态跟踪(acquire 初始化序列里已知起点)。
+
+---
+
+## 11. 与 D3-04 的依赖关系
+
+D3-04 = 删 operate 端已死的串口/相机栈(`ivf_tl_Entity/ComEntitys/*`、`CameraEntitys/*`)。grep 坐实这套死栈被调试页 ViewModel **编译期引用**——拆分后运行期已死(operate HAL 空单例),但类型还被引用,删了就编译失败。
+
+**本设计(D2-02)做完 → 调试页改走 `DebugSessionClient`,不再引用死栈类型 → D3-04 才能安全删。** 故 D3-04 排在 D2-02 之后(任务依赖已在进度文档登记)。
+
+---
+
+## 12. 测试策略
+
+### 12.1 纯逻辑单测(无需真机)
+- 命令分发表:每个 op 路由到正确的 lease 方法(可用假 lease/ISerialChannel mock 验证)。
+- **红线钳位边界**:越界拒绝、边界值放行、相对运动算目标位后钳位。
+- **会话超时自动回收**:注入假 lease + 可控时钟,验证 TTL 到点触发 Dispose、恢复采集;心跳续约不被回收;release 幂等。
+- MJPEG 帧切分:给定 multipart 字节流,client 状态机正确切出每帧。
+
+### 12.2 真机门控(= 主设计 V-012,由 Claude 自主真机跑,UAC 静默提权)
+- 借串口让路时序:acquire → 该舱采集暂停 → 操作 → release → 采集恢复(不死锁/不双占)。
+- **电机真机运动(红线两轴,守安全区间)**:水平/垂直 reset/move/forward/backward 真机走位,越界被拒。
+- MJPEG 真机出图:预览流能在 operate 看到该舱实时画面。
+- **崩溃自动回收**:杀掉 operate(或断预览)→ control 看门狗/快信号回收 → 该舱采集恢复(/status 反映)。
+- EEPROM 写:复用已入库 HIL 套件(`IvfTl.Hardware.HilTests`,默认零写入、开关才写)守护帧长/地址。
+
+---
+
+## 13. 风险与注意
+
+1. **MJPEG 长连接稳定性**:开预览几小时,HttpListener 长连接 + 抓帧循环要稳;确保不被某些默认超时掐断,异常要能让会话进入可回收态而非泄漏。
+2. **相机锁争用**:预览推流持续占相机锁,期间 `SavePic` 抓图会等;若体感卡顿,实现期可在抓图时临时降预览帧率。先按"串行+可接受"做,真机看表现再优化。
+3. **会话状态(当前电机位)跨进程跟踪**:相对运动钳位依赖 control 端记的"当前位置";acquire 初始化序列要把起点设准,异常路径(运动失败)要同步更新或标记位置未知。
+4. **配置来源**:初始化序列需要的 well 水平位/清晰位/motorDelay 由 operate 持有(连服务器拉取),经请求传给 control;control 不连这套配置服务。
+5. **不动 control 业务逻辑**:本设计只加 HTTP 端点 + 会话管理 + operate 客户端;采集/对焦/换气/上传零改动,降低回归风险。
+
+---
+
+## 14. 分步落地建议(实现计划在 writing-plans 阶段细化)
+
+1. control 端会话管理(会话表 + 心跳 TTL 看门狗 + 自动回收)+ 纯逻辑单测。
+2. control 端 `/debug/acquire|command|release|heartbeat` + 命令分发表 + 红线钳位 + 单测。
+3. control 端 `/debug/preview/stream` MJPEG 推流。
+4. operate 端 `DebugSessionClient` + `MjpegStreamClient`。
+5. operate 两个调试 VM + 两个 View 改造接入 client。
+6. 真机门控验证(V-012,Claude 自主跑,电机守区间)。
+7. (解锁后,属 D3-04)删 operate 死串口/相机栈。