Просмотр исходного кода

test(hil): 新增硬件在环回归套件 IvfTl.Hardware.HilTests + brainstorm/spec/plan + 真机验证

把 M 区真机检查(M-01/02/03 EEPROM写/M-05 0x12帧长/M-06 按well焦点零点)从
gitignore 里跑完即弃的临时 harness 沉淀为永久入库回归套件,防修复被改回去。

- 新增 xUnit 工程 IvfTl.Hardware.HilTests(net6.0-windows,入 control sln,
  经真实 SerialChannelImpl 端到端;纯新增,不动任何生产代码)
- 门控:HardwareRigFixture 扫口握手收响应真舱 + [SkippableFact] 无真舱/
  control占口即 Skip(常规 dotnet test/CI 永绿)+ 禁并行串行跑
- 覆盖:FocusZero(M-06 按well去重>1+安全Z区间)/FrameLength(M-05 连读12轮
  全干净)纯读始终跑;EepromWrite(M-01/02/03 写回环)默认 Skip、
  HIL_ALLOW_WRITE=1 才跑、逐舱取证、写后立即恢复原值(默认零写入)
- 真机验证:零写入 2过2跳;开写开关 4/4 全过(排气阀舱9 200→201→200、
  灯光逐舱落舱8 500→501→500;去重>1 与 raw 抓包诊断吻合)
- control全sln+operate Release 双编译0错;既有 SerialHelper 40 单测过

文档同步:设计/实现计划 + 工作计划表/进度状态.yaml/交接卡/待验证清单/进度数据.js

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
huangjie 3 дней назад
Родитель
Сommit
dcf0593a08

+ 4 - 0
ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests/AssemblyInfo.cs

@@ -0,0 +1,4 @@
+using Xunit;
+
+// HIL 套件连真机串口,多测试并发会抢同一 COM 口 → 全程禁并行、串行跑。
+[assembly: CollectionBehavior(DisableTestParallelization = true)]

+ 88 - 0
ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests/EepromWriteHilTests.cs

@@ -0,0 +1,88 @@
+using System;
+using System.Threading;
+using IvfTl.Hardware.HilTests.Infrastructure;
+using IvfTl.Hardware.Impl;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace IvfTl.Hardware.HilTests
+{
+    /// <summary>
+    /// M-01/02/03 防回归:写命令(排气阀时间/灯光亮度)真下发到下位机,读回随写变化,写后立即恢复原值(非破坏)。
+    /// 安全口径:默认 Skip(零写入);仅环境变量 HIL_ALLOW_WRITE=1 才执行。全程仅 EEPROM,无电机。
+    /// </summary>
+    [Collection("HIL")]
+    public class EepromWriteHilTests
+    {
+        private readonly HardwareRigFixture _rig;
+        private readonly ITestOutputHelper _out;
+        public EepromWriteHilTests(HardwareRigFixture rig, ITestOutputHelper o) { _rig = rig; _out = o; }
+
+        private static bool WriteAllowed =>
+            Environment.GetEnvironmentVariable("HIL_ALLOW_WRITE") == "1";
+
+        [SkippableFact]
+        public void WriteVentTime_RoundTrips_AndRestoresOriginal()
+        {
+            Skip.IfNot(WriteAllowed, "写路径默认关闭;设 HIL_ALLOW_WRITE=1 才跑(写后立即恢复原值)");
+            RoundTrip("排气阀时间(M-01写/M-02读)",
+                ch => ch.ReadOpenVentTimeWait(), (ch, v) => ch.WriteOpenVentTimeWait(v));
+        }
+
+        [SkippableFact]
+        public void WriteLightBrightness_RoundTrips_AndRestoresOriginal()
+        {
+            Skip.IfNot(WriteAllowed, "写路径默认关闭;设 HIL_ALLOW_WRITE=1 才跑(写后立即恢复原值)");
+            RoundTrip("灯光亮度(M-03写)",
+                ch => ch.ReadLightBrightnessWait(), (ch, v) => ch.WriteLightBrightnessWait(v));
+        }
+
+        private void RoundTrip(string label, Func<SerialChannelImpl, int> read, Action<SerialChannelImpl, int> write)
+        {
+            Skip.If(_rig.FirstChamberWithWells() == null, "无响应真舱:无硬件 / control 正占用串口 / 未连接");
+
+            // 逐舱(排除舱11)找到第一个能读到该项的真舱再验:不同舱配置不同 E方,
+            // 单一固定舱可能无此项(如灯光),逐舱试避免漏验该命令(对齐原 harness 取证逻辑)。
+            foreach (var chamber in _rig.Chambers)
+            {
+                if (chamber.HouseSn == 11) continue; // 缓冲瓶/总控,无舱室排气阀/灯光
+                SerialChannelImpl ch = null;
+                try
+                {
+                    ch = new SerialChannelImpl(0, chamber.Port);
+                    if (!ch.Open()) continue;
+
+                    // 读前丢弃一帧 + 间隔排空(隔离任何尾噪声),拿稳定原值。
+                    int ReadStable()
+                    {
+                        try { read(ch); } catch { }
+                        Thread.Sleep(700);
+                        int a = read(ch);
+                        Thread.Sleep(700);
+                        return a;
+                    }
+
+                    int v0 = ReadStable();
+                    if (v0 < 0) { _out.WriteLine($"[{label}] 舱{chamber.HouseSn} 无此项(读={v0}),试下一舱"); continue; }
+                    _out.WriteLine($"[{label}] 舱{chamber.HouseSn} 原值 V0={v0}");
+
+                    int target = v0 + 1;
+                    write(ch, target); Thread.Sleep(700);
+                    int v1 = ReadStable();
+                    _out.WriteLine($"[{label}] 写 {target} 后读回={v1}");
+
+                    write(ch, v0); Thread.Sleep(700);   // 恢复原值
+                    int v2 = ReadStable();
+                    _out.WriteLine($"[{label}] 写回 {v0} 后读回={v2}");
+
+                    Assert.Equal(target, v1); // 写真下发:读回随写变化
+                    Assert.Equal(v0, v2);     // 已恢复原值:非破坏
+                    return;                   // 已在该舱取得证据
+                }
+                finally { try { ch?.Close(); } catch { } }
+            }
+
+            Skip.If(true, $"[{label}] 所有真舱均无此项,无法取证(非失败)");
+        }
+    }
+}

+ 51 - 0
ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests/FocusZeroHilTests.cs

@@ -0,0 +1,51 @@
+using System.Linq;
+using System.Threading;
+using IvfTl.Hardware.HilTests.Infrastructure;
+using IvfTl.Hardware.Impl;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace IvfTl.Hardware.HilTests
+{
+    /// <summary>
+    /// M-06 防回归:control SerialChannelImpl.ReadWellFocusZeroWait(well) 必须按 well 读各自 Z 焦点零点。
+    /// 修前 ComBin.ReadEEPROMvertMtStartPulseWait 硬编码 well-1 → 各 well 全返回同值(去重=1)=回归。
+    /// 修后各 well 分槽(去重>1)。纯 0x11 EEPROM 读,无任何电机动作,非破坏。
+    /// </summary>
+    [Collection("HIL")]
+    public class FocusZeroHilTests
+    {
+        private readonly HardwareRigFixture _rig;
+        private readonly ITestOutputHelper _out;
+        public FocusZeroHilTests(HardwareRigFixture rig, ITestOutputHelper o) { _rig = rig; _out = o; }
+
+        [SkippableFact]
+        public void PerWellFocusZero_IsDistinctPerWell_AndWithinSafeZ()
+        {
+            var chamber = _rig.FirstChamberWithWells();
+            Skip.If(chamber == null, "无响应真舱:无硬件 / control 正占用串口 / 未连接");
+
+            var vals = new int[17];
+            SerialChannelImpl ch = null;
+            try
+            {
+                ch = new SerialChannelImpl(0, chamber.Port);
+                Assert.True(ch.Open(), $"{chamber.Port} 打开失败");
+                for (int well = 1; well <= 16; well++)
+                {
+                    vals[well] = ch.ReadWellFocusZeroWait(well);
+                    Thread.Sleep(120);
+                }
+            }
+            finally { try { ch?.Close(); } catch { } }
+
+            var read = vals.Skip(1).ToArray();
+            _out.WriteLine($"舱{chamber.HouseSn} 各 well 焦点零点: {string.Join(",", read)}");
+
+            int distinct = read.Distinct().Count();
+            Assert.True(distinct > 1,
+                $"按 well 读应各 well 不同(去重>1),实得去重={distinct}(=1 说明退回恒读 well-1=M-06 回归)");
+            Assert.All(read, v => Assert.InRange(v, 0, 125000)); // 安全 Z 区间
+        }
+    }
+}

