Explorar el Código

docs(d2-02): 第三阶段实现计划落盘——9任务bite-sized(control缓冲瓶op+培养态回带+装配/operate DebugSessionClient+两VM两View接入/全量编译单测/真机V-012),含完整代码与TDD步骤

huangjie hace 3 días
padre
commit
2a834a199f

+ 905 - 0
项目文档/开发计划/2026-06-24-D2-02-第三阶段-operate调试页接入-实现计划.md

@@ -0,0 +1,905 @@
+# D2-02 第三阶段 · operate 调试页接入 实现计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: 用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现。步骤用 `- [ ]` 复选框跟踪。
+> 上游设计:`需求文档/specs/2026-06-24-D2-02-第三阶段-operate调试页接入-design.md`(本计划逐任务落地它)。
+> 现状基线已读源码核实(见设计文档头);control 后端 `/debug/*` + 命令分发 + MJPEG 推流第一/二阶段已并 main。
+
+**Goal:** operate 调试页两个 ViewModel + 两个 View 从同进程直调硬件改为经 control 本地 HTTP 跨进程操作,补 control 缺的缓冲瓶 op 与培养态回带,完成后真机 V-012 验证。
+
+**Architecture:** operate 新增 `DebugSessionClient`(封 acquire/command/release/heartbeat + 心跳定时器 + 失效回调);两个调试 VM 把 `Serial.XxxWait()`/`Cam.*` 直调换成 `client.Command(op,args)`;两个 View 的初始化/卸载/返回走 acquire/release。control 端 `DebugSessionManager.Execute` 补缓冲瓶 op,`Acquire` 回带培养态(读 `HouseBin.Dish`)。抓图链本阶段不接(按钮置灰)。
+
+**Tech Stack:** C# .NET 6 (net6.0-windows)、WPF、xUnit、Newtonsoft.Json;control 端 `IvfTl.ControlHost` / `IvfTl.ControlHost.Tests`,operate 端 `ivf_tl_Operate`。
+
+---
+
+## 文件结构(改/建一览)
+
+**control 端**
+- 修改 `ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/DebugSessionManager.cs` — `Execute` 补缓冲瓶 op;`Acquire` 回带培养态(经注入的 `_cultivationOf`)。
+- 修改 `ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/DebugCommandResult.cs` 或新增 acquire 返回体 — 承载 `sessionId + cultivating + embryoCount`。
+- 修改 `ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Program.cs` — 装配 `DebugSessionManager` 时注入 `_cultivationOf` 委托(读 `AppData` 舱培养态)。
+- 修改 `ivf_tl_operate_2.0/control/ivf_tl_Control/AppData.cs` — 加只读 `GetCultivation(int houseSn) → (bool cultivating, int embryoCount)`,内部按 houseSn 选 `HouseBin1..10`/`BufferBottleBin` 读 `Dish`。
+- 测试 `ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/DebugBufferOpTests.cs`(新)、`DebugAcquireCultivationTests.cs`(新)。
+
+**operate 端**
+- 新增 `ivf_tl_operate_2.0/ivf_tl_Operate/Debug/DebugSessionClient.cs` — HTTP 客户端 + 心跳 + 失效回调。
+- 新增 `ivf_tl_operate_2.0/ivf_tl_Operate/Debug/AcquireResult.cs` — acquire 响应 DTO(ok/sessionId/cultivating/embryoCount/error/code)。
+- 修改 `ivf_tl_operate_2.0/ivf_tl_Operate/ViewModel/HouseDebugPageViewModel.cs` — 直调换 client。
+- 修改 `ivf_tl_operate_2.0/ivf_tl_Operate/ViewModel/BufferDebugViewModel.cs` — 直调换 client。
+- 修改 `ivf_tl_operate_2.0/ivf_tl_Operate/View/HouseDebugPageView.xaml.cs` + `.xaml` — acquire/release/喂 sid + 抓图按钮置灰。
+- 修改 `ivf_tl_operate_2.0/ivf_tl_Operate/View/BufferDebugView.xaml.cs` — acquire/release。
+- 测试:operate 端单测工程(若无则在 control 测试方案同款新建轻量 harness;DebugSessionClient 用可注入 HttpMessageHandler)。
+
+**单测工程归属**:control 端单测进 `IvfTl.ControlHost.Tests`(已存在,net6.0)。`DebugSessionClient` 是纯逻辑(HTTP 拼装/心跳/回调),放可独立编译的测试工程;若 operate 无现成 xUnit 工程,按现有 `临时文件/` harness 模式(Compile Include 真实源码)验证,与 H-08 同粒度。
+
+---
+
+## Task 1: control 端补缓冲瓶命令分发(纯逻辑 TDD)
+
+**Files:**
+- Modify: `ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/DebugSessionManager.cs`(`Execute` switch + `ExecuteMotorOrEeprom` 之外或之内补缓冲瓶分支)
+- Test: `ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/DebugBufferOpTests.cs`(新)
+
+底层方法签名(已核实 `ISerialChannel`):`(decimal pressure, decimal t1, decimal t2) BufferBottleStateWait()`、`bool BufferBottleAerationWait()`、`int ReadLightBrightnessWait()`、`bool WriteLightBrightnessWait(int)`、`bool WriteOpenIntakeTimeWait(int, bool isBuffer=false)`。测试用既有 `Fakes/FakeHardware.cs` 的 `FakeSerial`/`FakeLease`/`FakeGate`(已存在,见 DebugStreamSessionTests 引用)。
+
+- [ ] **Step 1: 写失败测试**
+
+```csharp
+// DebugBufferOpTests.cs
+using IvfTl.ControlHost.Debug;
+using IvfTl.ControlHost.Tests.Fakes;   // 与 DebugStreamSessionTests 同命名空间路径,核实实际 using
+using Newtonsoft.Json.Linq;
+using Xunit;
+
+public class DebugBufferOpTests
+{
+    // 复用现有测试构造法:照抄 DebugStreamSessionTests 里 new DebugSessionManager(...) 的 fake 装配方式
+    private static (DebugSessionManager mgr, string sid, FakeSerial ser) NewAcquired()
+    {
+        var ser = new FakeSerial();
+        var gate = new FakeGate(new FakeLease(ser, camera: null));
+        var mgr = new DebugSessionManager(_ => gate, () => System.DateTime.UtcNow, ttlMs: 10000, log: null);
+        var r = mgr.Acquire(11);
+        Assert.True(r.Ok);
+        return (mgr, (string)r.Result, ser);   // Acquire 现返回 Okay(sid);若 Task5 改成对象,这里取 sid 字段
+    }
+
+    [Fact]
+    public void BufferState_RoutesToBufferBottleStateWait()
+    {
+        var (mgr, sid, ser) = NewAcquired();
+        ser.BufferStateReturn = (5.5m, 36.4m, 36.6m);
+        var r = mgr.Execute(sid, "BufferState", null);
+        Assert.True(r.Ok);
+        Assert.Equal(1, ser.BufferStateCalls);
+    }
+
+    [Fact]
+    public void BufferAeration_RoutesToBufferBottleAerationWait()
+    {
+        var (mgr, sid, ser) = NewAcquired();
+        var r = mgr.Execute(sid, "BufferAeration", null);
+        Assert.True(r.Ok);
+        Assert.Equal(1, ser.BufferAerationCalls);
+    }
+
+    [Fact]
+    public void ReadLight_RoutesToReadLightBrightnessWait()
+    {
+        var (mgr, sid, ser) = NewAcquired();
+        ser.LightBrightnessReturn = 80;
+        var r = mgr.Execute(sid, "ReadLight", null);
+        Assert.True(r.Ok);
+        Assert.Equal(80, (int)r.Result);
+    }
+
+    [Fact]
+    public void WriteLight_PassesValue()
+    {
+        var (mgr, sid, ser) = NewAcquired();
+        var args = JObject.Parse("{\"value\":66}");
+        var r = mgr.Execute(sid, "WriteLight", args);
+        Assert.True(r.Ok);
+        Assert.Equal(66, ser.LastWriteLight);
+    }
+
+    [Fact]
+    public void WriteOpenIntakeTimeBuffer_PassesValueAndIsBufferTrue()
+    {
+        var (mgr, sid, ser) = NewAcquired();
+        var args = JObject.Parse("{\"value\":120}");
+        var r = mgr.Execute(sid, "WriteOpenIntakeTimeBuffer", args);
+        Assert.True(r.Ok);
+        Assert.Equal(120, ser.LastWriteIntake);
+        Assert.True(ser.LastWriteIntakeIsBuffer);
+    }
+}
+```
+
+> 若 `FakeSerial` 缺这些计数/返回字段,先在 `Fakes/FakeHardware.cs` 给 `FakeSerial` 补上对应实现(`BufferStateReturn`/`BufferStateCalls`/`BufferAerationCalls`/`LightBrightnessReturn`/`LastWriteLight`/`LastWriteIntake`/`LastWriteIntakeIsBuffer`),这是测试替身扩展,不碰生产逻辑。
+
+- [ ] **Step 2: 运行测试确认失败**
+
+Run: `dotnet test ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/IvfTl.ControlHost.Tests.csproj --filter DebugBufferOpTests`
+Expected: FAIL(`WriteOpenIntakeTimeBuffer`/`BufferState` 等 op 走 default 返回 `BAD_OP`,断言失败)
+
+- [ ] **Step 3: 在 Execute 补缓冲瓶 op 分支**
+
+在 `DebugSessionManager.Execute` 的 switch 内(`HouseVent` 之后、`default` 之前)加:
+
+```csharp
+                    case "BufferState":
+                    {
+                        var (pressure, t1, t2) = ser.BufferBottleStateWait();
+                        return DebugCommandResult.Okay(new { pressure, t1, t2 });
+                    }
+                    case "BufferAeration": return DebugCommandResult.Okay(ser.BufferBottleAerationWait());
+                    case "ReadLight": return DebugCommandResult.Okay(ser.ReadLightBrightnessWait());
+                    case "WriteLight":
+                    {
+                        int v = args?["value"] != null ? args["value"].Value<int>() : 0;
+                        return DebugCommandResult.Okay(ser.WriteLightBrightnessWait(v));
+                    }
+                    case "WriteOpenIntakeTimeBuffer":
+                    {
+                        int v = args?["value"] != null ? args["value"].Value<int>() : 0;
+                        return DebugCommandResult.Okay(ser.WriteOpenIntakeTimeWait(v, isBuffer: true));
+                    }
+```
+
+- [ ] **Step 4: 运行测试确认通过**
+
+Run: `dotnet test ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/IvfTl.ControlHost.Tests.csproj --filter DebugBufferOpTests`
+Expected: PASS(5 测试全绿)
+
+- [ ] **Step 5: 提交**
+
+```bash
+git add ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/DebugSessionManager.cs ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/DebugBufferOpTests.cs ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/Fakes/FakeHardware.cs
+git commit -m "feat(d2-02-t3): control 补缓冲瓶 op(BufferState/Aeration/ReadLight/WriteLight/WriteOpenIntakeTimeBuffer)+5单测"
+```
+
+---
+
+## Task 2: control 端 acquire 回带培养态(纯逻辑 TDD)
+
+**Files:**
+- Modify: `ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/DebugSessionManager.cs`(构造加 `Func<int,(bool,int)> cultivationOf`;`Acquire` 返回带 cultivating/embryoCount)
+- Modify: `ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/DebugCommandResult.cs`(加可选字段)或新增 `AcquireDto`
+- Test: `ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/DebugAcquireCultivationTests.cs`(新)
+
+设计取舍:`Acquire` 现返回 `DebugCommandResult.Okay(sid)`(Result=sid 字符串)。为了不破坏既有 `sessionId` 取法,在 `DebugCommandResult` 加两个可忽略空值的字段 `Cultivating`/`EmbryoCount`,`Acquire` 成功时填充;`Result` 仍放 sid(operate 端照常 `r.Result` 取 sid)。
+
+- [ ] **Step 1: 写失败测试**
+
+```csharp
+// DebugAcquireCultivationTests.cs
+using IvfTl.ControlHost.Debug;
+using IvfTl.ControlHost.Tests.Fakes;
+using Xunit;
+
+public class DebugAcquireCultivationTests
+{
+    private static DebugSessionManager NewMgr(System.Func<int,(bool,int)> cultiv)
+    {
+        var gate = new FakeGate(new FakeLease(new FakeSerial(), camera: null));
+        return new DebugSessionManager(_ => gate, () => System.DateTime.UtcNow, 10000, null, cultiv);
+    }
+
+    [Fact]
+    public void Acquire_CultivatingHouse_ReturnsTrueAndCount()
+    {
+        var mgr = NewMgr(sn => (true, 3));
+        var r = mgr.Acquire(6);
+        Assert.True(r.Ok);
+        Assert.True(r.Cultivating);
+        Assert.Equal(3, r.EmbryoCount);
+    }
+
+    [Fact]
+    public void Acquire_EmptyHouse_ReturnsFalseAndZero()
+    {
+        var mgr = NewMgr(sn => (false, 0));
+        var r = mgr.Acquire(6);
+        Assert.True(r.Ok);
+        Assert.False(r.Cultivating);
+        Assert.Equal(0, r.EmbryoCount);
+    }
+
+    [Fact]
+    public void Acquire_NullCultivationProvider_DefaultsFalse()
+    {
+        var gate = new FakeGate(new FakeLease(new FakeSerial(), camera: null));
+        var mgr = new DebugSessionManager(_ => gate, () => System.DateTime.UtcNow, 10000, null, cultivationOf: null);
+        var r = mgr.Acquire(6);
+        Assert.True(r.Ok);
+        Assert.False(r.Cultivating);   // 无 provider 不崩,默认 false/0
+    }
+}
+```
+
+- [ ] **Step 2: 运行确认失败**
+
+Run: `dotnet test .../IvfTl.ControlHost.Tests.csproj --filter DebugAcquireCultivationTests`
+Expected: FAIL(`DebugCommandResult` 无 `Cultivating`/`EmbryoCount`;构造无第 5 参 → 编译错)
+
+- [ ] **Step 3: 加字段 + 构造参数 + Acquire 填充**
+
+`DebugCommandResult.cs` 加:
+
+```csharp
+        [JsonProperty("cultivating", NullValueHandling = NullValueHandling.Ignore)] public bool Cultivating { get; set; }
+        [JsonProperty("embryoCount", NullValueHandling = NullValueHandling.Ignore)] public int EmbryoCount { get; set; }
+```
+
+`DebugSessionManager` 构造加可选第 5 参并存字段:
+
+```csharp
+        private readonly Func<int, (bool cultivating, int embryoCount)> _cultivationOf;
+        public DebugSessionManager(Func<int, IHouseGate> gateOf, Func<DateTime> clock, int ttlMs, Action<string> log,
+                                   Func<int, (bool, int)> cultivationOf = null)
+        {
+            _gateOf = gateOf; _clock = clock; _ttlMs = ttlMs; _log = log ?? (_ => { });
+            _cultivationOf = cultivationOf;
+        }
+```
+
+`Acquire` 成功分支(`return DebugCommandResult.Okay(sid);` 前)改为:
+
+```csharp
+            var res = DebugCommandResult.Okay(sid);
+            try
+            {
+                if (_cultivationOf != null) { var c = _cultivationOf(houseSn); res.Cultivating = c.Item1; res.EmbryoCount = c.Item2; }
+            }
+            catch (Exception ex) { _log($"[debug] 取培养态异常 舱{houseSn}: {ex.Message}"); }
+            _log($"[debug] acquire 舱{houseSn} sid={sid}");
+            return res;
+```
+
+- [ ] **Step 4: 运行确认通过**
+
+Run: `dotnet test .../IvfTl.ControlHost.Tests.csproj --filter DebugAcquireCultivationTests`
+Expected: PASS;并跑全量 `dotnet test .../IvfTl.ControlHost.Tests.csproj` 确认旧测试(含 Task1)不回归。
+
+- [ ] **Step 5: 提交**
+
+```bash
+git add ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/DebugSessionManager.cs ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/DebugCommandResult.cs ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/DebugAcquireCultivationTests.cs
+git commit -m "feat(d2-02-t3): acquire 回带培养态(cultivating+embryoCount,注入式不依赖业务类)+3单测"
+```
+
+---
+
+## Task 3: control 端装配培养态来源(AppData helper + Program 注入)
+
+**Files:**
+- Modify: `ivf_tl_operate_2.0/control/ivf_tl_Control/AppData.cs`(加 `GetCultivation`)
+- Modify: `ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Program.cs`(装配 `DebugSessionManager` 时传入 `AppData.Instance.GetCultivation`)
+
+> 这是装配/集成步,无独立单测(逻辑已在 Task2 用注入委托覆盖);验证靠编译 + Task7 真机。`GetCultivation` 只读现成 `HouseBin.Dish`,不改采集逻辑。
+
+- [ ] **Step 1: AppData 加只读 helper**
+
+`AppData.cs` 在类中新增(`HouseBin1..10`/`BufferBottleBin` 字段已存在):
+
+```csharp
+        /// <summary>D2-02 第三阶段:按 houseSn 读该舱培养态(Dish!=null=在培养),供调试 acquire 提示。只读,不改采集。</summary>
+        public (bool cultivating, int embryoCount) GetCultivation(int houseSn)
+        {
+            try
+            {
+                var bin = houseSn switch
+                {
+                    1 => HouseBin1, 2 => HouseBin2, 3 => HouseBin3, 4 => HouseBin4, 5 => HouseBin5,
+                    6 => HouseBin6, 7 => HouseBin7, 8 => HouseBin8, 9 => HouseBin9, 10 => HouseBin10,
+                    _ => null
+                };
+                var dish = bin?.Dish;                       // 缓冲瓶舱11无 HouseBin.Dish → 视为不培养
+                if (dish == null) return (false, 0);
+                int count = dish.embryoCount;               // Dish 实体字段名以实际为准(见下注)
+                return (true, count > 0 ? count : 0);
+            }
+            catch { return (false, 0); }
+        }
+```
+
+> ⚠ 实现期核实两点(读源码,勿猜):① `HouseBin1..10`/`BufferBottleBin` 在 `AppData` 的确切可见性(若为属性/字段名不同照实改);② `Dish` 实体取胚胎数的字段名(可能是 `embryoCount` 或需从 wellSn 列表 Count;查 `ivf_tl_Entity` 的 Dish 定义)。取不到数就回 `(true,0)`——提示仍显示"在培养",只是不报枚数,符合设计降级。
+
+- [ ] **Step 2: Program.cs 注入**
+
+`Program.Main` 里 `new DebugSessionManager(...)` 调用补第 5 参:
+
+```csharp
+                var debugMgr = new IvfTl.ControlHost.Debug.DebugSessionManager(
+                    houseSn => IvfTl.Hardware.Impl.HardwareAccessLayer.Instance.GetHouseGate(houseSn),
+                    () => DateTime.UtcNow,
+                    ttlMs: 10000,
+                    log: msg => Log4netHelper.WriteLog(msg),
+                    cultivationOf: houseSn => { try { return ivf_tl_Control.AppData.Instance.GetCultivation(houseSn); } catch { return (false, 0); } });
+```
+
+> 核实 Program.cs 是否已 `using ivf_tl_Control;`(头部第6行已 import)→ 可直接 `AppData.Instance`。
+
+- [ ] **Step 3: Release 编译 control**
+
+Run: `dotnet build ivf_tl_operate_2.0/control/ivf_tl_ControlHost/ivf_tl_ControlHost.csproj -c Release`
+Expected: 0 错(若 control 实例在跑锁 DLL 报 MSB3021,先 `/shutdown` token tl13579 优雅停再编)
+
+- [ ] **Step 4: 提交**
+
+```bash
+git add ivf_tl_operate_2.0/control/ivf_tl_Control/AppData.cs ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Program.cs
+git commit -m "feat(d2-02-t3): 装配培养态来源——AppData.GetCultivation只读HouseBin.Dish+Program注入DebugSessionManager"
+```
+
+---
+
+## Task 4: operate 端 DebugSessionClient(纯逻辑 TDD)
+
+**Files:**
+- Create: `ivf_tl_operate_2.0/ivf_tl_Operate/Debug/AcquireResult.cs`
+- Create: `ivf_tl_operate_2.0/ivf_tl_Operate/Debug/DebugSessionClient.cs`
+- Test: operate 端单测工程的 `DebugSessionClientTests.cs`(若无 xUnit 工程,见"单测工程归属")
+
+`DebugSessionClient` 用可注入的 `HttpClient`(传入或工厂)以便用 fake `HttpMessageHandler` 单测;心跳定时器用可注入间隔;失效回调是 `Action`。
+
+- [ ] **Step 1: 写失败测试(用 fake handler)**
+
+```csharp
+// DebugSessionClientTests.cs
+using System.Net;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using ivf_tl_Operate.Debug;
+using Xunit;
+
+public class DebugSessionClientTests
+{
+    // 简易 fake handler:按请求路径返回预设 JSON
+    class FakeHandler : HttpMessageHandler
+    {
+        public System.Func<HttpRequestMessage, (HttpStatusCode, string)> Responder;
+        public int HeartbeatCount;
+        protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage req, CancellationToken ct)
+        {
+            if (req.RequestUri.AbsolutePath.Contains("heartbeat")) Interlocked.Increment(ref HeartbeatCount);
+            var (code, body) = Responder(req);
+            return Task.FromResult(new HttpResponseMessage(code) { Content = new StringContent(body) });
+        }
+    }
+
+    [Fact]
+    public async Task Acquire_ParsesSessionIdAndCultivation()
+    {
+        var h = new FakeHandler { Responder = _ => (HttpStatusCode.OK, "{\"ok\":true,\"result\":\"sid123\",\"cultivating\":true,\"embryoCount\":3}") };
+        var c = new DebugSessionClient("http://127.0.0.1:9/", new HttpClient(h));
+        var r = await c.AcquireAsync(6);
+        Assert.True(r.Ok);
+        Assert.Equal("sid123", r.SessionId);
+        Assert.True(r.Cultivating);
+        Assert.Equal(3, r.EmbryoCount);
+    }
+
+    [Fact]
+    public async Task Command_SessionExpired_FiresOnSessionExpired()
+    {
+        bool fired = false;
+        var h = new FakeHandler { Responder = req =>
+            req.RequestUri.AbsolutePath.Contains("command")
+              ? (HttpStatusCode.Gone, "{\"ok\":false,\"code\":\"SESSION_EXPIRED\",\"error\":\"过期\"}")
+              : (HttpStatusCode.OK, "{\"ok\":true,\"result\":\"sid123\"}") };
+        var c = new DebugSessionClient("http://127.0.0.1:9/", new HttpClient(h));
+        c.OnSessionExpired = () => fired = true;
+        await c.AcquireAsync(6);
+        var r = await c.CommandAsync("ReadTemp", null);
+        Assert.False(r.Ok);
+        Assert.Equal("SESSION_EXPIRED", r.Code);
+        Assert.True(fired);
+    }
+
+    [Fact]
+    public async Task Release_IsIdempotent_NoThrowWhenCalledTwice()
+    {
+        var h = new FakeHandler { Responder = _ => (HttpStatusCode.OK, "{\"ok\":true}") };
+        var c = new DebugSessionClient("http://127.0.0.1:9/", new HttpClient(h));
+        await c.AcquireAsync(6);
+        await c.ReleaseAsync();
+        await c.ReleaseAsync();   // 第二次不抛
+    }
+
+    [Fact]
+    public async Task Heartbeat_TimerSendsAfterAcquire()
+    {
+        var h = new FakeHandler { Responder = _ => (HttpStatusCode.OK, "{\"ok\":true,\"result\":\"sid123\"}") };
+        var c = new DebugSessionClient("http://127.0.0.1:9/", new HttpClient(h)) { HeartbeatIntervalMs = 50 };
+        await c.AcquireAsync(6);
+        await Task.Delay(180);
+        await c.ReleaseAsync();
+        Assert.True(h.HeartbeatCount >= 2);   // 50ms 间隔 180ms 内至少 2 次
+    }
+}
+```
+
+- [ ] **Step 2: 运行确认失败**
+
+Run: operate 单测工程 `dotnet test --filter DebugSessionClientTests`
+Expected: FAIL(`DebugSessionClient`/`AcquireResult` 未定义)
+
+- [ ] **Step 3: 实现 AcquireResult + DebugSessionClient**
+
+`AcquireResult.cs`:
+
+```csharp
+using Newtonsoft.Json;
+namespace ivf_tl_Operate.Debug
+{
+    /// <summary>acquire/command 统一响应 DTO(��齐 control DebugCommandResult)。</summary>
+    public class AcquireResult
+    {
+        [JsonProperty("ok")] public bool Ok { get; set; }
+        [JsonProperty("result")] public object Result { get; set; }
+        [JsonProperty("error")] public string Error { get; set; }
+        [JsonProperty("code")] public string Code { get; set; }
+        [JsonProperty("cultivating")] public bool Cultivating { get; set; }
+        [JsonProperty("embryoCount")] public int EmbryoCount { get; set; }
+        public string SessionId => Result?.ToString();
+    }
+}
+```
+
+`DebugSessionClient.cs`:
+
+```csharp
+using System;
+using System.Net.Http;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Newtonsoft.Json;
+
+namespace ivf_tl_Operate.Debug
+{
+    /// <summary>
+    /// operate 端调试会话客户端:封 acquire/command/release/heartbeat 的本地 HTTP + 心跳定时器 + 会话失效回调。
+    /// spec §4.1。一次会话:AcquireAsync 起心跳,ReleaseAsync 停心跳。失效(SESSION_EXPIRED)触发 OnSessionExpired。
+    /// </summary>
+    public sealed class DebugSessionClient : IDisposable
+    {
+        private readonly string _baseUrl;
+        private readonly HttpClient _http;
+        private string _sessionId;
+        private Timer _heartbeatTimer;
+        private volatile bool _expiredFired;
+
+        public int HeartbeatIntervalMs { get; set; } = 2500;
+        public Action OnSessionExpired { get; set; }
+        public string SessionId => _sessionId;
+
+        public DebugSessionClient(string baseUrl, HttpClient http = null)
+        {
+            _baseUrl = baseUrl.TrimEnd('/');
+            _http = http ?? new HttpClient();
+        }
+
+        private async Task<AcquireResult> PostAsync(string path, object body)
+        {
+            var content = new StringContent(body == null ? "{}" : JsonConvert.SerializeObject(body), Encoding.UTF8, "application/json");
+            var resp = await _http.PostAsync($"{_baseUrl}{path}", content);
+            string s = await resp.Content.ReadAsStringAsync();
+            var r = JsonConvert.DeserializeObject<AcquireResult>(s) ?? new AcquireResult { Ok = false, Error = "空响应" };
+            if (r.Code == "SESSION_EXPIRED" && !_expiredFired)
+            {
+                _expiredFired = true;
+                StopHeartbeat();
+                try { OnSessionExpired?.Invoke(); } catch { }
+            }
+            return r;
+        }
+
+        public async Task<AcquireResult> AcquireAsync(int houseSn)
+        {
+            var r = await PostAsync("/debug/acquire", new { houseSn });
+            if (r.Ok && !string.IsNullOrEmpty(r.SessionId))
+            {
+                _sessionId = r.SessionId;
+                _expiredFired = false;
+                StartHeartbeat();
+            }
+            return r;
+        }
+
+        public Task<AcquireResult> CommandAsync(string op, object args)
+            => PostAsync("/debug/command", new { sessionId = _sessionId, op, args });
+
+        public async Task ReleaseAsync()
+        {
+            StopHeartbeat();
+            var sid = _sessionId;
+            _sessionId = null;
+            if (sid != null) { try { await PostAsync("/debug/release", new { sessionId = sid }); } catch { } }
+        }
+
+        private void StartHeartbeat()
+        {
+            StopHeartbeat();
+            _heartbeatTimer = new Timer(async _ =>
+            {
+                var sid = _sessionId;
+                if (sid == null) return;
+                try { await PostAsync("/debug/heartbeat", new { sessionId = sid }); } catch { }
+            }, null, HeartbeatIntervalMs, HeartbeatIntervalMs);
+        }
+
+        private void StopHeartbeat()
+        {
+            try { _heartbeatTimer?.Dispose(); } catch { }
+            _heartbeatTimer = null;
+        }
+
+        public void Dispose() { StopHeartbeat(); }
+    }
+}
+```
+
+- [ ] **Step 4: 运行确认通过**
+
+Run: operate 单测工程 `dotnet test --filter DebugSessionClientTests`
+Expected: PASS(4 测试)
+
+- [ ] **Step 5: 提交**
+
+```bash
+git add ivf_tl_operate_2.0/ivf_tl_Operate/Debug/AcquireResult.cs ivf_tl_operate_2.0/ivf_tl_Operate/Debug/DebugSessionClient.cs <测试文件>
+git commit -m "feat(d2-02-t3): operate DebugSessionClient(acquire/command/release/心跳定时器/失效回调)+4单测"
+```
+
+---
+
+## Task 5: HouseDebugPageViewModel 接入 client(舱1-10)
+
+**Files:**
+- Modify: `ivf_tl_operate_2.0/ivf_tl_Operate/ViewModel/HouseDebugPageViewModel.cs`
+
+策略:VM 持有 `DebugSessionClient _client`;删 `HardwareAccessLayer...Acquire`/`_halLease`/`Serial`/`Cam` 直调。每个操作方法把 `Serial.XxxWait()` 换成 `await _client.CommandAsync("Op", args)`(同步 UI 调用点用 `.GetAwaiter().GetResult()` 或方法改 async,按现有调用形态最小改)。`OperationLogger.Run` 埋点保留,被包动作换成 client 调用。
+
+> ⚠ 改动大,逐方法对照 op 表(spec §7.1 + control Execute 现有 case)。op 名必须与 control 完全一致:`ReadTemp/ReadPressure/ReadDoor/ReadVentTime/ShakeHands/OpenLed/CloseLed/OpenIntake/CloseIntake/OpenExhaust/CloseExhaust/HouseAeration/HouseVent/VerticalReset/VerticalMoveTo/VerticalForward/VerticalBackward/HorizontalReset/HorizontalMoveTo/HorizontalForward/HorizontalBackward/WriteScanStep/WriteOpenIntakeTime/WriteWellHorizontalPos`。args 键名对齐 Execute:电机用 `{pos}` 或 `{value}` + `{motorDelay}`;写孔位 `{well,hor}`。
+
+- [ ] **Step 1: 加 client 字段 + ComHouseInit 改 acquire + 逐句 command**
+
+```csharp
+        private DebugSessionClient _client;
+        public DebugSessionClient Client => _client;
+
+        public async Task<AcquireResult> AcquireAsync()
+        {
+            _client = new DebugSessionClient(ivf_tl_Operate.Helpers.ControlClient.BaseUrl);
+            var r = await _client.AcquireAsync(CurrentHouseId);
+            if (r.Ok) CurrentSessionId = r.SessionId;
+            return r;   // View 据 r.Cultivating/r.EmbryoCount 决定是否弹确认
+        }
+```
+
+`ComHouseInit` 改为不再 `HardwareAccessLayer.Instance.GetHouseGate().Acquire()`——acquire 由 View 在确认后调 `AcquireAsync`;`ComHouseInit` 只跑"初始化串"(逐句 command,删相机 Init/SetOpMode):
+
+```csharp
+        public async Task ComHouseInit()
+        {
+            try
+            {
+                var currentHouse = HouseList.FirstOrDefault(x => x.houseSn == CurrentHouseId);
+                var currentHorSetting = houseWellSettingList.FirstOrDefault(x => x.houseSn == CurrentHouseId && x.wellSn == 1);
+                if (currentHorSetting == null) { AddMessageInfo($"[{CurrentHouseId}][未获取到1号well的水平电机位置]"); return; }
+                var verNewValue = currentHorSetting.eepromClearPosition;
+                var cc = ccdPhoto.FirstOrDefault(x => x.houseSn == CurrentHouseId && x.wellSn == 1);
+                if (cc != null) verNewValue = cc.clearestPosition;
+
+                await _client.CommandAsync("ShakeHands", null);
+                OpenLed();
+                await _client.CommandAsync("HorizontalReset", new { motorDelay = tLSetting.motorDelay });
+                CurrentHor = 0; CurrentWell = 0;
+                await _client.CommandAsync("HorizontalMoveTo", new { pos = currentHorSetting.horizontalMotorPosition, motorDelay = tLSetting.motorDelay });
+                CurrentHor = currentHorSetting.horizontalMotorPosition; CurrentWell = 1;
+                await _client.CommandAsync("VerticalReset", new { motorDelay = tLSetting.motorDelay });
+                CurrentVer = 0; CurrentFocal = 0;
+                await _client.CommandAsync("VerticalMoveTo", new { pos = verNewValue, motorDelay = tLSetting.motorDelay });
+                CurrentVer = verNewValue; CurrentFocal = 1;
+                RedTem(); RedPre(); RedDoor(); RedVentTime();
+            }
+            catch (Exception ex) { ExLog(ex, "ComHouseInit"); AddMessageInfo($"初始化异常:{ex.Message}"); }
+        }
+```
+
+- [ ] **Step 2: 逐个操作方法换 client**
+
+样例(读温/开灯/电机/写EEPROM,其余照此规律,保留 OperationLogger):
+
+```csharp
+        public void RedTem()
+        {
+            var r = _client?.CommandAsync("ReadTemp", null).GetAwaiter().GetResult();
+            if (r != null && r.Ok && r.Result != null && decimal.TryParse(r.Result.ToString(), out var t)) Temperature = t;
+        }
+
+        public void OpenLed()
+        {
+            Aivfo.OperationLog.OperationLogger.Run("对焦调试", "打开LED灯",
+                () => _client?.CommandAsync("OpenLed", null).GetAwaiter().GetResult(),
+                input: new { houseSn = CurrentHouseId });
+            LedState = KeyToStringConvert.GetLanguageStringByKey("C0306");
+        }
+
+        public void HorizontalMotorForward(int value)
+        {
+            var r = Aivfo.OperationLog.OperationLogger.Run("对焦调试", "水平电机正向",
+                () => _client?.CommandAsync("HorizontalForward", new { value, motorDelay = tLSetting.motorDelay }).GetAwaiter().GetResult(),
+                input: new { houseSn = CurrentHouseId, value });
+            if (r != null && !r.Ok && r.Code == "OUT_OF_RANGE") AddMessageInfo($"[水平正向]越界被拒:{r.Error}");
+            else CurrentHor += value;   // 仅成功才更新跟踪位
+        }
+
+        public void WriteOpenIntakeTime(int newValue)
+        {
+            Aivfo.OperationLog.OperationLogger.Run("对焦调试", "写舱室进气阀时间",
+                () => _client?.CommandAsync("WriteOpenIntakeTime", new { value = newValue }).GetAwaiter().GetResult(),
+                input: new { houseSn = CurrentHouseId, newValue });
+        }
+```
+
+> 完整改完所有方法(读压力/读门/关灯/进排气阀/补气排气/各电机复位前进后退到位/写排气阀时间/读排气阀时间/写扫描间隔/保存孔位/读换气时间)。`ComHouseUnit` 改 `_client?.ReleaseAsync().GetAwaiter().GetResult(); _client=null;`。电机方法统一:越界(`OUT_OF_RANGE`)提示且不更新跟踪位。
+
+- [ ] **Step 3: Release 编译 operate**
+
+Run: `dotnet build ivf_tl_operate_2.0/ivf_tl_Operate/ivf_tl_Operate.csproj -c Release`
+Expected: 0 错(operate.exe 在跑会锁 DLL,先关 operate;僵尸 20268 不锁输出 DLL)
+
+- [ ] **Step 4: 提交**
+
+```bash
+git add ivf_tl_operate_2.0/ivf_tl_Operate/ViewModel/HouseDebugPageViewModel.cs
+git commit -m "feat(d2-02-t3): HouseDebugPageViewModel 接入 DebugSessionClient(初始化串逐句command+各操作走command+越界提示)"
+```
+
+---
+
+## Task 6: BufferDebugViewModel 接入 client(缓冲瓶舱11)
+
+**Files:**
+- Modify: `ivf_tl_operate_2.0/ivf_tl_Operate/ViewModel/BufferDebugViewModel.cs`
+
+- [ ] **Step 1: 加 client + ComHouseInit/各方法换 command**
+
+```csharp
+        private DebugSessionClient _client;
+
+        public async Task<AcquireResult> AcquireAsync()
+        {
+            _client = new DebugSessionClient(ivf_tl_Operate.Helpers.ControlClient.BaseUrl);
+            var r = await _client.AcquireAsync(CurrentHouse.houseSn);   // 缓冲瓶 houseSn=11
+            return r;
+        }
+
+        public async Task ComHouseInit()
+        {
+            try
+            {
+                await _client.CommandAsync("ShakeHands", null);
+                RedL();
+                BufferState();
+            }
+            catch (Exception ex) { ExLog(ex, "ComHouseInit"); AddMessageInfo($"初始化异常:{ex.Message}"); }
+        }
+
+        public void BufferState()
+        {
+            var r = _client?.CommandAsync("BufferState", null).GetAwaiter().GetResult();
+            if (r != null && r.Ok && r.Result != null)
+            {
+                var jo = Newtonsoft.Json.Linq.JObject.FromObject(r.Result);
+                BufferBottlePressure = jo["pressure"]?.Value<decimal>() ?? BufferBottlePressure;
+                Temperature1 = jo["t1"]?.Value<decimal>() ?? Temperature1;
+                Temperature2 = jo["t2"]?.Value<decimal>() ?? Temperature2;
+            }
+        }
+
+        public void Are()
+        {
+            Aivfo.OperationLog.OperationLogger.Run("缓冲瓶调试", "缓冲瓶补气",
+                () => _client?.CommandAsync("BufferAeration", null).GetAwaiter().GetResult(),
+                input: new { houseSn = CurrentHouse?.houseSn });
+        }
+
+        public void RedL()
+        {
+            var r = _client?.CommandAsync("ReadLight", null).GetAwaiter().GetResult();
+            if (r != null && r.Ok && r.Result != null && int.TryParse(r.Result.ToString(), out var v)) LedLight = v;
+        }
+
+        public void writeL(int newValue)
+        {
+            using var _op = Aivfo.OperationLog.OperationLogger.Begin("缓冲瓶调试", "保存缓冲瓶灯光亮度", houseSn: CurrentHouse?.houseSn);
+            try { _op.Input(new { houseSn = CurrentHouse?.houseSn, newValue }); } catch { }
+            var r = _client?.CommandAsync("WriteLight", new { value = newValue }).GetAwaiter().GetResult();
+            if (r == null || !r.Ok) AddMessageInfo($"[灯光亮度]下发失败,仅本地暂存{newValue}");
+            LedLight = newValue;
+            AddMessageInfo($"灯光亮度保存服务器结果:{AppData.Instance.HttpHelper.LightsUpdateApi(tLSetting.tlSn, LedLight, CurrentHouse.inletValveOpeningTime)}");
+        }
+
+        public void writeE(int newValue)
+        {
+            using var _op = Aivfo.OperationLog.OperationLogger.Begin("缓冲瓶调试", "保存缓冲瓶进气阀时间", houseSn: CurrentHouse?.houseSn);
+            try { _op.Input(new { houseSn = CurrentHouse?.houseSn, newValue }); } catch { }
+            _client?.CommandAsync("WriteOpenIntakeTimeBuffer", new { value = newValue }).GetAwaiter().GetResult();
+            CurrentHouse.inletValveOpeningTime = newValue;
+            AddMessageInfo($"进气阀打开时间保存服务器结果:{AppData.Instance.HttpHelper.LightsUpdateApi(tLSetting.tlSn, LedLight, CurrentHouse.inletValveOpeningTime)}");
+        }
+
+        public bool ComHouseUnit()
+        {
+            try { _client?.ReleaseAsync().GetAwaiter().GetResult(); _client = null; AddMessageInfo($"[{CurrentHouse?.houseSn}]缓冲瓶已归还借用,恢复后台采集"); return true; }
+            catch (Exception ex) { ExLog(ex, "ComHouseUnit"); return false; }
+        }
+```
+
+> 删 `HardwareAccessLayer...Acquire`/`_halLease`/`Serial` 直调。核实 `BufferDebugViewModel` 是否有 `ExLog`(有,见源码)。
+
+- [ ] **Step 2: Release 编译 operate**
+
+Run: `dotnet build ivf_tl_operate_2.0/ivf_tl_Operate/ivf_tl_Operate.csproj -c Release`
+Expected: 0 错
+
+- [ ] **Step 3: 提交**
+
+```bash
+git add ivf_tl_operate_2.0/ivf_tl_Operate/ViewModel/BufferDebugViewModel.cs
+git commit -m "feat(d2-02-t3): BufferDebugViewModel 接入 client(BufferState/补气/灯亮度读写/进气阀时间走command)"
+```
+
+---
+
+## Task 7: 两个 View 接入(acquire/release/喂sid/确认框/抓图按钮置灰)
+
+**Files:**
+- Modify: `ivf_tl_operate_2.0/ivf_tl_Operate/View/HouseDebugPageView.xaml.cs`
+- Modify: `ivf_tl_operate_2.0/ivf_tl_Operate/View/HouseDebugPageView.xaml`(抓图按钮 IsEnabled=false + tooltip)
+- Modify: `ivf_tl_operate_2.0/ivf_tl_Operate/View/BufferDebugView.xaml.cs`
+
+- [ ] **Step 1: HouseDebugPageView Start_Click 改 acquire + 培养态确认框**
+
+```csharp
+        private async void Start_Click(object sender, RoutedEventArgs e)
+        {
+            try
+            {
+                this._house_StackPanel.IsEnabled = false;
+                this._ccd_StackPanel.IsEnabled = false;
+                this._startButton.IsEnabled = false;
+
+                var r = await vm.AcquireAsync();
+                if (!r.Ok)
+                {
+                    AddMessageInfo($"借用失败:{r.Error}(code={r.Code})");
+                    this._house_StackPanel.IsEnabled = true; this._ccd_StackPanel.IsEnabled = true; this._startButton.IsEnabled = true;
+                    return;
+                }
+                // 决策3:在培养舱弹确认框,告知暂停换气/控温/拍照的代价
+                if (r.Cultivating)
+                {
+                    var msg = $"舱 {vm.CurrentHouseId} 正在培养胚胎(共 {r.EmbryoCount} 枚)。\n进入调试会暂停该舱的换气、控温和拍照,直到点【卸载】退出,期间胚胎培养中断。\n\n确定继续吗?";
+                    var ans = MessageBox.Show(msg, "调试确认", MessageBoxButton.OKCancel, MessageBoxImage.Warning, MessageBoxResult.Cancel, MessageBoxOptions.DefaultDesktopOnly);
+                    if (ans != MessageBoxResult.OK)
+                    {
+                        await System.Threading.Tasks.Task.Run(() => vm.ComHouseUnit());   // 取消即 release,采集恢复
+                        this._house_StackPanel.IsEnabled = true; this._ccd_StackPanel.IsEnabled = true; this._startButton.IsEnabled = true;
+                        AddMessageInfo("已取消进入调试,采集已恢复");
+                        return;
+                    }
+                }
+
+                await vm.ComHouseInit();
+                RefshInfo();
+                OpenVideo();
+
+                this._button1.IsEnabled = true; this._button2.IsEnabled = true; this._button3.IsEnabled = true;
+                this._button4.IsEnabled = true; this._button5.IsEnabled = true; this._button6.IsEnabled = true; this._button7.IsEnabled = true;
+                this._button90.IsEnabled = true; this._button100.IsEnabled = true;
+                this._stackPanel1.IsEnabled = true; this._stackPanel2.IsEnabled = true; this._stackPanel3.IsEnabled = true; this._stackPanel4.IsEnabled = true;
+            }
+            catch (Exception ex) { ExLog(ex, "Start_Click"); }
+        }
+```
+
+> `OpenVideo()` 已有(第二阶段),它读 `vm.CurrentSessionId`——Task5 的 `AcquireAsync` 已赋值,预览即接通。`End_Click`/`Return_Click` 已调 `vm.ComHouseUnit()`(Task5 改为 release),无需再改逻辑,确认其调用 `CloseVideo()` 在前(现状已是)。
+
+- [ ] **Step 2: 抓图相关按钮置灰(XAML)**
+
+在 `HouseDebugPageView.xaml` 找到抓图/曝光相关按钮(单张抓拍/水平抓图/清晰图层抓图/写曝光),加 `IsEnabled="False"` + `ToolTip="相机抓图功能下阶段接入"`。核实按钮 x:Name(读 xaml),逐个标注。对应 code-behind 的 `_Click` 保留(死按钮不删,降风险)。
+
+- [ ] **Step 3: BufferDebugView Start 改 acquire**
+
+`BufferDebugView.xaml.cs` 的初始化入口(对应"初始化"按钮)改为:
+
+```csharp
+            var r = await vm.AcquireAsync();
+            if (!r.Ok) { /* 提示借用失败,恢复按钮 */ return; }
+            // 缓冲瓶舱11不养胚胎(无 HouseBin.Dish),r.Cultivating 恒 false,不弹确认
+            await vm.ComHouseInit();
+```
+
+卸载/返回入口改调 `vm.ComHouseUnit()`(Task6 已改 release)。核实 BufferDebugView 实际按钮事件名(读源码)。
+
+- [ ] **Step 4: Release 编译 operate**
+
+Run: `dotnet build ivf_tl_operate_2.0/ivf_tl_Operate/ivf_tl_Operate.csproj -c Release`
+Expected: 0 错(XAML→BAML 绑定全对)
+
+- [ ] **Step 5: 提交**
+
+```bash
+git add ivf_tl_operate_2.0/ivf_tl_Operate/View/HouseDebugPageView.xaml.cs ivf_tl_operate_2.0/ivf_tl_Operate/View/HouseDebugPageView.xaml ivf_tl_operate_2.0/ivf_tl_Operate/View/BufferDebugView.xaml.cs
+git commit -m "feat(d2-02-t3): 调试页View接入——Start走acquire+培养态确认框(取消即release)+喂sid接预览+抓图按钮置灰"
+```
+
+---
+
+## Task 8: 全量编译 + 单测 + codegraph 同步
+
+- [ ] **Step 1: control + operate Release 双编译**
+
+```bash
+dotnet build ivf_tl_operate_2.0/control/ivf_tl_ControlHost/ivf_tl_ControlHost.csproj -c Release
+dotnet build ivf_tl_operate_2.0/ivf_tl_Operate/ivf_tl_Operate.csproj -c Release
+```
+Expected: 均 0 错
+
+- [ ] **Step 2: 全量单测**
+
+```bash
+dotnet test ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/IvfTl.ControlHost.Tests.csproj
+```
+Expected: 全绿(原 46 + Task1 的 5 + Task2 的 3 = 54;operate DebugSessionClient 4 测视工程归属另跑)
+
+- [ ] **Step 3: codegraph 同步**
+
+```bash
+codegraph sync
+```
+
+- [ ] **Step 4: 回写文档 + 提交**
+
+更新 `进度状态.yaml`(断点)/ `交接卡.md`(追加)/ `工作计划表.md`(D2-02 第三阶段状态)/ `待验证清单.md`(V-012 / D2-04 细化),再提交。
+
+```bash
+git add 项目文档/进度/ 项目文档/进度/进度数据.js
+git commit -m "docs(d2-02-t3): 第三阶段代码完成回写——待真机V-012"
+```
+
+---
+
+## Task 9: 真机 V-012 验证(Claude 自主跑,UAC 静默提权)
+
+> 真机门控,代码不阻塞。起独立 control(见记忆 [control 真机冒烟启动要点]),用 curl/PowerShell 自主跑。电机守安全区间:水平 [0,220000]、垂直 Z [0,125000]。
+
+- [ ] **Step 1: 借串口让路时序** — acquire 舱X → curl `/status` 确认该舱采集暂停 → 发命令 → release → `/status` 确认采集恢复。不死锁/不双占。
+- [ ] **Step 2: 电机红线两轴** — 水平/垂直 reset/moveTo/forward/backward 真机走位(守区间),构造越界请求确认 HTTP 400 `OUT_OF_RANGE` 且不下发。
+- [ ] **Step 3: MJPEG 出图** — 起 operate(或 curl acquire 拿 sid)开预览,确认 `<Image>` 出该舱实时画面;画面方向正确(倒置则推流层补 Y 翻转,改 `StartPreviewStream` 抓帧后,不改纯逻辑 MjpegStreamWriter)。
+- [ ] **Step 4: 培养态确认框** — 在培养舱(有 Dish)acquire 返回 `cultivating=true`+枚数准;空舱 `cultivating=false`。验 operate 弹框/取消恢复采集(取消即 release,/status 确认未真停)。
+- [ ] **Step 5: 崩溃自动回收** — 杀 operate / 断预览 → control 看门狗(TTL 10s)或快信号回收 → `/status` 确认采集恢复。
+- [ ] **Step 6: ★use-after-free 压测** — 预览中反复 release/超时回收同一舱多轮,确认不偶发崩溃(若崩,最小修=底层 `Camera.UnInit` 置 `IsStart=false`,单独评估)。
+- [ ] **Step 7: 缓冲瓶舱11** — 读双温/瓶压/补气/灯亮度读写真机走一遍。
+- [ ] **Step 8: EEPROM 写** — 复用 HIL 套件 `IvfTl.Hardware.HilTests`(默认零写入)守护帧长/地址。
+- [ ] **Step 9: 回写真机结果** — `待验证清单.md` D2-02/D2-04/V-012 标记;`交接卡.md` 追加;提交。
+
+---
+
+## 自检(写完计划对照 spec)
+
+- **spec §2 范围**:Task1-7 覆盖 operate 两VM两View + control 缓冲瓶op;抓图链 Task7 置灰(范围外)。✓
+- **spec §3 决策1(抓图分阶段)**:Task7 置灰按钮。✓ **决策2(初始化串operate逐句)**:Task5 ComHouseInit 逐句 command。✓ **决策3(培养态确认框)**:Task2/3 回带 + Task7 弹框。✓
+- **spec §4.1 DebugSessionClient**:Task4(心跳/失效回调/幂等)。✓
+- **spec §5 缓冲瓶op**:Task1(5个op)。✓ **§5a acquire回带**:Task2/3。✓
+- **spec §6 业务风险**:Task7 确认框落 §6.1;Task9 真机验让路/回收/压测。✓
+- **spec §7 测试**:纯逻辑 Task1/2/4 单测;真机 Task9。✓
+- **占位符扫描**:无 TBD;两处标注"实现期核实"(Dish 胚胎数字段名、FakeSerial 字段、View 按钮名)均为"读源码确认具体名"的明确指令,非逻辑空缺。✓
+- **类型一致性**:`AcquireResult.SessionId`/`Cultivating`/`EmbryoCount`、`DebugSessionClient.CommandAsync/AcquireAsync/ReleaseAsync`、control `Cultivating/EmbryoCount`、op 名贯穿一致。✓