|
|
@@ -0,0 +1,1022 @@
|
|
|
+# 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**
|
|
|
+
|
|
|
+```xml
|
|
|
+<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`:
|
|
|
+```csharp
|
|
|
+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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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**
|
|
|
+
|
|
|
+```csharp
|
|
|
+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**
|
|
|
+
|
|
|
+```csharp
|
|
|
+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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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: 写失败测试**
|
|
|
+
|
|
|
+```csharp
|
|
|
+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**
|
|
|
+
|
|
|
+```csharp
|
|
|
+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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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(无测试,仅替身)**
|
|
|
+
|
|
|
+```csharp
|
|
|
+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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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: 写失败测试**
|
|
|
+
|
|
|
+```csharp
|
|
|
+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 补)**
|
|
|
+
|
|
|
+```csharp
|
|
|
+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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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: 加失败测试(可控时钟快进)**
|
|
|
+
|
|
|
+```csharp
|
|
|
+ [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**
|
|
|
+
|
|
|
+```csharp
|
|
|
+ // 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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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: 写失败测试**
|
|
|
+
|
|
|
+```csharp
|
|
|
+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;`):
|
|
|
+```csharp
|
|
|
+ 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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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: 加失败测试(钳位是红线重点)**
|
|
|
+
|
|
|
+```csharp
|
|
|
+ [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 为真实分发**
|
|
|
+
|
|
|
+```csharp
|
|
|
+ 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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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` 之前插入:
|
|
|
+```csharp
|
|
|
+ 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 多字段需要)**
|
|
|
+
|
|
|
+```csharp
|
|
|
+ /// <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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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 之前加:
|
|
|
+```csharp
|
|
|
+ var debugMgr = new IvfTl.ControlHost.Debug.DebugSessionManager(
|
|
|
+ houseSn => IvfTl.Hardware.Impl.HardwareAccessLayer.Instance.GetHouseGate(houseSn),
|
|
|
+ () => DateTime.UtcNow,
|
|
|
+ ttlMs: 10000,
|
|
|
+ log: msg => Log4netHelper.WriteLog(msg));
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 注入 ControlHttpServer(末尾加 debugMgr 实参)**
|
|
|
+
|
|
|
+把 `new ControlHttpServer(...)` 调用末尾参数 `msg => Log4netHelper.WriteLog(msg)` 后补一个实参 `, debugMgr`(构造已加可选参)。
|
|
|
+
|
|
|
+- [ ] **Step 3: 起看门狗线程(周期 SweepExpired)**
|
|
|
+
|
|
|
+在 `_http.Start();` 之后加:
|
|
|
+```csharp
|
|
|
+ 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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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/Fail`、`code` 串(BUSY/SESSION_EXPIRED/OUT_OF_RANGE/NO_HANDLE/BAD_OP/HARDWARE_ERROR)、`Execute(sid,op,JObject)` 签名贯穿 Task6-9 一致。
|