2026-06-23-阶段2-D2-02-调试页命令代理实现计划.md 44 KB

D2-02 调试页命令代理 实现计划

For agentic workers: REQUIRED SUB-SKILL: 用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 按任务逐个实现。步骤用 - [ ] 复选框跟踪。 设计依据:需求文档/specs/2026-06-23-D2-02-调试页命令代理-design.md

Goal: operate 调试页在双进程下经 control 本地 HTTP 跨进程借用某舱、连续操作硬件、看实时预览、归还恢复采集。

Architecture: control 新增"调试会话"后端(会话表 + 心跳 TTL 自动回收 + 通用命令分发 + 红线钳位),挂在现有 ControlHttpServer 上;operate 新增 DebugSessionClient/MjpegStreamClient,2 个调试 VM 改走 HTTP。安全地基是"租约+心跳+control端超时自动回收",扛 operate 崩溃/丢消息。

Tech Stack: C# net6.0-windows;xUnit(control 单测);System.Net.HttpListener(control)、HttpClient(operate);Newtonsoft.Json。


子计划拆分(Scope Check)

D2-02 含三个有依赖顺序、各自可独立验证的子系统。本文档完整写第一阶段,后两阶段列提纲(待第一阶段真机验证后各自展开为独立计划):

阶段 子系统 验证方式 本文档
control 端会话后端(acquire/command/release/heartbeat + 钳位 + 超时回收) 纯 xUnit 单测 + curl 真机 bite-sized 完整
MJPEG 预览(control 推流 + operate 解码) 真机出图 提纲(§后续)
operate 接入(2 client + 2 VM + 2 View)+ 真机 V-012 真机门控 提纲(§后续)

第一阶段 · 文件结构

