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