+ 49 - 0
ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests/FrameLengthHilTests.cs

@@ -0,0 +1,49 @@
+using System.Threading;
+using IvfTl.Hardware.HilTests.Infrastructure;
+using IvfTl.Hardware.Impl;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace IvfTl.Hardware.HilTests
+{
+    /// <summary>
+    /// M-05 防回归:0x12 写 E方回包帧长修复为 12 后,读路径不被残留字节污染。
+    /// 帧长若回退为 6,写后残留字节会错位污染紧接的读 → 出现 -1/垃圾值。
+    /// 纯读变体:连续多轮读排气阀时间,断言全部 sane 非负。纯 0x11 读,无电机,非破坏。
+    /// (帧长表本身另由 ivf_tl_SerialHelper.Tests/CustomProtocolLengthTests 单测逐项锁死。)
+    /// </summary>
+    [Collection("HIL")]
+    public class FrameLengthHilTests
+    {
+        private readonly HardwareRigFixture _rig;
+        private readonly ITestOutputHelper _out;
+        public FrameLengthHilTests(HardwareRigFixture rig, ITestOutputHelper o) { _rig = rig; _out = o; }
+
+        [SkippableFact]
+        public void RepeatedReads_AreClean_NoFrameCorruption()
+        {
+            var chamber = _rig.FirstChamberWithWells();
+            Skip.If(chamber == null, "无响应真舱:无硬件 / control 正占用串口 / 未连接");
+
+            const int rounds = 12;
+            int clean = 0;
+            SerialChannelImpl ch = null;
+            try
+            {
+                ch = new SerialChannelImpl(0, chamber.Port);
+                Assert.True(ch.Open(), $"{chamber.Port} 打开失败");
+                for (int r = 0; r < rounds; r++)
+                {
+                    int v = ch.ReadOpenVentTimeWait();
+                    if (v >= 0) clean++;
+                    else _out.WriteLine($"轮{r} 读={v}(脏/无响应)");
+                    Thread.Sleep(80);
+                }
+            }
+            finally { try { ch?.Close(); } catch { } }
+
+            _out.WriteLine($"舱{chamber.HouseSn} 连续读 {rounds} 轮,干净 {clean}/{rounds}");
+            Assert.Equal(rounds, clean); // 帧长正确则每轮都 sane;有错位则出现 -1
+        }
+    }
+}

+ 58 - 0
ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests/Infrastructure/HardwareRigFixture.cs

@@ -0,0 +1,58 @@
+using System;
+using System.Collections.Generic;
+using System.IO.Ports;
+using IvfTl.Hardware.Impl;
+
+namespace IvfTl.Hardware.HilTests.Infrastructure
+{
+    /// <summary>一个有响应的真舱:串口 + 握手返回的舱号。</summary>
+    public sealed class ChamberInfo
+    {
+        public string Port { get; }
+        public int HouseSn { get; }
+        public ChamberInfo(string port, int houseSn) { Port = port; HouseSn = houseSn; }
+    }
+
+    /// <summary>
+    /// 全套件构造一次:扫描 COM 口、逐口 Open+握手,收集有响应的真舱。
+    /// 探测完即 Close 释放;各测试用时各自重开端口。
+    /// 无硬件 / control 正占用串口 → Chambers 为空 → 测试 Skip。
+    /// </summary>
+    public sealed class HardwareRigFixture : IDisposable
+    {
+        public IReadOnlyList<ChamberInfo> Chambers { get; }
+
+        public HardwareRigFixture()
+        {
+            var found = new List<ChamberInfo>();
+            string[] ports;
+            try { ports = SerialPort.GetPortNames(); } catch { ports = Array.Empty<string>(); }
+
+            foreach (var port in ports)
+            {
+                if (port == "COM1" || port == "COM2") continue; // 非舱口
+                SerialChannelImpl ch = null;
+                try
+                {
+                    ch = new SerialChannelImpl(0, port);
+                    if (!ch.Open()) continue;
+                    int house = ch.ShakeHandsWait();
+                    if (house < 0) continue;
+                    found.Add(new ChamberInfo(port, house));
+                }
+                catch { /* 占口/无响应 → 不计入 */ }
+                finally { try { ch?.Close(); } catch { } }
+            }
+            Chambers = found;
+        }
+
+        /// <summary>取第一个有舱室 E方(排气阀/灯光/焦点)的真舱,排除舱11(缓冲瓶/总控)。无则返回 null。</summary>
+        public ChamberInfo FirstChamberWithWells()
+        {
+            foreach (var c in Chambers) if (c.HouseSn != 11) return c;
+            return null;
+        }
+
+        public void Dispose() { }
+    }
+}

+ 8 - 0
ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests/Infrastructure/HilCollection.cs

@@ -0,0 +1,8 @@
+using Xunit;
+
+namespace IvfTl.Hardware.HilTests.Infrastructure
+{
+    // 所有 HIL 测试类共享同一 HardwareRigFixture(扫口只做一次)。配合禁并行 → 串行复用端口探测结果。
+    [CollectionDefinition("HIL")]
+    public sealed class HilCollection : ICollectionFixture<HardwareRigFixture> { }
+}

+ 17 - 0
ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests/IvfTl.Hardware.HilTests.csproj

@@ -0,0 +1,17 @@
+<Project Sdk="Microsoft.NET.Sdk">
+  <PropertyGroup>
+    <TargetFramework>net6.0-windows</TargetFramework>
+    <Nullable>disable</Nullable>
+    <IsPackable>false</IsPackable>
+    <Platforms>AnyCPU;x64</Platforms>
+  </PropertyGroup>
+  <ItemGroup>
+    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.0" />
+    <PackageReference Include="xunit" Version="2.4.2" />
+    <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5" />
+    <PackageReference Include="Xunit.SkippableFact" Version="1.4.13" />
+  </ItemGroup>
+  <ItemGroup>
+    <ProjectReference Include="..\IvfTl.Hardware\IvfTl.Hardware.csproj" />
+  </ItemGroup>
+</Project>

+ 45 - 0
ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests/README.md

@@ -0,0 +1,45 @@
+# IvfTl.Hardware.HilTests — 硬件在环(HIL)回归套件
+
+把合并改造遗留 M 区**真机验证**的检查逻辑沉淀为永久入库的回归用例,防止这些修复以后被改回去。
+经真实 `IvfTl.Hardware.Impl.SerialChannelImpl` 端到端连真舱(与去桩后 control 走同一路径)。
+
+## 守护什么
+
+| 测试 | 守护缺陷 | 类型 | 断言 |
+|---|---|---|---|
+| `FocusZeroHilTests` | **M-06** 按 well 焦点零点 | 纯读 0x11,无电机 | 各 well 读值去重>1(证按 well 分槽,非恒读 well-1);值落在安全 Z 区间 [0,125000] |
+| `FrameLengthHilTests` | **M-05** 0x12 写 E方回包帧长(=12)修复 | 纯读(连读不写) | 连续读 12 轮全部 sane 非负(帧长错会致残留字节污染读) |
+| `EepromWriteHilTests` | **M-01/02/03** EEPROM 写命令真下发 | 写回环(非破坏) | **默认 Skip**;读 V→写 V+1→读==V+1→写回 V→读==V(立即恢复原值) |
+
+> 帧长表本身另由 `ivf_tl_SerialHelper.Tests/CustomProtocolLengthTests`(纯逻辑单测)逐项锁死;本套件守护真机端到端行为。
+
+## 怎么跑
+
+**前提**:`control` 未运行、`operate` 未运行(否则串口被占,测试会 Skip 而非真验证)。
+
+```bash
+# 默认:零写入(只跑两个纯读测试 + 写测试 Skip)
+dotnet test ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests/IvfTl.Hardware.HilTests.csproj -c Debug
+
+# 开写回环(写 EEPROM 后立即恢复原值,非破坏)
+HIL_ALLOW_WRITE=1 dotnet test ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests/IvfTl.Hardware.HilTests.csproj -c Debug
+```
+
+详细输出加 `--logger "console;verbosity=detailed"` 看每 well 值/写读回值。
+
+## Skip 语义(诚实,不伪绿)
+
+- **无响应真舱**(无硬件 / control 占口 / 未连接)→ 全部 **Skipped**,常规 `dotnet test`/CI 永绿。
+- **写测试**默认 Skipped,需 `HIL_ALLOW_WRITE=1` 才跑。
+- 某项在所选舱**不存在**(如某舱无灯光)→ 自动逐舱试,全舱都无才 Skip。
+
+## 安全口径
+
+- 默认运行**零写入**,只读 EEPROM。
+- 写回环每次写后**立即恢复原值**,设备状态不变(已真机验证非破坏)。
+- **全程不动任何电机**,仅 EEPROM 读写。
+
+## 设计/计划
+
+- 设计:`项目文档/需求文档/specs/2026-06-23-HIL硬件在环回归套件-design.md`
+- 实现计划:`项目文档/开发计划/2026-06-23-HIL硬件在环回归套件实现计划.md`