文件 责任
Create control/ivf_tl_ControlHost/Debug/DebugSession.cs 单个会话的状态(sessionId/houseSn/lease/最后心跳/当前电机位)
Create control/ivf_tl_ControlHost/Debug/MotorClamp.cs 红线钳位纯函数(水平/垂直边界 + 相对运动算目标位)
Create control/ivf_tl_ControlHost/Debug/DebugCommandResult.cs 命令返回体(ok/result/error/code)
Create control/ivf_tl_ControlHost/Debug/DebugSessionManager.cs 会话表 + acquire/release/heartbeat/execute + SweepExpired;依赖 IHardwareAccessLayer + 注入时钟,可纯单测
Modify control/ivf_tl_ControlHost/ControlHttpServer.cs /debug/* 路由,转给注入的 manager
Modify control/ivf_tl_ControlHost/Program.cs 装配 DebugSessionManager + 起 TTL 看门狗线程
Create control/IvfTl.ControlHost.Tests/ xUnit 工程(net6.0-windows),加入 control sln

钳位常量(spec §10):水平 [0, 220000]、垂直 Z [0, 125000]


Task 0: 新建 control 单测工程

Files:

  • Create: ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/IvfTl.ControlHost.Tests.csproj
  • Modify: ivf_tl_operate_2.0/control/ivf_tl_Control.sln

  • [ ] Step 1: 建 csproj

    <Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
    <TargetFramework>net6.0-windows</TargetFramework>
    <IsPackable>false</IsPackable>
    <Nullable>disable</Nullable>
    <LangVersion>latest</LangVersion>
    </PropertyGroup>
    <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
    <PackageReference Include="xunit" Version="2.6.2" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.5.4" />
    </ItemGroup>
    <ItemGroup>
    <ProjectReference Include="..\ivf_tl_ControlHost\ivf_tl_ControlHost.csproj" />
    <ProjectReference Include="..\IvfTl.Hardware\IvfTl.Hardware.csproj" />
    </ItemGroup>
    </Project>
    
  • [ ] Step 2: 加入解决方案

Run: dotnet sln ivf_tl_operate_2.0/control/ivf_tl_Control.sln add ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/IvfTl.ControlHost.Tests.csproj Expected: Project ... added to the solution.

  • Step 3: 占位测试确认工程能编译能跑

Create ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/SmokeTest.cs:

using Xunit;
namespace IvfTl.ControlHost.Tests
{
    public class SmokeTest
    {
        [Fact] public void Smoke() => Assert.True(true);
    }
}
  • Step 4: 跑通

Run: dotnet test ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/IvfTl.ControlHost.Tests.csproj Expected: PASS(1 passed)。若 ControlHost 是 WinExe 被引用报错,在其 csproj 确认 OutputType 仍可被测试工程 ProjectReference(net6 可引用 exe 程序集)。

  • [ ] Step 5: Commit

    git add ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests ivf_tl_operate_2.0/control/ivf_tl_Control.sln
    git commit -m "test(d2-02): 新建 control 单测工程 IvfTl.ControlHost.Tests"
    

Task 1: DebugCommandResult + DebugSession 数据类

Files:

  • Create: ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/DebugCommandResult.cs
  • Create: ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/DebugSession.cs

  • [ ] Step 1: DebugCommandResult

    using Newtonsoft.Json;
    namespace IvfTl.ControlHost.Debug
    {
    /// <summary>命令/借用统一返回体。code 见 spec §4。</summary>
    public class DebugCommandResult
    {
        [JsonProperty("ok")] public bool Ok { get; set; }
        [JsonProperty("result", NullValueHandling = NullValueHandling.Ignore)] public object Result { get; set; }
        [JsonProperty("error", NullValueHandling = NullValueHandling.Ignore)] public string Error { get; set; }
        [JsonProperty("code", NullValueHandling = NullValueHandling.Ignore)] public string Code { get; set; }
    
        public static DebugCommandResult Okay(object result = null) => new DebugCommandResult { Ok = true, Result = result };
        public static DebugCommandResult Fail(string code, string error) => new DebugCommandResult { Ok = false, Code = code, Error = error };
    }
    }
    
  • [ ] Step 2: DebugSession

    using System;
    using IvfTl.Hardware;
    namespace IvfTl.ControlHost.Debug
    {
    /// <summary>一次调试借用会话。lease 是 control 进程内借到的舱句柄;LastSeen 用注入时钟刷新。</summary>
    public sealed class DebugSession
    {
        public string SessionId { get; }
        public int HouseSn { get; }
        public IHardwareLease Lease { get; }
        public DateTime LastSeen { get; set; }
        // 相对运动钳位用:control 端跟踪的当前电机位(初值 -1 = 未知)。
        public int CurrentHor { get; set; } = -1;
        public int CurrentVer { get; set; } = -1;
    
        public DebugSession(string sessionId, int houseSn, IHardwareLease lease, DateTime now)
        {
            SessionId = sessionId; HouseSn = houseSn; Lease = lease; LastSeen = now;
        }
    }
    }
    
  • [ ] Step 3: 编译

Run: dotnet build ivf_tl_operate_2.0/control/ivf_tl_ControlHost/ivf_tl_ControlHost.csproj -c Debug Expected: 0 错(operate.exe 不在跑,不锁 DLL)。

  • [ ] Step 4: Commit

    git add ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug
    git commit -m "feat(d2-02): control 调试会话数据类(DebugSession/DebugCommandResult)"
    

Task 2: MotorClamp 红线钳位(TDD)

Files:

  • Create: ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/MotorClamp.cs
  • Test: ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/MotorClampTests.cs

  • [ ] Step 1: 写失败测试

    using IvfTl.ControlHost.Debug;
    using Xunit;
    namespace IvfTl.ControlHost.Tests
    {
    public class MotorClampTests
    {
        [Theory]
        [InlineData(0, true)]
        [InlineData(125000, true)]
        [InlineData(90000, true)]
        [InlineData(-1, false)]
        [InlineData(125001, false)]
        public void Vertical_InRange(int pulse, bool ok)
            => Assert.Equal(ok, MotorClamp.IsVerticalInRange(pulse));
    
        [Theory]
        [InlineData(0, true)]
        [InlineData(220000, true)]
        [InlineData(220001, false)]
        [InlineData(-5, false)]
        public void Horizontal_InRange(int pulse, bool ok)
            => Assert.Equal(ok, MotorClamp.IsHorizontalInRange(pulse));
    
        [Fact] // 相对运动:当前位 + 增量算目标位再判定
        public void Relative_Target_Computed()
        {
            Assert.Equal(90000, MotorClamp.RelativeTarget(80000, 10000)); // forward
            Assert.False(MotorClamp.IsVerticalInRange(MotorClamp.RelativeTarget(120000, 10000))); // 130000 越界
        }
    }
    }
    
  • [ ] Step 2: 跑验证失败

Run: dotnet test ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests --filter MotorClampTests Expected: FAIL("MotorClamp 不存在")。

  • [ ] Step 3: 实现 MotorClamp

    namespace IvfTl.ControlHost.Debug
    {
    /// <summary>红线电机钳位(spec §10)。水平[0,220000]、垂直Z[0,125000]。纯函数,无副作用。</summary>
    public static class MotorClamp
    {
        public const int HorMin = 0, HorMax = 220000;
        public const int VerMin = 0, VerMax = 125000;
    
        public static bool IsHorizontalInRange(int pulse) => pulse >= HorMin && pulse <= HorMax;
        public static bool IsVerticalInRange(int pulse) => pulse >= VerMin && pulse <= VerMax;
    
        /// <summary>当前位 + 增量 = 目标绝对位(forward 传正、backward 传负)。</summary>
        public static int RelativeTarget(int current, int delta) => current + delta;
    }
    }
    
  • [ ] Step 4: 跑验证通过

Run: dotnet test ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests --filter MotorClampTests Expected: PASS(全绿)。

  • [ ] Step 5: Commit

    git add ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/MotorClamp.cs ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/MotorClampTests.cs
    git commit -m "feat(d2-02): 红线电机钳位 MotorClamp + 单测"
    

Task 3: 测试替身 FakeHardware(借用闸门 + 串口)

DebugSessionManager 依赖 IHardwareAccessLayer/IHouseGate/IHardwareLease/ISerialChannel(全是接口)。手写 fake 记录调用,不引 Moq。

Files:

  • Create: ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/Fakes/FakeHardware.cs

  • [ ] Step 1: 写 fake(无测试,仅替身)

    using System;
    using System.Collections.Generic;
    using IvfTl.Hardware;
    namespace IvfTl.ControlHost.Tests.Fakes
    {
    // 记录被调方法名;电机/写操作记参数,供断言分发正确。
    public class FakeSerial : ISerialChannel
    {
        public List<string> Calls = new List<string>();
        public bool IsOpen => true;
        public string PortName => "COMTEST";
        public Action<string> Log { get; set; }
        public int MoveReadTimeoutMs { get; set; }
        public int QueryReadTimeoutMs { get; set; }
        public int MotorSettleMs { get; set; }
        public bool Open() { Calls.Add("Open"); return true; }
        public void Close() => Calls.Add("Close");
        public byte[] SendWait(byte[] f, int e = 0) { Calls.Add("SendWait"); return null; }
        public int ShakeHandsWait() { Calls.Add("ShakeHands"); return 5; }
        public int ReadCcdSnWait() => -1;
        public int ReadLightBrightnessWait() { Calls.Add("ReadLight"); return 500; }
        public int ReadWellHorizontalPosWait(int w) => -1;
        public int ReadWellFocusZeroWait(int w) => -1;
        public int ReadScanStepWait() => -1;
        public bool WriteWellHorizontalPosWait(int well, int p) { Calls.Add($"WriteWellHor({well},{p})"); return true; }
        public bool WriteScanStepWait(int p) { Calls.Add($"WriteScanStep({p})"); return true; }
        public bool WriteOpenIntakeTimeWait(int v, bool b = false) { Calls.Add($"WriteIntake({v})"); return true; }
        public bool WriteOpenVentTimeWait(int v) { Calls.Add($"WriteVent({v})"); return true; }
        public int ReadOpenVentTimeWait() { Calls.Add("ReadVent"); return 200; }
        public bool WriteLightBrightnessWait(int v) { Calls.Add($"WriteLight({v})"); return true; }
        public bool VerticalMoveToWait(int p, int d = -1) { Calls.Add($"VMoveTo({p})"); return true; }
        public bool VerticalForwardWait(int p, int d = -1) { Calls.Add($"VFwd({p})"); return true; }
        public bool VerticalBackwardWait(int p, int d = -1) { Calls.Add($"VBack({p})"); return true; }
        public bool VerticalResetWait(int d = -1) { Calls.Add("VReset"); return true; }
        public int ReadVerticalPositionWait() => 0;
        public bool HorizontalMoveToWait(int p, int d = -1) { Calls.Add($"HMoveTo({p})"); return true; }
        public bool HorizontalForwardWait(int p, int d = -1) { Calls.Add($"HFwd({p})"); return true; }
        public bool HorizontalBackwardWait(int p, int d = -1) { Calls.Add($"HBack({p})"); return true; }
        public bool HorizontalResetWait(int d = -1) { Calls.Add("HReset"); return true; }
        public int ReadHorizontalPositionWait() => 0;
        public decimal TemperatureWait() { Calls.Add("Temp"); return 37.0m; }
        public decimal ShangTemperatureWait() => 37.0m;
        public decimal BoLiTemperatureWait() => 37.0m;
        public decimal PressureWait() { Calls.Add("Pressure"); return 1.0m; }
        public (decimal, decimal, decimal) BufferBottleStateWait() => (1m, 37m, 37m);
        public DoorState DoorStatusWait() { Calls.Add("Door"); return DoorState.关闭; }
        public bool OpenLedWait() { Calls.Add("OpenLed"); return true; }
        public bool CloseLedWait() { Calls.Add("CloseLed"); return true; }
        public bool OpenIntakeValveWait() { Calls.Add("OpenIntake"); return true; }
        public bool CloseIntakeValveWait() { Calls.Add("CloseIntake"); return true; }
        public bool OpenExhaustValveWait() { Calls.Add("OpenExhaust"); return true; }
        public bool CloseExhaustValveWait() { Calls.Add("CloseExhaust"); return true; }
        public bool HouseAerationWait() { Calls.Add("Aeration"); return true; }
        public bool HouseVentWait() { Calls.Add("Vent"); return true; }
        public bool BufferBottleAerationWait() => true;
        public bool AutoAirSwapWait(bool on) => true;
        public object RawComBin => null;
        public void Dispose() => Calls.Add("Dispose");
    }
    
    public class FakeLease : IHardwareLease
    {
        public HardwareUser Owner { get; }
        public ISerialChannel Serial { get; }
        public ICamera Camera => null;
        public bool Disposed;
        public FakeLease(ISerialChannel s, HardwareUser u) { Serial = s; Owner = u; }
        public void Dispose() => Disposed = true;
    }
    
    // 可控是否借得到(模拟 BUSY)。
    public class FakeGate : IHouseGate
    {
        private readonly ISerialChannel _serial;
        public bool CanAcquire = true;
        public FakeLease LastLease;
        public FakeGate(int sn, ISerialChannel s) { HouseSn = sn; _serial = s; }
        public int HouseSn { get; }
        public bool IsCapturePaused { get; private set; }
        public event Action OnPauseCapture;
        public event Action OnResumeCapture;
        public IHardwareLease Acquire(HardwareUser u, int t = 30000)
        {
            if (!CanAcquire) return null;
            IsCapturePaused = true;
            LastLease = new FakeLease(_serial, u);
            return LastLease;
        }
        public bool TryAcquire(HardwareUser u, out IHardwareLease l) { l = Acquire(u); return l != null; }
        public void PauseCapture() => IsCapturePaused = true;
        public void ResumeCapture() => IsCapturePaused = false;
    }
    }
    
  • [ ] Step 2: 编译测试工程

Run: dotnet build ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests Expected: 0 错(确认 fake 实现了 ISerialChannel 全部成员;若漏成员编译器会报)。

  • [ ] Step 3: Commit

    git add ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/Fakes
    git commit -m "test(d2-02): 测试替身 FakeSerial/FakeLease/FakeGate"
    

Task 4: DebugSessionManager — acquire / release / 幂等(TDD)

Files:

  • Create: ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/DebugSessionManager.cs
  • Test: ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/DebugSessionManagerTests.cs

manager 不直接依赖 HardwareAccessLayer 单例,而是注入"按舱取 gate"的委托 Func<int,IHouseGate> + 注入时钟 Func<DateTime>,以便单测。

  • [ ] Step 1: 写失败测试

    using System;
    using IvfTl.ControlHost.Debug;
    using IvfTl.ControlHost.Tests.Fakes;
    using Xunit;
    namespace IvfTl.ControlHost.Tests
    {
    public class DebugSessionManagerTests
    {
        private DateTime _now = new DateTime(2026, 6, 23, 0, 0, 0);
        private (DebugSessionManager mgr, FakeGate gate) New(bool canAcquire = true)
        {
            var serial = new FakeSerial();
            var gate = new FakeGate(5, serial) { CanAcquire = canAcquire };
            var mgr = new DebugSessionManager(sn => gate, () => _now, ttlMs: 10000, log: _ => { });
            return (mgr, gate);
        }
    
        [Fact]
        public void Acquire_Returns_SessionId_And_Pauses()
        {
            var (mgr, gate) = New();
            var r = mgr.Acquire(5);
            Assert.True(r.Ok);
            Assert.False(string.IsNullOrEmpty((string)r.Result));
            Assert.True(gate.IsCapturePaused);
        }
    
        [Fact]
        public void Acquire_Busy_Returns_BUSY()
        {
            var (mgr, _) = New(canAcquire: false);
            var r = mgr.Acquire(5);
            Assert.False(r.Ok);
            Assert.Equal("BUSY", r.Code);
        }
    
        [Fact]
        public void Release_Disposes_Lease_And_Resumes()
        {
            var (mgr, gate) = New();
            string sid = (string)mgr.Acquire(5).Result;
            var r = mgr.Release(sid);
            Assert.True(r.Ok);
            Assert.True(gate.LastLease.Disposed);
        }
    
        [Fact]
        public void Release_Unknown_Session_Is_Idempotent_Ok()
        {
            var (mgr, _) = New();
            Assert.True(mgr.Release("not-a-session").Ok); // 幂等:不认识也回 ok
        }
    }
    }
    
  • [ ] Step 2: 跑验证失败

Run: dotnet test ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests --filter DebugSessionManagerTests Expected: FAIL("DebugSessionManager 不存在")。

  • [ ] Step 3: 实现 acquire/release(命令分发下个 Task 补)

    using System;
    using System.Collections.Concurrent;
    using IvfTl.Hardware;
    namespace IvfTl.ControlHost.Debug
    {
    /// <summary>
    /// control 端调试会话后端:会话表 + 借用/归还 + 心跳续约 + 超时自动回收 + 命令分发。
    /// 安全地基(spec §5):绝不指望 operate 主动还,SweepExpired 超时回收兜底。
    /// </summary>
    public sealed class DebugSessionManager
    {
        private readonly Func<int, IHouseGate> _gateOf;
        private readonly Func<DateTime> _clock;
        private readonly int _ttlMs;
        private readonly Action<string> _log;
        private readonly ConcurrentDictionary<string, DebugSession> _sessions = new ConcurrentDictionary<string, DebugSession>();
    
        public DebugSessionManager(Func<int, IHouseGate> gateOf, Func<DateTime> clock, int ttlMs, Action<string> log)
        {
            _gateOf = gateOf; _clock = clock; _ttlMs = ttlMs; _log = log ?? (_ => { });
        }
    
        public DebugCommandResult Acquire(int houseSn)
        {
            var gate = _gateOf(houseSn);
            if (gate == null) return DebugCommandResult.Fail("NO_HANDLE", $"舱{houseSn}无闸门");
            var lease = gate.Acquire(HardwareUser.OperateDebug);
            if (lease == null) return DebugCommandResult.Fail("BUSY", $"舱{houseSn}被占用,借用超时");
            string sid = Guid.NewGuid().ToString("N");
            _sessions[sid] = new DebugSession(sid, houseSn, lease, _clock());
            _log($"[debug] acquire 舱{houseSn} sid={sid}");
            return DebugCommandResult.Okay(sid);
        }
    
        public DebugCommandResult Heartbeat(string sid)
        {
            if (sid != null && _sessions.TryGetValue(sid, out var s)) { s.LastSeen = _clock(); return DebugCommandResult.Okay(); }
            return DebugCommandResult.Fail("SESSION_EXPIRED", "会话不存在或已过期");
        }
    
        public DebugCommandResult Release(string sid)
        {
            // 幂等:不认识的 sid 也回 ok(spec §5.3)。
            if (sid != null && _sessions.TryRemove(sid, out var s))
            {
                try { s.Lease.Dispose(); } catch { }
                _log($"[debug] release sid={sid} 舱{s.HouseSn}");
            }
            return DebugCommandResult.Okay();
        }
    
        /// <summary>超时回收:LastSeen + ttl < now 的会话自动归还(spec §5.1)。看门狗线程周期调,也可单测直接调。</summary>
        public int SweepExpired()
        {
            int n = 0; var now = _clock();
            foreach (var kv in _sessions)
            {
                if ((now - kv.Value.LastSeen).TotalMilliseconds > _ttlMs)
                {
                    if (_sessions.TryRemove(kv.Key, out var s))
                    {
                        try { s.Lease.Dispose(); } catch { }
                        _log($"[debug] 会话超时自动回收 sid={kv.Key} 舱{s.HouseSn}");
                        n++;
                    }
                }
            }
            return n;
        }
    }
    }
    
  • [ ] Step 4: 跑验证通过

Run: dotnet test ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests --filter DebugSessionManagerTests Expected: PASS(4 绿)。

  • [ ] Step 5: Commit

    git add ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/DebugSessionManager.cs ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/DebugSessionManagerTests.cs
    git commit -m "feat(d2-02): DebugSessionManager acquire/release/heartbeat + 幂等单测"
    

Task 5: 超时自动回收(TDD) — 安全地基

Files:

  • Modify Test: ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/DebugSessionManagerTests.cs

  • [ ] Step 1: 加失败测试(可控时钟快进)

        [Fact]
        public void Sweep_Reclaims_After_Ttl_And_Resumes()
        {
            var (mgr, gate) = New();
            string sid = (string)mgr.Acquire(5).Result;
            _now = _now.AddMilliseconds(11000); // 超过 ttl 10s
            int reclaimed = mgr.SweepExpired();
            Assert.Equal(1, reclaimed);
            Assert.True(gate.LastLease.Disposed);
            Assert.False(gate.IsCapturePaused); // 恢复采集
            Assert.Equal("SESSION_EXPIRED", mgr.Heartbeat(sid).Code); // 会话已没
        }
    
        [Fact]
        public void Heartbeat_Keeps_Session_Alive()
        {
            var (mgr, gate) = New();
            string sid = (string)mgr.Acquire(5).Result;
            _now = _now.AddMilliseconds(8000); mgr.Heartbeat(sid); // 续约
            _now = _now.AddMilliseconds(8000); // 距上次心跳仅 8s < ttl
            Assert.Equal(0, mgr.SweepExpired()); // 不回收
            Assert.False(gate.LastLease.Disposed);
        }
    
  • [ ] Step 2: 跑验证

Run: dotnet test ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests --filter DebugSessionManagerTests Expected: PASS(6 绿)。Task4 的实现已满足;若 IsCapturePaused 未随 Dispose 复位,检查 FakeGate.FakeLease.Dispose 是否回调 gate.ResumeCapture——真实 HardwareLeaseImpl.Dispose 会(见 HouseGateImpl.Release),fake 需补:在 FakeLease.Dispose 里调 gate.ResumeCapture。

  • [ ] Step 3: 若需要,修 FakeLease 让 Dispose 触发 Resume

    // FakeLease 改为持 gate 引用,Dispose 时恢复(对齐真实 HardwareLeaseImpl):
    public class FakeLease : IHardwareLease
    {
        private readonly FakeGate _gate;
        public HardwareUser Owner { get; }
        public ISerialChannel Serial { get; }
        public ICamera Camera => null;
        public bool Disposed;
        public FakeLease(FakeGate gate, ISerialChannel s, HardwareUser u) { _gate = gate; Serial = s; Owner = u; }
        public void Dispose() { Disposed = true; _gate.ResumeCapture(); }
    }
    // FakeGate.Acquire 内改为 new FakeLease(this, _serial, u);
    
  • [ ] Step 4: 跑通

Run: dotnet test ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests --filter DebugSessionManagerTests Expected: PASS(6 绿)。

  • [ ] Step 5: Commit

    git add ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests
    git commit -m "test(d2-02): 会话超时自动回收 + 心跳续约(安全地基)"
    

Task 6: 命令分发 Execute — 读数/握手/阀/LED(TDD)

Files:

  • Modify: ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/DebugSessionManager.cs
  • Test: ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/DebugExecuteTests.cs

  • [ ] Step 1: 写失败测试

    using IvfTl.ControlHost.Debug;
    using IvfTl.ControlHost.Tests.Fakes;
    using Newtonsoft.Json.Linq;
    using System;
    using Xunit;
    namespace IvfTl.ControlHost.Tests
    {
    public class DebugExecuteTests
    {
        private DateTime _now = new DateTime(2026, 6, 23);
        private (DebugSessionManager mgr, FakeSerial serial, string sid) New()
        {
            var serial = new FakeSerial();
            var gate = new FakeGate(5, serial);
            var mgr = new DebugSessionManager(sn => gate, () => _now, 10000, _ => { });
            string sid = (string)mgr.Acquire(5).Result;
            return (mgr, serial, sid);
        }
    
        [Fact] public void Execute_ReadTemp_Dispatches()
        {
            var (mgr, serial, sid) = New();
            var r = mgr.Execute(sid, "ReadTemp", null);
            Assert.True(r.Ok);
            Assert.Contains("Temp", serial.Calls);
        }
    
        [Fact] public void Execute_OpenLed_Dispatches()
        {
            var (mgr, serial, sid) = New();
            Assert.True(mgr.Execute(sid, "OpenLed", null).Ok);
            Assert.Contains("OpenLed", serial.Calls);
        }
    
        [Fact] public void Execute_Expired_Session_Rejected()
        {
            var (mgr, _, _) = New();
            Assert.Equal("SESSION_EXPIRED", mgr.Execute("ghost", "ReadTemp", null).Code);
        }
    
        [Fact] public void Execute_Unknown_Op_Rejected()
        {
            var (mgr, _, sid) = New();
            var r = mgr.Execute(sid, "NoSuchOp", null);
            Assert.False(r.Ok);
            Assert.Equal("BAD_OP", r.Code);
        }
    }
    }
    
  • [ ] Step 2: 跑验证失败

Run: dotnet test ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests --filter DebugExecuteTests Expected: FAIL("Execute 不存在")。

  • Step 3: 实现 Execute 分发(本 Task 含非电机部分;电机/EEPROM 在 Task7)

DebugSessionManager 内加(using Newtonsoft.Json.Linq;):

        public DebugCommandResult Execute(string sid, string op, JObject args)
        {
            if (sid == null || !_sessions.TryGetValue(sid, out var s))
                return DebugCommandResult.Fail("SESSION_EXPIRED", "会话不存在或已过期");
            s.LastSeen = _clock(); // 命令本身也算活动,顺带续约
            var ser = s.Lease.Serial;
            if (ser == null) return DebugCommandResult.Fail("NO_HANDLE", "借用串口句柄为空");
            try
            {
                switch (op)
                {
                    // 读数
                    case "ReadTemp": return DebugCommandResult.Okay(ser.TemperatureWait());
                    case "ReadPressure": return DebugCommandResult.Okay(ser.PressureWait());
                    case "ReadDoor": return DebugCommandResult.Okay(ser.DoorStatusWait().ToString());
                    case "ReadVentTime": return DebugCommandResult.Okay(ser.ReadOpenVentTimeWait());
                    case "ShakeHands": return DebugCommandResult.Okay(ser.ShakeHandsWait());
                    // 阀/LED/换气
                    case "OpenLed": return DebugCommandResult.Okay(ser.OpenLedWait());
                    case "CloseLed": return DebugCommandResult.Okay(ser.CloseLedWait());
                    case "OpenIntake": return DebugCommandResult.Okay(ser.OpenIntakeValveWait());
                    case "CloseIntake": return DebugCommandResult.Okay(ser.CloseIntakeValveWait());
                    case "OpenExhaust": return DebugCommandResult.Okay(ser.OpenExhaustValveWait());
                    case "CloseExhaust": return DebugCommandResult.Okay(ser.CloseExhaustValveWait());
                    case "HouseAeration": return DebugCommandResult.Okay(ser.HouseAerationWait());
                    case "HouseVent": return DebugCommandResult.Okay(ser.HouseVentWait());
                    default:
                        return ExecuteMotorOrEeprom(s, ser, op, args); // Task7 实现
                }
            }
            catch (Exception ex) { return DebugCommandResult.Fail("HARDWARE_ERROR", ex.Message); }
        }

        // Task7 先占位:返回 BAD_OP,让"未知 op"测试通过;Task7 替换为真实电机/EEPROM 分发。
        private DebugCommandResult ExecuteMotorOrEeprom(DebugSession s, IvfTl.Hardware.ISerialChannel ser, string op, JObject args)
            => DebugCommandResult.Fail("BAD_OP", $"未知 op: {op}");
  • Step 4: 跑验证通过

Run: dotnet test ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests --filter DebugExecuteTests Expected: PASS(4 绿)。

  • [ ] Step 5: Commit

    git add ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/DebugSessionManager.cs ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/DebugExecuteTests.cs
    git commit -m "feat(d2-02): 命令分发 Execute(读数/阀/LED)+ 会话校验单测"
    

Task 7: 命令分发 — 电机(钳位)/EEPROM 写(TDD)

Files:

  • Modify: ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/DebugSessionManager.cs(替换 ExecuteMotorOrEeprom)
  • Modify Test: ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/DebugExecuteTests.cs

  • [ ] Step 1: 加失败测试(钳位是红线重点)

        [Fact] public void Vertical_MoveTo_InRange_Dispatches()
        {
            var (mgr, serial, sid) = New();
            var r = mgr.Execute(sid, "VerticalMoveTo", JObject.FromObject(new { pos = 90000 }));
            Assert.True(r.Ok);
            Assert.Contains("VMoveTo(90000)", serial.Calls);
        }
    
        [Fact] public void Vertical_MoveTo_OutOfRange_Rejected_NotDispatched()
        {
            var (mgr, serial, sid) = New();
            var r = mgr.Execute(sid, "VerticalMoveTo", JObject.FromObject(new { pos = 130000 }));
            Assert.Equal("OUT_OF_RANGE", r.Code);
            Assert.DoesNotContain("VMoveTo(130000)", serial.Calls); // 越界绝不下发
        }
    
        [Fact] public void Horizontal_Forward_Relative_Clamped()
        {
            var (mgr, serial, sid) = New();
            mgr.Execute(sid, "HorizontalMoveTo", JObject.FromObject(new { pos = 215000 })); // 当前位=215000
            var r = mgr.Execute(sid, "HorizontalForward", JObject.FromObject(new { value = 10000 })); // 目标225000>220000
            Assert.Equal("OUT_OF_RANGE", r.Code);
        }
    
        [Fact] public void WriteScanStep_Dispatches()
        {
            var (mgr, serial, sid) = New();
            Assert.True(mgr.Execute(sid, "WriteScanStep", JObject.FromObject(new { value = 300 })).Ok);
            Assert.Contains("WriteScanStep(300)", serial.Calls);
        }
    
  • [ ] Step 2: 跑验证失败

Run: dotnet test ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests --filter DebugExecuteTests Expected: FAIL(钳位/电机未实现,返回 BAD_OP)。

  • [ ] Step 3: 替换 ExecuteMotorOrEeprom 为真实分发

        private DebugCommandResult ExecuteMotorOrEeprom(DebugSession s, IvfTl.Hardware.ISerialChannel ser, string op, JObject args)
        {
            int Arg(string k, int def = 0) => args?[k] != null ? args[k].Value<int>() : def;
            int delay = -1; // 用 ISerialChannel 内部 MotorSettleMs 默认
    
            switch (op)
            {
                // ── 垂直 Z(对焦轴,钳 [0,125000]) ──
                case "VerticalReset": { bool ok = ser.VerticalResetWait(delay); s.CurrentVer = 0; return DebugCommandResult.Okay(ok); }
                case "VerticalMoveTo":
                {
                    int pos = Arg("pos");
                    if (!MotorClamp.IsVerticalInRange(pos)) return DebugCommandResult.Fail("OUT_OF_RANGE", $"垂直目标{pos}越界[0,{MotorClamp.VerMax}]");
                    bool ok = ser.VerticalMoveToWait(pos, delay); if (ok) s.CurrentVer = pos; return DebugCommandResult.Okay(ok);
                }
                case "VerticalForward":
                case "VerticalBackward":
                {
                    int delta = Arg("value") * (op == "VerticalBackward" ? -1 : 1);
                    int basePos = s.CurrentVer < 0 ? 0 : s.CurrentVer;
                    int target = MotorClamp.RelativeTarget(basePos, delta);
                    if (!MotorClamp.IsVerticalInRange(target)) return DebugCommandResult.Fail("OUT_OF_RANGE", $"垂直目标{target}越界");
                    bool ok = op == "VerticalBackward" ? ser.VerticalBackwardWait(Arg("value"), delay) : ser.VerticalForwardWait(Arg("value"), delay);
                    if (ok) s.CurrentVer = target; return DebugCommandResult.Okay(ok);
                }
                // ── 水平(皿孔,钳 [0,220000]) ──
                case "HorizontalReset": { bool ok = ser.HorizontalResetWait(delay); s.CurrentHor = 0; return DebugCommandResult.Okay(ok); }
                case "HorizontalMoveTo":
                {
                    int pos = Arg("pos");
                    if (!MotorClamp.IsHorizontalInRange(pos)) return DebugCommandResult.Fail("OUT_OF_RANGE", $"水平目标{pos}越界[0,{MotorClamp.HorMax}]");
                    bool ok = ser.HorizontalMoveToWait(pos, delay); if (ok) s.CurrentHor = pos; return DebugCommandResult.Okay(ok);
                }
                case "HorizontalForward":
                case "HorizontalBackward":
                {
                    int delta = Arg("value") * (op == "HorizontalBackward" ? -1 : 1);
                    int basePos = s.CurrentHor < 0 ? 0 : s.CurrentHor;
                    int target = MotorClamp.RelativeTarget(basePos, delta);
                    if (!MotorClamp.IsHorizontalInRange(target)) return DebugCommandResult.Fail("OUT_OF_RANGE", $"水平目标{target}越界");
                    bool ok = op == "HorizontalBackward" ? ser.HorizontalBackwardWait(Arg("value"), delay) : ser.HorizontalForwardWait(Arg("value"), delay);
                    if (ok) s.CurrentHor = target; return DebugCommandResult.Okay(ok);
                }
                // ── EEPROM 写 ──
                case "WriteScanStep": return DebugCommandResult.Okay(ser.WriteScanStepWait(Arg("value")));
                case "WriteOpenIntakeTime": return DebugCommandResult.Okay(ser.WriteOpenIntakeTimeWait(Arg("value")));
                case "WriteOpenVentTime": return DebugCommandResult.Okay(ser.WriteOpenVentTimeWait(Arg("value")));
                case "WriteWellHorizontalPos": return DebugCommandResult.Okay(ser.WriteWellHorizontalPosWait(Arg("well"), Arg("hor")));
                default: return DebugCommandResult.Fail("BAD_OP", $"未知 op: {op}");
            }
        }
    
  • [ ] Step 4: 跑验证通过

Run: dotnet test ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests --filter DebugExecuteTests Expected: PASS(8 绿)。

  • [ ] Step 5: Commit

    git add ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/DebugSessionManager.cs ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/DebugExecuteTests.cs
    git commit -m "feat(d2-02): 命令分发电机(红线钳位)/EEPROM写 + 越界拒绝单测"
    

Task 8: ControlHttpServer 加 /debug/* 路由

Files:

  • Modify: ivf_tl_operate_2.0/control/ivf_tl_ControlHost/ControlHttpServer.cs

Handle() 是"算 body 一次性写完"。本 Task 加 4 个 JSON 端点(acquire/command/release/heartbeat);/debug/preview/stream(流式)留第二阶段。新增一个可选构造参数 DebugSessionManager debug,避免再膨胀必填参数。

  • Step 1: 构造函数加可选 manager 字段

ControlHttpServer 字段区加 private readonly DebugSessionManager _debug;,构造函数末尾加可选参 DebugSessionManager debug = null_debug = debug;

  • Step 2: switch 加 /debug/* 分支

Handle()switch(path)default 之前插入:

                case "/debug/acquire":
                    if (method != "POST") { code = 405; body = Err("method not allowed"); break; }
                    {
                        int houseSn = ReadIntField(ctx, "houseSn");
                        var r = _debug != null ? _debug.Acquire(houseSn) : DebugCommandResult.Fail("NO_HANDLE", "debug 未装配");
                        code = r.Ok ? 200 : 409; body = JsonConvert.SerializeObject(r);
                    }
                    break;
                case "/debug/heartbeat":
                    if (method != "POST") { code = 405; body = Err("method not allowed"); break; }
                    {
                        var r = _debug != null ? _debug.Heartbeat(ReadField(ctx, "sessionId")) : DebugCommandResult.Fail("SESSION_EXPIRED", "debug 未装配");
                        code = r.Ok ? 200 : 410; body = JsonConvert.SerializeObject(r);
                    }
                    break;
                case "/debug/release":
                    if (method != "POST") { code = 405; body = Err("method not allowed"); break; }
                    {
                        var r = _debug != null ? _debug.Release(ReadField(ctx, "sessionId")) : DebugCommandResult.Okay();
                        code = 200; body = JsonConvert.SerializeObject(r);
                    }
                    break;
                case "/debug/command":
                    if (method != "POST") { code = 405; body = Err("method not allowed"); break; }
                    {
                        var jo = ReadBody(ctx); // 见 Step3:一次性读 body 成 JObject
                        string sid = jo?["sessionId"]?.ToString();
                        string op = jo?["op"]?.ToString();
                        var argsObj = jo?["args"] as Newtonsoft.Json.Linq.JObject;
                        var r = _debug != null ? _debug.Execute(sid, op, argsObj) : DebugCommandResult.Fail("SESSION_EXPIRED", "debug 未装配");
                        code = r.Ok ? 200 : (r.Code == "SESSION_EXPIRED" ? 410 : (r.Code == "OUT_OF_RANGE" ? 400 : 200));
                        body = JsonConvert.SerializeObject(r);
                    }
                    break;
  • [ ] Step 3: 加 ReadBody(整 body 解析一次,/command 多字段需要)

        /// <summary>把 POST body 整体解析为 JObject(失败返回 null)。/debug/command 多字段用。</summary>
        private Newtonsoft.Json.Linq.JObject ReadBody(HttpListenerContext ctx)
        {
            try
            {
                using (var sr = new StreamReader(ctx.Request.InputStream, ctx.Request.ContentEncoding ?? Encoding.UTF8))
                {
                    string raw = sr.ReadToEnd();
                    return string.IsNullOrEmpty(raw) ? null : Newtonsoft.Json.Linq.JObject.Parse(raw);
                }
            }
            catch (Exception ex) { _log("解析 body 异常:" + ex.Message); return null; }
        }
    
  • [ ] Step 4: 编译

Run: dotnet build ivf_tl_operate_2.0/control/ivf_tl_ControlHost/ivf_tl_ControlHost.csproj -c Debug Expected: 0 错。

  • [ ] Step 5: Commit

    git add ivf_tl_operate_2.0/control/ivf_tl_ControlHost/ControlHttpServer.cs
    git commit -m "feat(d2-02): ControlHttpServer 加 /debug/acquire|command|release|heartbeat 路由"
    

Task 9: Program.cs 装配 manager + TTL 看门狗线程

Files:

  • Modify: ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Program.cs

  • [ ] Step 1: 建 manager(用真实 HAL 的 GetHouseGate + 系统时钟)

Main 起 HTTP 之前加:

                var debugMgr = new IvfTl.ControlHost.Debug.DebugSessionManager(
                    houseSn => IvfTl.Hardware.Impl.HardwareAccessLayer.Instance.GetHouseGate(houseSn),
                    () => DateTime.UtcNow,
                    ttlMs: 10000,
                    log: msg => Log4netHelper.WriteLog(msg));
  • Step 2: 注入 ControlHttpServer(末尾加 debugMgr 实参)

new ControlHttpServer(...) 调用末尾参数 msg => Log4netHelper.WriteLog(msg) 后补一个实参 , debugMgr(构造已加可选参)。

  • Step 3: 起看门狗线程(周期 SweepExpired)

_http.Start(); 之后加:

                System.Threading.Tasks.Task.Run(async () =>
                {
                    while (!(_exitEvent?.IsSet ?? false))
                    {
                        try { debugMgr.SweepExpired(); } catch (Exception ex) { Log4netHelper.WriteLog("[debug] Sweep 异常:" + ex.Message); }
                        await System.Threading.Tasks.Task.Delay(3000);
                    }
                });

(注:_exitEvent 在第 73 行才 new;把看门狗启动挪到 _exitEvent = new ManualResetEventSlim(false); 之后、_exitEvent.Wait(); 之前。)

  • Step 4: 编译

Run: dotnet build ivf_tl_operate_2.0/control/ivf_tl_ControlHost/ivf_tl_ControlHost.csproj -c Release Expected: 0 错(真机用 Release)。

  • [ ] Step 5: Commit

    git add ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Program.cs
    git commit -m "feat(d2-02): Program 装配 DebugSessionManager + TTL 看门狗线程"
    

Task 10: 第一阶段真机冒烟(curl,Claude 自主跑)

真机门控:UAC 静默提权拉起 control,curl 验证端点。电机命令本 Task 只验越界拒绝(安全),真机走位放第三阶段 V-012。

  • [ ] Step 1: Release 部署 control 到真机布局,提权拉起(参考部署指南;先清僵尸 operate)。

  • [ ] Step 2: 探活 + 借用

Run: curl -s -X POST http://127.0.0.1:38080/debug/acquire -d '{"houseSn":5}' Expected: {"ok":true,"result":"<sid>"};control 日志 [debug] acquire 舱5

  • Step 3: 读数命令

Run: curl -s -X POST http://127.0.0.1:38080/debug/command -d '{"sessionId":"<sid>","op":"ReadTemp"}' Expected: {"ok":true,"result":<温度>}

  • Step 4: 越界电机被拒(红线)

Run: curl -s -X POST http://127.0.0.1:38080/debug/command -d '{"sessionId":"<sid>","op":"VerticalMoveTo","args":{"pos":130000}}' Expected: {"ok":false,"code":"OUT_OF_RANGE",...};下位机无动作。

  • Step 5: 归还 + 超时回收

归还:curl -s -X POST http://127.0.0.1:38080/debug/release -d '{"sessionId":"<sid>"}'{"ok":true}。 再借一次但不发心跳,等 >10s,control 日志应出 会话超时自动回收、该舱采集恢复。记录到 待验证清单.md D2-02 条目,提交。


后续阶段(待第一阶段真机验证后各自展开为独立计划)

第二阶段 · MJPEG 预览(独立计划 2026-06-xx-D2-02-MJPEG预览.md)

  • control:/debug/preview/stream?sessionId=ControlHttpServer流式分支(不进统一 body 写法):校验会话→起抓帧循环(lease.Camera.GrabStable()→JPEG→multipart/x-mixed-replace 持续写)→client 断开抛异常即停 + 标记会话可回收(快信号)。
  • operate:MjpegStreamClient(HttpClient 读流→切 boundary→BitmapImage→WPF Image)。
  • 关键:抓帧与 SavePic 同台相机经 ICameraGate 串行;长连接稳定性(spec §13.1)。
  • 验证:真机出图。

第三阶段 · operate 接入 + 真机 V-012(独立计划 2026-06-xx-D2-02-operate接入.md)

  • operate 新建 DebugSessionClient(HttpClient 封 acquire/command/release + 心跳定时器 + 会话失效回调弹"请重新进入调试")。
  • HouseDebugPageViewModel + BufferDebugViewModel:把 HardwareAccessLayer.Instance.GetHouseGate().Acquire + lease.Serial.* 全换成 client.Command(op,args);OperationLogger 埋点留 operate 侧。
  • HouseDebugPageView.xaml.cs + BufferDebugView.xaml.cs:Start/End/Return 改调 client;预览接 MjpegStreamClient
  • DebugCalibrationAdapter:仅当引用死栈类型时换类型。
  • 真机 V-012:借串口让路时序、电机真机走位(红线两轴守安全区间)、operate 崩溃后 control 自动回收恢复采集。
  • 完成后解锁 D3-04(删 operate 死串口/相机栈)。

自查结论(对照 spec)

  • spec §3/§4 形态与契约 → Task1/4/6/7/8(数据类/会话/分发/路由)。
  • spec §5 安全地基(崩溃/丢消息/长时/失效) → Task4(幂等)/Task5(超时回收+心跳续约)/Task6(失效拒绝);崩溃快信号在第二阶段(MJPEG 断流)。
  • spec §7 操作清单 → Task6(读数/阀/LED)+Task7(电机/EEPROM);缓冲瓶 B 类 op 在第三阶段接入时按 BufferDebugViewModel 现有方法补登记(同 switch 模式)。
  • spec §10 红线钳位 → Task2(纯函数)+Task7(分发处拦截+越界不下发)。
  • spec §6 ComHouseInit 初始化序列 → 第三阶段接入时定 A/B 案;第一阶段 acquire 只借用、不跑序列(curl 验证够用)。
  • 类型一致:DebugCommandResult.Okay/Failcode 串(BUSY/SESSION_EXPIRED/OUT_OF_RANGE/NO_HANDLE/BAD_OP/HARDWARE_ERROR)、Execute(sid,op,JObject) 签名贯穿 Task6-9 一致。