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。
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]。
Files:
ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/IvfTl.ControlHost.Tests.csprojModify: 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.
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);
}
}
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"
Files:
ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/DebugCommandResult.csCreate: 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)"
Files:
ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/MotorClamp.csTest: 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 + 单测"
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"
Files:
ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/DebugSessionManager.csivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/DebugSessionManagerTests.csmanager 不直接依赖
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 + 幂等单测"
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): 会话超时自动回收 + 心跳续约(安全地基)"
Files:
ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/DebugSessionManager.csTest: 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 不存在")。
在 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}");
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)+ 会话校验单测"
Files:
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写 + 越界拒绝单测"
Files:
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,避免再膨胀必填参数。
在 ControlHttpServer 字段区加 private readonly DebugSessionManager _debug;,构造函数末尾加可选参 DebugSessionManager debug = null 并 _debug = 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 路由"
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));
把 new ControlHttpServer(...) 调用末尾参数 msg => Log4netHelper.WriteLog(msg) 后补一个实参 , debugMgr(构造已加可选参)。
在 _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(); 之前。)
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 看门狗线程"
真机门控: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。
Run: curl -s -X POST http://127.0.0.1:38080/debug/command -d '{"sessionId":"<sid>","op":"ReadTemp"}'
Expected: {"ok":true,"result":<温度>}。
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",...};下位机无动作。
归还:curl -s -X POST http://127.0.0.1:38080/debug/release -d '{"sessionId":"<sid>"}' → {"ok":true}。
再借一次但不发心跳,等 >10s,control 日志应出 会话超时自动回收、该舱采集恢复。记录到 待验证清单.md D2-02 条目,提交。
2026-06-xx-D2-02-MJPEG预览.md)/debug/preview/stream?sessionId= 在 ControlHttpServer 走流式分支(不进统一 body 写法):校验会话→起抓帧循环(lease.Camera.GrabStable()→JPEG→multipart/x-mixed-replace 持续写)→client 断开抛异常即停 + 标记会话可回收(快信号)。MjpegStreamClient(HttpClient 读流→切 boundary→BitmapImage→WPF Image)。SavePic 同台相机经 ICameraGate 串行;长连接稳定性(spec §13.1)。2026-06-xx-D2-02-operate接入.md)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:仅当引用死栈类型时换类型。BufferDebugViewModel 现有方法补登记(同 switch 模式)。DebugCommandResult.Okay/Fail、code 串(BUSY/SESSION_EXPIRED/OUT_OF_RANGE/NO_HANDLE/BAD_OP/HARDWARE_ERROR)、Execute(sid,op,JObject) 签名贯穿 Task6-9 一致。