+ 10 - 0
ivf_tl_operate_2.0/control/ivf_tl_Control.sln

@@ -37,6 +37,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ivf_tl_Watchdog.Tests", "iv
 EndProject
 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ivf_tl_Watchdog", "ivf_tl_Watchdog\ivf_tl_Watchdog.csproj", "{BC68ECC3-5155-4605-AF7D-FF72D54850B3}"
 EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IvfTl.Hardware.HilTests", "IvfTl.Hardware.HilTests\IvfTl.Hardware.HilTests.csproj", "{80629306-515D-4353-98A1-7800D2433C7D}"
+EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
@@ -173,6 +175,14 @@ Global
 		{BC68ECC3-5155-4605-AF7D-FF72D54850B3}.Release|Any CPU.Build.0 = Release|Any CPU
 		{BC68ECC3-5155-4605-AF7D-FF72D54850B3}.Release|x64.ActiveCfg = Release|x64
 		{BC68ECC3-5155-4605-AF7D-FF72D54850B3}.Release|x64.Build.0 = Release|x64
+		{80629306-515D-4353-98A1-7800D2433C7D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{80629306-515D-4353-98A1-7800D2433C7D}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{80629306-515D-4353-98A1-7800D2433C7D}.Debug|x64.ActiveCfg = Debug|x64
+		{80629306-515D-4353-98A1-7800D2433C7D}.Debug|x64.Build.0 = Debug|x64
+		{80629306-515D-4353-98A1-7800D2433C7D}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{80629306-515D-4353-98A1-7800D2433C7D}.Release|Any CPU.Build.0 = Release|Any CPU
+		{80629306-515D-4353-98A1-7800D2433C7D}.Release|x64.ActiveCfg = Release|x64
+		{80629306-515D-4353-98A1-7800D2433C7D}.Release|x64.Build.0 = Release|x64
 	EndGlobalSection
 	GlobalSection(SolutionProperties) = preSolution
 		HideSolutionNode = FALSE

+ 416 - 0
项目文档/开发计划/2026-06-23-HIL硬件在环回归套件实现计划.md

@@ -0,0 +1,416 @@
+# HIL 硬件在环回归套件 实现计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development 或 superpowers:executing-plans 逐任务实现。步骤用 `- [ ]` 复选框跟踪。
+> 关联设计:`项目文档/需求文档/specs/2026-06-23-HIL硬件在环回归套件-design.md`。
+
+**Goal:** 新增永久入库的 xUnit HIL 测试工程 `IvfTl.Hardware.HilTests`,把 M-05/M-06/M-01-03 的真机检查固化为防回归用例;真机不在时优雅 Skip。
+
+**Architecture:** `net6.0-windows` xUnit 工程,经真实 `IvfTl.Hardware.Impl.SerialChannelImpl` 端到端连真舱。collection fixture 扫口握手收集响应真舱,禁并行串行跑;`[SkippableFact]` + `Skip.If` 在无硬件/control 占口时 Skip。默认零写入,写回环用 `HIL_ALLOW_WRITE=1` 显式开关。
+
+**Tech Stack:** C# / net6.0-windows / xUnit 2.4.2 / Xunit.SkippableFact / Microsoft.NET.Test.Sdk 17.6.0;`SerialChannelImpl`(System.IO.Ports)。
+
+**约定提醒:** operate.exe 在跑会锁 DLL(MSB3021),编译前先确保 operate 未跑;control 占串口会致测试 Skip(预期行为)。真机方法签名:`SerialChannelImpl(int index, string port)` / `bool Open()` / `int ShakeHandsWait()` / `int ReadOpenVentTimeWait()` / `bool WriteOpenVentTimeWait(int)` / `int ReadLightBrightnessWait()` / `bool WriteLightBrightnessWait(int)` / `int ReadWellFocusZeroWait(int well)` / `void Close()`。
+
+---
+
+### Task 1: 工程骨架 + 加入 sln + 编译通过
+
+**Files:**
+- Create: `ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests/IvfTl.Hardware.HilTests.csproj`
+- Create: `ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests/AssemblyInfo.cs`
+- Modify: `ivf_tl_operate_2.0/control/ivf_tl_Control.sln`(加工程)
+
+- [ ] **Step 1: 写 csproj**
+
+```xml
+<Project Sdk="Microsoft.NET.Sdk">
+  <PropertyGroup>
+    <TargetFramework>net6.0-windows</TargetFramework>
+    <Nullable>disable</Nullable>
+    <IsPackable>false</IsPackable>
+    <Platforms>AnyCPU;x64</Platforms>
+  </PropertyGroup>
+  <ItemGroup>
+    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.0" />
+    <PackageReference Include="xunit" Version="2.4.2" />
+    <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5" />
+    <PackageReference Include="Xunit.SkippableFact" Version="1.4.13" />
+  </ItemGroup>
+  <ItemGroup>
+    <ProjectReference Include="..\IvfTl.Hardware\IvfTl.Hardware.csproj" />
+  </ItemGroup>
+</Project>
+```
+
+- [ ] **Step 2: 写 AssemblyInfo.cs(禁并行,防多测试抢 COM 口)**
+
+```csharp
+using Xunit;
+
+[assembly: CollectionBehavior(DisableTestParallelization = true)]
+```
+
+- [ ] **Step 3: 加进 sln**
+
+Run: `cd ivf_tl_operate_2.0/control && dotnet sln ivf_tl_Control.sln add IvfTl.Hardware.HilTests/IvfTl.Hardware.HilTests.csproj`
+Expected: `Project ... added to the solution.`
+
+- [ ] **Step 4: 编译验证(含 NuGet restore SkippableFact)**
+
+Run: `dotnet build ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests/IvfTl.Hardware.HilTests.csproj -c Debug`
+Expected: `Build succeeded. 0 Error(s)`。
+**若 `Xunit.SkippableFact` 离线 restore 失败**:走回退——从 csproj 删该 PackageReference,Task 2 改用无依赖 `[HilFact]`(见 Task 2 回退分支)。
+
+- [ ] **Step 5: 提交点(暂不单独 commit,按 §3.4 末尾合并提交)**
+
+---
+
+### Task 2: HardwareRigFixture(扫口握手收集响应真舱)+ collection
+
+**Files:**
+- Create: `ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests/Infrastructure/HardwareRigFixture.cs`
+- Create: `ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests/Infrastructure/HilCollection.cs`
+
+- [ ] **Step 1: 写 HardwareRigFixture.cs**
+
+```csharp
+using System;
+using System.Collections.Generic;
+using System.IO.Ports;
+using IvfTl.Hardware.Impl;
+
+namespace IvfTl.Hardware.HilTests.Infrastructure
+{
+    /// <summary>一个有响应的真舱:串口 + 握手返回的舱号。</summary>
+    public sealed class ChamberInfo
+    {
+        public string Port { get; }
+        public int HouseSn { get; }
+        public ChamberInfo(string port, int houseSn) { Port = port; HouseSn = houseSn; }
+    }
+
+    /// <summary>
+    /// 全套件构造一次:扫描 COM 口、逐口 Open+握手,收集有响应的真舱。
+    /// 探测完即 Close 释放;各测试用时各自重开端口。
+    /// 无硬件 / control 正占用串口 → Chambers 为空 → 测试 Skip。
+    /// </summary>
+    public sealed class HardwareRigFixture : IDisposable
+    {
+        public IReadOnlyList<ChamberInfo> Chambers { get; }
+
+        public HardwareRigFixture()
+        {
+            var found = new List<ChamberInfo>();
+            string[] ports;
+            try { ports = SerialPort.GetPortNames(); } catch { ports = Array.Empty<string>(); }
+
+            foreach (var port in ports)
+            {
+                if (port == "COM1" || port == "COM2") continue; // 非舱口
+                SerialChannelImpl ch = null;
+                try
+                {
+                    ch = new SerialChannelImpl(0, port);
+                    if (!ch.Open()) continue;
+                    int house = ch.ShakeHandsWait();
+                    if (house < 0) continue;
+                    found.Add(new ChamberInfo(port, house));
+                }
+                catch { /* 占口/无响应 → 不计入 */ }
+                finally { try { ch?.Close(); } catch { } }
+            }
+            Chambers = found;
+        }
+
+        /// <summary>取第一个有舱室 E方(排气阀/灯光/焦点)的真舱,排除舱11(缓冲瓶/总控)。</summary>
+        public ChamberInfo FirstChamberWithWells()
+        {
+            foreach (var c in Chambers) if (c.HouseSn != 11) return c;
+            return null;
+        }
+
+        public void Dispose() { }
+    }
+}
+```
+
+- [ ] **Step 2: 写 HilCollection.cs**
+
+```csharp
+using Xunit;
+
+namespace IvfTl.Hardware.HilTests.Infrastructure
+{
+    [CollectionDefinition("HIL")]
+    public sealed class HilCollection : ICollectionFixture<HardwareRigFixture> { }
+}
+```
+
+- [ ] **Step 3: 编译验证**
+
+Run: `dotnet build ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests/IvfTl.Hardware.HilTests.csproj -c Debug`
+Expected: `Build succeeded. 0 Error(s)`
+
+> **回退分支(仅当 SkippableFact 不可用)**:在 Infrastructure 下加 `HilFactAttribute.cs`:`public sealed class HilFactAttribute : FactAttribute {}`(普通 Fact);各测试改用 `if (rig.Chambers.Count==0) { output.WriteLine("skip:无真舱"); return; }` 早返回(报 Passed)。后续 Task 的 `[SkippableFact]`/`Skip.If` 相应换成 `[HilFact]` + 早返回。
+
+---
+
+### Task 3: FocusZeroHilTests(M-06 按 well 焦点零点,纯读)
+
+**Files:**
+- Create: `ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests/FocusZeroHilTests.cs`
+
+- [ ] **Step 1: 写测试**
+
+```csharp
+using System.Linq;
+using System.Threading;
+using IvfTl.Hardware.HilTests.Infrastructure;
+using IvfTl.Hardware.Impl;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace IvfTl.Hardware.HilTests
+{
+    [Collection("HIL")]
+    public class FocusZeroHilTests
+    {
+        private readonly HardwareRigFixture _rig;
+        private readonly ITestOutputHelper _out;
+        public FocusZeroHilTests(HardwareRigFixture rig, ITestOutputHelper o) { _rig = rig; _out = o; }
+
+        // M-06:按 well 读 Z 焦点零点。修前恒读 well-1(去重=1);修后各 well 分槽(去重>1)。纯 0x11 读,无电机。
+        [SkippableFact]
+        public void PerWellFocusZero_IsDistinctPerWell_AndWithinSafeZ()
+        {
+            var chamber = _rig.FirstChamberWithWells();
+            Skip.If(chamber == null, "无响应真舱:无硬件 / control 正占用串口 / 未连接");
+
+            var vals = new int[17];
+            SerialChannelImpl ch = null;
+            try
+            {
+                ch = new SerialChannelImpl(0, chamber.Port);
+                Assert.True(ch.Open(), $"{chamber.Port} 打开失败");
+                for (int well = 1; well <= 16; well++)
+                {
+                    vals[well] = ch.ReadWellFocusZeroWait(well);
+                    Thread.Sleep(120);
+                }
+            }
+            finally { try { ch?.Close(); } catch { } }
+
+            var read = vals.Skip(1).ToArray();
+            _out.WriteLine($"舱{chamber.HouseSn} 各 well 焦点零点: {string.Join(",", read)}");
+
+            int distinct = read.Distinct().Count();
+            Assert.True(distinct > 1, $"按 well 读应各 well 不同(去重>1),实得去重={distinct}(=1 说明退回恒读 well-1=M-06 回归)");
+            Assert.All(read, v => Assert.InRange(v, 0, 125000)); // 安全 Z 区间
+        }
+    }
+}
+```
+
+- [ ] **Step 2: 编译验证**
+
+Run: `dotnet build ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests/IvfTl.Hardware.HilTests.csproj -c Debug`
+Expected: `Build succeeded. 0 Error(s)`
+
+---
+
+### Task 4: FrameLengthHilTests(M-05 连续读干净度,纯读)
+
+**Files:**
+- Create: `ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests/FrameLengthHilTests.cs`
+
+- [ ] **Step 1: 写测试**
+
+```csharp
+using System.Threading;
+using IvfTl.Hardware.HilTests.Infrastructure;
+using IvfTl.Hardware.Impl;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace IvfTl.Hardware.HilTests
+{
+    [Collection("HIL")]
+    public class FrameLengthHilTests
+    {
+        private readonly HardwareRigFixture _rig;
+        private readonly ITestOutputHelper _out;
+        public FrameLengthHilTests(HardwareRigFixture rig, ITestOutputHelper o) { _rig = rig; _out = o; }
+
+        // M-05:0x12 写 E方回包帧长修复为 12 后,读路径不被残留字节污染。
+        // 纯读变体:连续多轮读排气阀时间,断言全部 sane 非负(帧错位会出现 -1/垃圾)。
+        [SkippableFact]
+        public void RepeatedReads_AreClean_NoFrameCorruption()
+        {
+            var chamber = _rig.FirstChamberWithWells();
+            Skip.If(chamber == null, "无响应真舱:无硬件 / control 正占用串口 / 未连接");
+
+            const int rounds = 12;
+            int clean = 0;
+            SerialChannelImpl ch = null;
+            try
+            {
+                ch = new SerialChannelImpl(0, chamber.Port);
+                Assert.True(ch.Open(), $"{chamber.Port} 打开失败");
+                for (int r = 0; r < rounds; r++)
+                {
+                    int v = ch.ReadOpenVentTimeWait();
+                    if (v >= 0) clean++;
+                    else _out.WriteLine($"轮{r} 读={v}(脏/无响应)");
+                    Thread.Sleep(80);
+                }
+            }
+            finally { try { ch?.Close(); } catch { } }
+
+            _out.WriteLine($"舱{chamber.HouseSn} 连续读 {rounds} 轮,干净 {clean}/{rounds}");
+            Assert.Equal(rounds, clean); // 帧长正确则每轮都 sane;有错位则出现 -1
+        }
+    }
+}
+```
+
+- [ ] **Step 2: 编译验证**
+
+Run: `dotnet build ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests/IvfTl.Hardware.HilTests.csproj -c Debug`
+Expected: `Build succeeded. 0 Error(s)`
+
+---
+
+### Task 5: EepromWriteHilTests(M-01/02/03 写回环,默认 Skip)
+
+**Files:**
+- Create: `ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests/EepromWriteHilTests.cs`
+
+- [ ] **Step 1: 写测试**
+
+```csharp
+using System;
+using System.Threading;
+using IvfTl.Hardware.HilTests.Infrastructure;
+using IvfTl.Hardware.Impl;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace IvfTl.Hardware.HilTests
+{
+    [Collection("HIL")]
+    public class EepromWriteHilTests
+    {
+        private readonly HardwareRigFixture _rig;
+        private readonly ITestOutputHelper _out;
+        public EepromWriteHilTests(HardwareRigFixture rig, ITestOutputHelper o) { _rig = rig; _out = o; }
+
+        private static bool WriteAllowed =>
+            Environment.GetEnvironmentVariable("HIL_ALLOW_WRITE") == "1";
+
+        // M-01/02/03:写命令真下发到下位机(读回随写变化),写后立即恢复原值(非破坏)。
+        // 默认 Skip(零写入);仅 HIL_ALLOW_WRITE=1 才跑。
+        [SkippableFact]
+        public void WriteVentTime_RoundTrips_AndRestoresOriginal()
+        {
+            Skip.IfNot(WriteAllowed, "写路径默认关闭;设 HIL_ALLOW_WRITE=1 才跑(写后立即恢复原值)");
+            RoundTrip("排气阀时间(M-01写/M-02读)",
+                ch => ch.ReadOpenVentTimeWait(), (ch, v) => ch.WriteOpenVentTimeWait(v));
+        }
+
+        [SkippableFact]
+        public void WriteLightBrightness_RoundTrips_AndRestoresOriginal()
+        {
+            Skip.IfNot(WriteAllowed, "写路径默认关闭;设 HIL_ALLOW_WRITE=1 才跑(写后立即恢复原值)");
+            RoundTrip("灯光亮度(M-03写)",
+                ch => ch.ReadLightBrightnessWait(), (ch, v) => ch.WriteLightBrightnessWait(v));
+        }
+
+        private void RoundTrip(string label, Func<SerialChannelImpl, int> read, Action<SerialChannelImpl, int> write)
+        {
+            var chamber = _rig.FirstChamberWithWells();
+            Skip.If(chamber == null, "无响应真舱:无硬件 / control 正占用串口 / 未连接");
+
+            SerialChannelImpl ch = null;
+            try
+            {
+                ch = new SerialChannelImpl(0, chamber.Port);
+                Assert.True(ch.Open(), $"{chamber.Port} 打开失败");
+
+                // 读前丢弃一帧 + 间隔排空(隔离任何尾噪声),拿稳定原值
+                int ReadStable() { try { read(ch); } catch { } Thread.Sleep(700); int a = read(ch); Thread.Sleep(700); return a; }
+
+                int v0 = ReadStable();
+                _out.WriteLine($"[{label}] 舱{chamber.HouseSn} 原值 V0={v0}");
+                Skip.If(v0 < 0, $"[{label}] 原值读不到(该舱无此项),跳过");
+
+                int target = v0 + 1;
+                write(ch, target); Thread.Sleep(700);
+                int v1 = ReadStable();
+                _out.WriteLine($"[{label}] 写 {target} 后读回={v1}");
+
+                write(ch, v0); Thread.Sleep(700);   // 恢复原值
+                int v2 = ReadStable();
+                _out.WriteLine($"[{label}] 写回 {v0} 后读回={v2}");
+
+                Assert.Equal(target, v1); // 写真下发:读回随写变化
+                Assert.Equal(v0, v2);     // 已恢复原值:非破坏
+            }
+            finally { try { ch?.Close(); } catch { } }
+        }
+    }
+}
+```
+
+- [ ] **Step 2: 编译验证**
+
+Run: `dotnet build ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests/IvfTl.Hardware.HilTests.csproj -c Debug`
+Expected: `Build succeeded. 0 Error(s)`
+
+---
+
+### Task 6: 真机跑 + README + 双编译 + 文档同步 + 提交
+
+**Files:**
+- Create: `ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests/README.md`
+- Modify: 文档同步(见 Step 4)
+
+- [ ] **Step 1: 确认 control/operate 未占串口,真机跑 HIL(默认零写入)**
+
+Run: `dotnet test ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests/IvfTl.Hardware.HilTests.csproj -c Debug`
+Expected:`FocusZero`/`FrameLength` 两测试 **Passed**(真验证);3 个写测试 **Skipped**(默认关)。若真机不在/被占 → 全 Skipped。记录输出。
+
+- [ ] **Step 2: 开写开关重跑,验证写回环(写后恢复原值)**
+
+Run(bash):`HIL_ALLOW_WRITE=1 dotnet test ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests/IvfTl.Hardware.HilTests.csproj -c Debug`
+Expected:写回环 2 测试 Passed(读回随写变化、值已恢复)。记录输出。
+
+- [ ] **Step 3: 写 README.md**
+
+内容:套件用途(防 M-05/M-06/M-01-03 回归)、运行前提(control 未占串口、operate 未跑)、`dotnet test` 默认零写入、`HIL_ALLOW_WRITE=1` 开写回环、Skip 语义(无真舱即 Skip 非 Fail)、非破坏安全说明。
+
+- [ ] **Step 4: 双编译 0 错 + 回归既有单测 + codegraph sync**
+
+Run: `dotnet build ivf_tl_operate_2.0/control/ivf_tl_Control.sln -c Debug`(control 全 sln)
+Run: `dotnet build ivf_tl_operate_2.0/ivf_tl_Operate.sln -c Release`(operate Release,先确保 operate.exe 未跑)
+Run: `dotnet test ivf_tl_operate_2.0/control/ivf_tl_SerialHelper.Tests/ivf_tl_SerialHelper.Tests.csproj`(既有 40 单测仍过)
+Run: `codegraph sync`
+Expected:全 0 错 / 40 过 / 索引同步。
+
+- [ ] **Step 5: 文档同步(提交边界=文档已对齐)**
+
+更新:`工作计划表.md`(阶段3 加 HIL 套件入库行)、`进度状态.yaml`(覆盖断点)、`交接卡.md`(追加本次)、`待验证清单.md`(M-05/M-06/M-01-03 标注已有入库 HIL 守护)、`进度数据.js`(面板)。
+
+- [ ] **Step 6: 提交**
+
+```bash
+git add ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests ivf_tl_operate_2.0/control/ivf_tl_Control.sln 项目文档/
+git commit -m "test(hil): 新增硬件在环回归套件 IvfTl.Hardware.HilTests(守护 M-05帧长/M-06按well焦点/M-01-03 EEPROM写;门控Skip+默认零写入)"
+```
+
+---
+
+## 自检(Self-Review)
+
+- **Spec 覆盖**:形态(xUnit 门控)=Task1-2;M-06=Task3;M-05=Task4;M-01/02/03 写开关=Task5;不收录 FrameLenProbe=已述;README+验收+文档同步=Task6。✓ 全覆盖。
+- **占位扫描**:无 TBD;每个代码步骤含完整源码。✓
+- **类型一致**:`HardwareRigFixture.Chambers`/`FirstChamberWithWells()`/`ChamberInfo.{Port,HouseSn}` 在 Task2 定义,Task3-5 一致引用;`[SkippableFact]`+`Skip.If/IfNot` 一致;方法签名对齐头部清单。✓

+ 18 - 0
项目文档/进度/交接卡.md

@@ -347,3 +347,21 @@
 - **决策(对齐 /goal「按工作计划把活先干完,再出建议」)**:工作计划里**可无监督安全闭环的部分已干完(D1-10)**;D2-02 需设计、D3-04 被其阻塞、D3-02 需重启——均非本会话可负责任自主完成。遂转入 /goal 第二段:**向用户呈现昨日的战略建议(已被今日工作刷新:第3条 control 审计埋点=D1-10 已完成;第1条验证清零=D/M 项基本已清)**,并就 D2-02 设计 / 看门狗(昨日第2条,安全且可自主)等让用户定方向。
 - **核实**:operate 死栈引用面 grep 实证(4 调试 ViewModel);D2-02 硬件调用面逐行枚举(电机/EEPROM/LED/StartPreview);spec §160/§178 复读。
 - **下一步**:呈现建议 + 待用户选方向(D2-02 设计 / control 看门狗 / 其它)。
+
+---
+
+## 2026-06-23 · HIL 硬件在环回归套件入库(= 昨日建议「HIL 回归套件入库」)+ brainstorm/spec/plan + 真机验证
+
+- **背景**:M 区全闭合、看门狗(D3-05)完成后,前序停在「待用户选方向」。用户选「HIL 回归套件入库」。缺口:M-01/02/03/05/06 是**经真机验证**的串口/EEPROM 行为修复,但当时的验证 harness 都在 gitignore 的 `临时文件/`、跑完即弃——**没有入库资产能防这些修复被改回去**;单测层只锁纯逻辑(帧长表等),锁不住真机端到端行为。
+- **流程(brainstorming→writing-plans→inline 执行)**:经 brainstorming 拍三决策:① 形态=xUnit 门控集成(非 console runner);② 覆盖=默认只读、写路径显式开关;③ 动态 Skip 用 Xunit.SkippableFact。设计文档 `需求文档/specs/2026-06-23-HIL硬件在环回归套件-design.md` + 实现计划 `开发计划/2026-06-23-HIL硬件在环回归套件实现计划.md`。
+- **实现(纯新增工程,不动任何生产代码)**:新建 `ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests`(net6.0-windows,加入 control sln,ProjectReference IvfTl.Hardware,经真实 `SerialChannelImpl` 端到端):
+  - `Infrastructure/HardwareRigFixture.cs`:扫 COM 口(跳 COM1/2)逐口 Open+ShakeHands 收集响应真舱(ChamberInfo{Port,HouseSn}),collection fixture 全套件构造一次;`FirstChamberWithWells()` 排除舱11。
+  - `Infrastructure/HilCollection.cs` + `AssemblyInfo.cs`:`[CollectionDefinition("HIL")]` + `[assembly: CollectionBehavior(DisableTestParallelization=true)]`(串行,防多测试抢同一 COM 口)。
+  - `FocusZeroHilTests`(M-06):按 well 读焦点零点,断言去重>1 + 值∈[0,125000];`FrameLengthHilTests`(M-05):连读12轮断言全 sane;`EepromWriteHilTests`(M-01/02/03):写回环 `[SkippableFact]` 默认 Skip,仅 `HIL_ALLOW_WRITE=1` 才跑、写后立即恢复原值。
+  - `README.md`:用途/怎么跑/Skip 语义/安全口径。
+- **真机踩坑(逐舱取证)**:首版写回环用固定 `FirstChamberWithWells()`(恒舱9),开写开关跑时**M-03 灯光在舱9无项被动态 Skip→该命令永远得不到守护**。改 RoundTrip **逐舱(排除11)找到能读到该项的舱再验**(对齐原 EepromVerify 取证逻辑)——重跑后灯光自动落舱8 真验证。
+- **真机验证(UAC 静默提权;当前无 control 在跑、无活体培养,可自由跑)**:
+  - 默认零写入:`dotnet test` → FocusZero/FrameLength **2 过**(舱9 焦点零点 80200/79800/79300… 去重>1 与 raw 抓包诊断吻合;连读 12/12 干净),写测试 **2 Skip**。
+  - 开写开关:`HIL_ALLOW_WRITE=1 dotnet test` → **4/4 全过**(排气阀舱9 200→201→200;灯光逐舱落舱8 500→501→500,与原 harness 证据一致),值已恢复原值。
+- **核实**:真机两种模式实跑;control 全 sln + operate Release **双编译 0 错**;既有 SerialHelper **40 单测过**;codegraph sync 已跑(Already up to date);Xunit.SkippableFact 离线 restore 成功(未走回退)。僵尸 operate 20268 提权 taskkill 仍杀不掉(需重启清,符合既往记录),但未占舱口、不影响真机验证与编译。
+- **下一步**:工作计划剩延后专项(D2-02 调试页命令代理=多会话级设计→解锁 D3-04 删死栈 / D3-02 整机自启复测需真重启)+ 昨日建议配置收敛。均需用户定优先级或受控/重启。

+ 1 - 1
项目文档/进度/工作计划表.md

@@ -27,7 +27,7 @@
 |------|------|------|----------|
 | **阶段1** | control 独立进程骨架 | 🟢 代码完成·真机闭环打通(待并 main) | control 独立 exe 能起✓、HTTP探活/读状态✓、续命✓、单实例✓、硬件获取✓、**真机自控环运行✓**;阻塞闭环的 D1-08 串口握手死锁已修复 |
 | **阶段2** | 监控补全 + 调试借串口 + 受护栏停止 | 🟢 监控/受护栏停止/借串口让路 已实现+真机验;调试页完整驱动待设计 | 监控页跨进程 /status 显示完整✓;受护栏 /shutdown 安全停✓;/serial 让路✓(调试页完整借串口需命令代理设计+受监督真机) |
-| **阶段3** | 清理老壳 + 装机收尾 | 🟢 退役删ControlTest+部署文档+开机自启 已做;**D1-10 control oplog审计埋点已迁移+真机验证**;**D3-05 control崩溃看门狗已实现+真机验证(2026-06-23)**;删operate死栈延后 | 退役删 ivf_tl_ControlTest✓;双进程部署指南✓;开机自启✓;**D1-10 oplog审计迁移到control活栈✓**;**D3-05 看门狗(崩溃重拉/DPAPI凭据/可暂停停止卸载)✓**;ComBin删operate死栈(D3-04,被D2-02阻塞)仍延后 |
+| **阶段3** | 清理老壳 + 装机收尾 | 🟢 退役删ControlTest+部署文档+开机自启 已做;**D1-10 control oplog审计埋点已迁移+真机验证**;**D3-05 control崩溃看门狗已实现+真机验证(2026-06-23)**;**HIL硬件在环回归套件已入库+真机验证(2026-06-23)**;删operate死栈延后 | 退役删 ivf_tl_ControlTest✓;双进程部署指南✓;开机自启✓;**D1-10 oplog审计迁移到control活栈✓**;**D3-05 看门狗(崩溃重拉/DPAPI凭据/可暂停停止卸载)✓**;**HIL套件 IvfTl.Hardware.HilTests(守护M-05帧长/M-06按well焦点/M-01-03 EEPROM写,门控Skip+默认零写入)✓**;ComBin删operate死栈(D3-04,被D2-02阻塞)仍延后 |
 
 ---
 

+ 2 - 0
项目文档/进度/待验证清单.md

@@ -90,6 +90,8 @@
 | M-06 | `ReadWellFocusZero` 按 well 区分 | **已修复并真机验证**:`ComBin.ReadEEPROMvertMtStartPulseWait` 加 `well` 参(默认1,SerialBin 舱级读不变)、`SerialChannelImpl.ReadWellFocusZeroWait` 传真 well;builder 早支持 case 1-16(地址 0x08+4*(well-1))。集成层 red→green:经真实 SerialChannelImpl 各 well,修前全返回 well-1(舱9恒80200/舱8恒74600)→ 修后 per-well 各异(舱9 3值/舱8 5值,与 raw 诊断逐 well 吻合)。**对电机零影响**:消费方 CalibrationEngine 粗对焦用固定中心90000、eepromZ 实际未参与 Z 定位、且所有 Z 移动恒 ClampZ[0,125000] | **真机** | ☑ 已修复并真机验证(纯只读,无电机影响) |
 | M-07 | Release 连内网网关(非测试外网) | ✅ **已验证**:Release 排除 `#if DEBUG`(其 :108 行覆写到 test-gateway 外网);部署 `App.config` `outInter=0`(不触发 :87 外网覆写)+ `urlIp=http://127.0.0.1`+`urlPort=10010`→ BaseUrl=内网网关。已由阶段1 operate WPF 真外壳 Release E2E 真服务器登录成功 + control 10010 ESTABLISHED 坐实。现场换站点改 `urlIp` 即可 | 部署 | ☑ 已验通过 |
 
+> **2026-06-23 HIL 硬件在环回归套件入库 ☑(防 M-01/02/03/05/06 回归)**:新增入库 xUnit 工程 `ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests`(经真实 `SerialChannelImpl` 端到端),把上述 M 区真机检查固化为可重复回归用例,替代 gitignore 里跑完即弃的临时 harness。门控:`HardwareRigFixture` 扫口握手收响应真舱,`[SkippableFact]` 在无真舱/control 占口时 Skip 而非 Fail(常规 dotnet test/CI 永绿),禁并行串行跑。覆盖:`FocusZeroHilTests`(M-06 按 well 去重>1+安全Z区间)/`FrameLengthHilTests`(M-05 连读12轮全干净)纯读始终跑;`EepromWriteHilTests`(M-01/02/03 写回环)默认 Skip、`HIL_ALLOW_WRITE=1` 才跑、逐舱取证、写后立即恢复原值(默认零写入)。真机验证:零写入 2过2跳;开写开关 4/4 全过(排气阀舱9 200→201→200、灯光逐舱落舱8 500→501→500,与原 harness 证据一致;去重>1 与 raw 抓包诊断吻合)。详见 `需求文档/specs/2026-06-23-HIL硬件在环回归套件-design.md`。
+
 > **2026-06-23 M-01/M-02/M-03 ☑ 已修复并真机验证(TDD)**:
 > - **根因**:合并阶段 control 端 Commander 缺 3 个 E方 builder(`CreateWriteEEPROOpenVentTimeCommand`/`CreateReadEEPROMVentNum`/`CreateWriteEEPROMLightNum`),`SerialChannelImpl` 对应方法返回桩值(false/-1),写排气阀时间/读排气阀时间/写灯光亮度未真正下发下位机。
 > - **TDD red→green**:新建 `ivf_tl_SerialHelper.Tests`(net6.0 xUnit,已入 control sln),对 3 个 builder 断言精确字节(期望=合并前 operate 同名方法黄金真值,末位 CreateORC 校验);先 RED(方法不存在 CS1061),补 builder 后 **4 单测全绿**(含"写排气阀 vs 写进气阀仅地址低字节 0x08/0x0c 不同"交叉校验)。

+ 5 - 4
项目文档/进度/进度数据.js

@@ -1,10 +1,10 @@
 // 实时面板数据源(监控面板.html 读 window.PROGRESS_DATA)。每推进一步更新本文件。
 window.PROGRESS_DATA = {
   project: "operate/control 双进程拆分",
-  generatedAt: "2026-06-23 13:10",
-  phase: "三阶段主体完成;M区全闭合;D1-10 control oplog审计埋点已迁移真机验证;D3-05 control崩溃看门狗已实现真机验证",
-  currentTask: "D3-05 control崩溃看门狗(=昨日建议第2条单点续命缺口):独立常驻进程探活control崩溃自动重拉,DPAPI加密缓存首次凭据,受护栏/shutdown写停机标记区分崩溃vs故意停,--pause/--resume/--stop/--install/--uninstall三档手动控制可干净卸载。TDD 17单测+真机5项全过。",
-  note: "D3-05看门狗补单点续命缺口:operate→control只一次性拉起,control崩了原本无人重拉(尤其operate关闭后)。新增ivf_tl_Watchdog.Core(纯逻辑)+ivf_tl_Watchdog(WinExe:探活/重拉/退避/CLI)+.Tests;ControlHost Login成功DPAPI缓存凭据+清停机标记、SafeShutdown写停机标记。TDD red→green 17单测(Args/ShouldRelaunch真值表/DPAPI往返)。真机5项全过:①凭据缓存(246字节DPAPI密文不泄明文)②崩溃重拉(杀control→看门狗自动重拉新pid started:true)③故意停不重拉(受护栏/shutdown写control.stopped标记)④暂停让路(--pause不重拉/--resume恢复)⑤卸载干净(--install自启项→--uninstall删项+优雅退出)。踩坑:部署必拷watchdog全目录(deps.json+runtimes的Windows DPAPI),否则PlatformNotSupported(已写部署指南红线)。双编译0错+SerialHelper40单测过。剩余方向:昨日建议HIL套件/配置收敛,或D2-02命令代理(解锁D3-04删死栈)。",
+  generatedAt: "2026-06-23 17:30",
+  phase: "三阶段主体完成;M区全闭合;D1-10 oplog审计迁移真机验证;D3-05 看门狗真机验证;HIL硬件在环回归套件已入库真机验证",
+  currentTask: "HIL硬件在环回归套件入库(=昨日建议「HIL回归套件入库」):新增xUnit工程IvfTl.Hardware.HilTests经真实SerialChannelImpl端到端,把M-01/02/03/05/06真机检查固化为防回归用例,替代gitignore里跑完即弃的临时harness。门控Skip+默认零写入+写路径HIL_ALLOW_WRITE=1开关。真机零写入2过2跳、开写开关4/4全过。",
+  note: "HIL套件防M区修复被改回去:单测只锁纯逻辑(帧长表等),锁不住真机端到端行为;原验证harness都在gitignore临时目录跑完即弃,无入库护栏。经brainstorming→writing-plans→inline执行。新增ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests(net6.0-windows,入control sln,纯新增不动生产代码):HardwareRigFixture扫口握手收响应真舱+[SkippableFact](Xunit.SkippableFact)无真舱/被占即Skip+禁并行串行跑。覆盖FocusZero(M-06按well去重>1+安全Z区间)/FrameLength(M-05连读12轮全干净)纯读始终跑;EepromWrite(M-01/02/03写回环)默认Skip、HIL_ALLOW_WRITE=1才跑、逐舱取证、写后立即恢复原值。真机踩坑:首版写回环固定取舱9致M-03灯光(舱9无项)被永久跳过→改逐舱找到能读该项的舱再验,灯光自动落舱8真验证。真机:零写入FocusZero/FrameLength 2过+写2跳;开写4/4全过(排气阀舱9 200→201→200、灯光舱8 500→501→500、去重>1与raw抓包吻合)。control全sln+operate Release双编译0错+SerialHelper 40单测过+codegraph sync。剩余:D2-02命令代理(多会话设计→解锁D3-04)/D3-02整机自启(需重启)/配置收敛。",
   milestones: [
     { name: "阶段1 · control 独立进程骨架(完成)", tasks: [
       { id: "Task1-7", name: "全过+D1-08死锁修复+operate真外壳E2E+数据入库DB铁证", status: "☑" }
@@ -20,6 +20,7 @@ window.PROGRESS_DATA = {
       { id: "D3-02", name: "开机自启注册表方案验(整机复测需重启)", status: "◑" },
       { id: "D1-10", name: "control oplog审计埋点迁移到活栈(project=control真机入库)", status: "☑" },
       { id: "D3-05", name: "control崩溃看门狗(崩溃重拉/DPAPI凭据/可暂停停止卸载,TDD+真机5项)", status: "☑" },
+      { id: "HIL", name: "硬件在环回归套件IvfTl.Hardware.HilTests(守护M-05帧长/M-06按well焦点/M-01-03 EEPROM写;门控Skip+默认零写入,真机4/4)", status: "☑" },
       { id: "D3-04", name: "删operate死串口栈(去重·有风险删除·被D2-02阻塞)延后专项", status: "✗" }
     ]}
   ],

+ 10 - 10
项目文档/进度/进度状态.yaml

@@ -1,16 +1,16 @@
 # 续接断点状态(机器可解析)。换会话/换电脑后首先读它定位。
 # 状态取值: 未开始 / 进行中 / 完成 / 代码完成待验证
 # 纪律:本字段只存【当前断点】,历史细节进 交接卡.md(见 CLAUDE.md 第三节)。
-更新时间: 2026-06-23 D3-05 control 崩溃看门狗已实现并真机验证(独立进程探活+崩溃重拉,DPAPI 缓存凭据,TDD 17 单测 + 5 项真机全过)。= 昨日建议第2条(单点续命缺口)。当前无 control 在跑、无活体培养
+更新时间: 2026-06-23 HIL 硬件在环回归套件已入库并真机验证(= 昨日建议「HIL 回归套件入库」)。新增 IvfTl.Hardware.HilTests 守护 M-05/M-06/M-01-03 真机行为防回归。当前无 control 在跑、无活体培养(僵尸 operate 20268 仍在但不占舱口、需重启清)
 当前任务: >
-  【D3-05 control 崩溃看门狗 ☑ 完成】(= 昨日建议第2条:单点续命缺口)
-  · 新增 ivf_tl_Watchdog.Core(纯逻辑)+ ivf_tl_Watchdog(WinExe:探活循环/崩溃重拉/退避/CLI)+ .Tests;
-    ControlHost Login 成功 DPAPI 缓存凭据+清停机标记、SafeShutdown 写停机标记
-  · 看门狗不依赖 operate;--pause/--resume/--stop/--install/--uninstall 三档手动控制(可干净卸载);
-    受护栏 /shutdown 写 control.stopped → 看门狗不与人对着干
-  · TDD 17 单测 red→green;真机 5 项全过(凭据缓存/崩溃重拉/故意停不拉/暂停让路/卸载干净);双编译 0 错。
-  · 真机踩坑:看门狗部署必拷全目录(deps.json+runtimes 的 Windows DPAPI),否则 PlatformNotSupported(已写部署指南)
-  · 下一步:回到昨日建议剩余项(HIL 回归套件/配置收敛)或工作计划延后专项(D2-02 命令代理→解锁 D3-04),按用户优先级。
+  【HIL 硬件在环回归套件入库 ☑ 完成】(= 昨日建议「HIL 回归套件入库」)
+  · 新增 xUnit 工程 IvfTl.Hardware.HilTests(入 control sln,经真实 SerialChannelImpl 端到端):
+    HardwareRigFixture 扫口握手收响应真舱 + [SkippableFact] 无真舱/被占即 Skip + 禁并行串行跑
+  · 覆盖:FocusZero(M-06 按well去重>1)/FrameLength(M-05 连读12/12干净)纯读始终跑;
+    EepromWrite(M-01/02/03 写回环)默认 Skip,HIL_ALLOW_WRITE=1 才跑、逐舱取证、写后恢复原值(零写入默认)
+  · 真机:零写入跑 2过2跳;开写开关 4/4 全过(排气阀舱9 200→201→200、灯光逐舱落舱8 500→501→500);
+    去重>1 与 raw 抓包诊断吻合;control全sln+operate Release 双编译0错;既有40单测过
+  · 下一步:剩工作计划延后专项(D2-02 命令代理需多会话设计→解锁 D3-04 / D3-02 整机自启需重启)+ 昨日建议配置收敛。按用户优先级。
 说明: >
   operate/control 双进程拆分三阶段主体早已完成;合并遗留 M 区 M-01~M-07 本轮全部闭合
   (M-01/02/03 builder去桩、M-04 存图代码定论、M-05 0x12帧长回归、M-06 按well焦点零点、M-07 网关)。
@@ -36,4 +36,4 @@
     名称: 清理老壳 + 装机收尾
     状态: 未开始
     备注: "退役删ivf_tl_ControlTest脏壳 + operate开机自启 + ComBin两套栈去重(G1-2) + 部署文档。待阶段2完成后拆计划"
-下一步: D3-05 看门狗已完成并真机验证。可选方向:昨日建议剩余项(HIL 回归套件入库 / 配置收敛)或工作计划延后专项(D2-02 调试页命令代理设计→解锁 D3-04 删死栈;整机开机自启复测需重启)。按用户优先级。
+下一步: HIL 回归套件入库已完成并真机验证。剩工作计划延后专项(D2-02 命令代理设计→解锁 D3-04 删死栈 / D3-02 整机自启需重启)+ 昨日建议配置收敛。按用户优先级。

+ 74 - 0
项目文档/需求文档/specs/2026-06-23-HIL硬件在环回归套件-design.md

@@ -0,0 +1,74 @@
+# HIL 硬件在环回归套件 — 设计文档
+
+> 日期:2026-06-23 | 任务:把已真机验证的一次性 harness 沉淀为永久入库的硬件在环(HIL)回归套件,防 M 区修复被回退。
+> 关联:M-01/02/03(EEPROM 读写去桩)、M-05(0x12 帧长回归)、M-06(按 well 焦点零点)。源 harness 见 `临时文件/{EepromVerify,FrameFixVerify,FocusZeroVerify}`(gitignore,跑完弃)。
+
+## 一、背景与目标
+
+合并改造遗留的 M 区缺陷(M-01~M-07)本轮全部闭合,其中 M-01/02/03/05/06 是**经真机验证**的串口/EEPROM 行为修复。验证当时写的是 `临时文件/` 下的一次性 console harness(在 `.gitignore` 内,不入库),它们承载了"这些修复在真机上确实成立"的证据逻辑,但跑完即弃 —— **没有任何入库资产能在以后防止这些修复被改回去**。
+
+- 单测层(`ivf_tl_SerialHelper.Tests/CustomProtocolLengthTests` 等)已锁住**纯逻辑**(如帧长表 0x12=12),但锁不住**真机端到端行为**(命令真下发、读回随写变化、按 well 分槽)。
+- 这类验证连真机 COM 口、需 control 未跑时跑、无法 CI 无人值守 —— 属典型**硬件在环(HIL)**测试。
+
+**目标**:新增一个**永久入库**的 xUnit HIL 测试工程,把上述真机检查逻辑固化为可重复运行的回归用例;真机不在(或被 control 占口)时**优雅 Skip**,使常规 `dotnet test`/CI 永绿;上机跑时真正验证。**纯新增,不改动任何生产代码。**
+
+**非目标**:不做 CI 自动化(HIL 本质需真机);不收录 raw 抓包诊断工具;不触碰任何电机;不引入新的串口写命令。
+
+## 二、形态与位置
+
+- 新建工程 **`IvfTl.Hardware.HilTests`**,位于 `ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests/`,加入 `ivf_tl_Control.sln`。
+- 框架/包对齐现有 4 个 `.Tests` 工程:`net6.0-windows`、`Microsoft.NET.Test.Sdk 17.6.0`、`xunit 2.4.2`、`xunit.runner.visualstudio 2.4.5`;`IsPackable=false`。
+- 仅 `ProjectReference` → `..\IvfTl.Hardware\IvfTl.Hardware.csproj`(经真实 `SerialChannelImpl` 端到端,与去桩后 control 走同一路径)。
+- 动态 Skip 用 `Xunit.SkippableFact` 包(`[SkippableFact]` + `Skip.If`)。**回退方案**:若本机离线 restore 不到该包,改为无依赖自定义 `[HilFact]`(无硬件时记日志早返回,报告为 Passed 而非 Skipped)——优先用 SkippableFact 以获得诚实的 Skipped 状态。
+
+## 三、门控机制(核心)
+
+真机不在、无 COM 口、或 control 正占用串口时,测试必须 **Skip 而非 Fail**,否则常规构建会误红。
+
+- **`HardwareRigFixture`**(xUnit collection fixture,全套件构造一次):
+  - 构造时扫描 `SerialPort.GetPortNames()`,跳过 `COM1`/`COM2`。
+  - 逐口尝试 `new SerialChannelImpl(0, port)` → `Open()` → `ShakeHandsWait()`。握手返回有效舱号(≥0)即记为一个**响应真舱**(`ChamberInfo { Port, HouseSn }`),随即 `Close()` 释放(各测试用时再各自重开)。
+  - 暴露 `IReadOnlyList<ChamberInfo> Chambers`。打不开/握手失败的口(没硬件、或 control 占口)不计入。
+  - `IDisposable`:确保扫描期所有探测连接关闭。
+- **`[Collection("HIL")]`** + **`[assembly: CollectionBehavior(DisableTestParallelization = true)]`**:所有测试类共享同一 fixture 且**串行**执行,杜绝多测试并发抢同一 COM 口。
+- 每个测试头部:`Skip.If(rig.Chambers.Count == 0, "无响应真舱:无硬件 / control 正占用串口 / 未连接")`。
+- 舱级筛选:需特定模块的测试跳过缓冲瓶/总控舱(`HouseSn == 11`,无舱室排气阀/灯光 E方);取证够即停(参照源 harness:取 1~3 舱即结束,避免逐舱长跑)。
+
+## 四、覆盖用例(默认只读,写路径显式开关)
+
+| 测试类 | 守护缺陷 | 类型 | 断言要点 |
+|---|---|---|---|
+| `FocusZeroHilTests` | M-06 按 well 焦点零点 | 纯读 0x11,无电机 | 对一个真舱读 well 1~16 的 `ReadWellFocusZeroWait(well)`,**去重数 > 1**(证按 well 分槽,非恒读 well-1);所有值落在安全 Z 区间 `[0,125000]` |
+| `FrameLengthHilTests` | M-05 0x12 帧长(=12)修复 | 纯读(连续读、不写) | 对真舱连续多轮调 `ReadOpenVentTimeWait()`/`ReadWellFocusZeroWait`,**全部得 sane 非负值、无 -1/错位污染**(帧长错会导致残留字节污染紧接的读) |
+| `EepromWriteHilTests` | M-01/02/03 写命令真下发到下位机 | 写回环(非破坏) | **`[SkippableFact]` 默认 Skip**,仅环境变量 `HIL_ALLOW_WRITE=1` 才执行;执行时:读 V0 → 写 V0+1 → 读==V0+1 → **写回 V0** → 读==V0(立即恢复原值,设备状态不变);覆盖排气阀时间(M-01写/M-02读)、灯光亮度(M-03写) |
+
+**安全口径**:默认 `dotnet test` **零写入**(只跑两个纯读测试 + 写测试被 Skip)。写回环仅在显式开关下跑,且每次写后立即恢复原值——与已真机验证非破坏的 `EepromVerify`/`FrameFixVerify` 同口径。全程不动任何电机。
+
+## 五、不收录
+
+- `FrameLenProbe`(raw 裸 `SerialPort` 抓包诊断,128 行):是定位 0x12 帧长根因时的一次性诊断工具,非断言测试。帧长表已由 `CustomProtocolLengthTests`(22 单测)逐项锁死;真机读干净度由本套件 `FrameLengthHilTests` 守护。该探针留作临时诊断,跑完清,不入库。
+
+## 六、文件清单(预期)
+
+```
+ivf_tl_operate_2.0/control/IvfTl.Hardware.HilTests/
+├── IvfTl.Hardware.HilTests.csproj      # net6.0-windows + xunit + SkippableFact + ref IvfTl.Hardware
+├── AssemblyInfo.cs                      # [assembly: CollectionBehavior(DisableTestParallelization = true)]
+├── Infrastructure/
+│   ├── HardwareRigFixture.cs           # 扫口+握手,收集响应真舱;collection fixture
+│   └── HilCollection.cs                # [CollectionDefinition("HIL")] ICollectionFixture<HardwareRigFixture>
+├── FocusZeroHilTests.cs                # M-06 按 well 读(纯读)
+├── FrameLengthHilTests.cs             # M-05 连续读干净度(纯读)
+├── EepromWriteHilTests.cs             # M-01/02/03 写回环([SkippableFact] 默认 Skip,HIL_ALLOW_WRITE=1 开)
+└── README.md                          # 怎么跑/安全说明/Skip 语义
+```
+
+## 七、验收
+
+1. `control` sln + `operate` Release **双编译 0 错**(新工程不破坏既有构建)。
+2. `dotnet test IvfTl.Hardware.HilTests`:
+   - 真机在 + control 未占口 → 纯读测试**真验证通过**(去重>1、读干净),写测试 Skipped。
+   - `HIL_ALLOW_WRITE=1` 下重跑 → 写回环测试真验证通过,值已恢复原值。
+   - 真机不在/control 占口 → 全部 **Skipped**(非 Failed)。
+3. 既有 40 单测仍全过;`codegraph sync` 已跑。
+4. 文档同步(工作计划表/进度状态.yaml/交接卡/待验证清单 对应行)后再提交(提交边界 = 文档已对齐)。