2026-06-23-舱室故障隔离与双端故障提示-实现计划.md 38 KB

舱室故障隔离与双端故障提示 实现计划

给执行者(子代理/续接会话): 必读子技能 —— 用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐 Task 执行。每步用 - [ ] 复选框跟踪。 设计依据:需求文档/specs/2026-06-23-舱室故障隔离与双端故障提示-design.md(逐行源码核实的现状 + 目标 + 验收)。 源码已用 codegraph 读透(StartMain.InitTL/InitHouse、control 版 SerialBin 四个 errorlist 来源、MonitorSnapshot、告警通道 /api/tl/control/alarm/reportCloudAlarm),本计划所有 file:line 与签名均照此对齐。

目标(一句话): 开机时部分舱坏 → 只排除坏舱、好舱照常培养、整机绝不因单舱失败而中止;并把"哪个舱/什么故障/何时/是否已隔离"经现有告警通道送到 operate + front 双端明确展示。

架构(怎么改): control 侧把"一刀切 errorlist 非空即整体中止"改成"结构化坏舱清单 → 剔除坏舱 → 仅在零可跑舱时才中止";坏舱清单贯穿 /status 快照(operate 监控页轮询)+ 复用 reportCloudAlarm 告警(front 展示)。纯决策逻辑(坏舱剔除/致命判定)抽成无 I/O 静态类做 TDD;改启动核心(SerialBin/InitTL/InitHouse)的部分单测覆盖决策、行为靠真机拔插验收。

技术栈: C# net6.0-windows;xUnit(复用现有 IvfTl.ControlHost.Tests 工程,不新建);现有 HttpService.reportCloudAlarm 告警通道 + MonitorSnapshot 快照;无新增第三方依赖。

分支: feature/house-fault-isolation(独立于 D2-02;改 control 启动核心,风险较高,单独分支)。


文件结构(改面地图,决策先锁这里)

新增(纯数据/纯逻辑,可单测):

  • ivf_tl_operate_2.0/control/IvfTl.Control.Entity/InitEntitys/HouseFault.cs — 坏舱故障数据载体 + HouseFaultType 枚举。放 Entity 工程,ivf_tl_Com(SerialBin)与 ivf_tl_Control(StartMain/AppData/MonitorSnapshot)都引用得到(二者均已 using IvfTl.Control.Entity.*)。
  • ivf_tl_operate_2.0/control/ivf_tl_Control/StartupFaultPolicy.cs — 无 I/O 静态决策类(坏舱集合/可跑舱/致命判定)。放 ivf_tl_Control,测试工程引用得到。

修改(改启动核心,真机门控):

  • ivf_tl_operate_2.0/control/ivf_tl_Com/SerialBin.cs — 加 public List<HouseFault> Faults,在现有 4 处 errorlist.Add 旁同步登记结构化故障(保留 errorlist 供日志兼容)。
  • ivf_tl_operate_2.0/control/ivf_tl_Control/StartMain.csInitTLStartupFaultPolicy 算可跑舱、仅零可跑舱才中止;InitHouse 逐舱构造/StartTask 各自 try-catch。
  • ivf_tl_operate_2.0/control/ivf_tl_Control/AppData.cs — 加 List<HouseFault> StartupFaults,GetMonitorSnapshot 透出,启动排除时经 HttpService 上报告警。
  • ivf_tl_operate_2.0/control/ivf_tl_Control/MonitorSnapshot.cs — 加 List<HouseFaultRow> Faults 字段。

新增测试(并入现有工程):

  • ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/HouseFaultTests.cs
  • ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/StartupFaultPolicyTests.cs
  • ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/MonitorSnapshotFaultTests.cs

阶段划分(对齐 spec §59:①②control 侧纯单测+真机可自主,③④双端 UI 真机门控)

  • 第一阶段(本计划详细 Task1-7) = control 坏舱剔除 + 故障登记 + 启动排除上报。Task1/2/6a 纯 TDD 自主;Task3/4/5/6b/7 改启动核心 + 真机拔插,Claude 可自主跑(UAC 提权、当前无活体培养)。
  • 第二/三/四/五阶段(本计划末尾提纲) = 运行期故障升级去抖 / operate 监控页"舱故障"区 / front 告警展示 / 真机拔插整体验收。UI 两项真机门控,待第一阶段落地后单独拆细。

第一阶段:control 坏舱剔除 + 故障登记 + 上报

Task 1: HouseFault 数据载体 + 枚举

Files:

  • Create: ivf_tl_operate_2.0/control/IvfTl.Control.Entity/InitEntitys/HouseFault.cs
  • Test: ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/HouseFaultTests.cs

  • [ ] Step 1: 写失败测试

IvfTl.ControlHost.Tests/HouseFaultTests.cs:

using System;
using IvfTl.Control.Entity.InitEntitys;
using Xunit;

namespace IvfTl.ControlHost.Tests
{
    public class HouseFaultTests
    {
        [Fact]
        public void HouseFault_Holds_All_Fields()
        {
            var at = new DateTime(2026, 6, 23, 12, 0, 0, DateTimeKind.Utc);
            var f = new HouseFault
            {
                HouseSn = 6,
                Port = "COM7",
                Type = HouseFaultType.CcdSnMissing,
                Reason = "相机列表中不存在仓室的CCDSN12345",
                Stage = "扫口握手",
                At = at,
                Isolated = true
            };

            Assert.Equal(6, f.HouseSn);
            Assert.Equal("COM7", f.Port);
            Assert.Equal(HouseFaultType.CcdSnMissing, f.Type);
            Assert.Equal("相机列表中不存在仓室的CCDSN12345", f.Reason);
            Assert.Equal("扫口握手", f.Stage);
            Assert.Equal(at, f.At);
            Assert.True(f.Isolated);
        }

        [Fact]
        public void HouseFault_Defaults_HouseSn_Unknown_As_Minus1()
        {
            var f = new HouseFault { Type = HouseFaultType.CameraDuplicateSn };
            Assert.Equal(-1, f.HouseSn);   // -1 = 舱号未知(相机/串口级故障)
            Assert.False(f.Isolated);
        }
    }
}
  • Step 2: 跑测试确认失败

Run: dotnet test ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/IvfTl.ControlHost.Tests.csproj --filter HouseFaultTests Expected: FAIL —— 编译错误 HouseFault/HouseFaultType 未定义。

  • Step 3: 写最小实现

IvfTl.Control.Entity/InitEntitys/HouseFault.cs:

using System;

namespace IvfTl.Control.Entity.InitEntitys
{
    /// <summary>
    /// 舱级故障类型。启动期 SerialBin 登记 + 运行期升级共用。
    /// </summary>
    public enum HouseFaultType
    {
        CameraReadFailed = 0,    // 相机 SN 读取异常(舱号未知)
        CameraDuplicateSn = 1,   // 两台相机报同一 SN,配对不明(舱号未知)
        HouseSnDuplicate = 2,    // 两个串口握手返回同一舱号
        CcdSnMissing = 3,        // 相机列表中不存在该舱 CCDSN(该舱相机半坏)
        CcdSnDuplicate = 4,      // 两个舱报同一 CCDSN
        SerialReadException = 5, // 扫口过程串口读异常(舱号可能未知)
        InitException = 6,       // 舱构造/StartTask 抛异常
        RuntimeFault = 7         // 运行期突发(第二阶段用)
    }

    /// <summary>
    /// 单条舱故障。HouseSn>0=已知具体舱(可被剔除);HouseSn=-1=相机/串口级故障(仅提示,不剔除某舱)。
    /// 纯数据载体,无任何行为。
    /// </summary>
    public class HouseFault
    {
        /// <summary>舱号;-1 表示舱号未知(相机/串口级)。</summary>
        public int HouseSn { get; set; } = -1;
        /// <summary>串口名(已知时)。</summary>
        public string Port { get; set; }
        public HouseFaultType Type { get; set; }
        /// <summary>人类可读原因(直接取自原 errorlist 文案)。</summary>
        public string Reason { get; set; }
        /// <summary>发生阶段:相机枚举/扫口握手/舱初始化/运行期。</summary>
        public string Stage { get; set; }
        /// <summary>发生时刻(UTC)。</summary>
        public DateTime At { get; set; }
        /// <summary>是否已被隔离(该舱已从可跑清单剔除/已停)。</summary>
        public bool Isolated { get; set; }
    }
}
  • Step 4: 跑测试确认通过

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

  • [ ] Step 5: 提交

    git add ivf_tl_operate_2.0/control/IvfTl.Control.Entity/InitEntitys/HouseFault.cs \
        ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/HouseFaultTests.cs
    git commit -m "feat(house-fault): HouseFault 数据载体 + HouseFaultType 枚举(TDD)"
    

Task 2: StartupFaultPolicy 纯决策(坏舱剔除 / 致命判定)

Files:

  • Create: ivf_tl_operate_2.0/control/ivf_tl_Control/StartupFaultPolicy.cs
  • Test: ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/StartupFaultPolicyTests.cs

决策规则(锁定): ① 坏舱 = 故障清单里 HouseSn>0 的舱号集合(舱号未知的相机/串口级故障不剔除任何舱,只作提示);② 可跑舱 = 发现的舱 − 坏舱;③ 致命 = 一个可跑舱都没有(零可跑)才整机中止。单舱/部分舱坏一律不中止 —— 直接落实 spec §34「仅全部舱失败才中止」。

  • Step 1: 写失败测试(完整真值表)

IvfTl.ControlHost.Tests/StartupFaultPolicyTests.cs:

using System.Collections.Generic;
using System.Linq;
using IvfTl.Control.Entity.InitEntitys;
using ivf_tl_Control;
using Xunit;

namespace IvfTl.ControlHost.Tests
{
    public class StartupFaultPolicyTests
    {
        private static HouseFault Fault(int sn, HouseFaultType t = HouseFaultType.CcdSnMissing)
            => new HouseFault { HouseSn = sn, Type = t, Reason = "x", Stage = "扫口握手" };

        [Fact]
        public void BadHouseSns_Only_Counts_Known_Houses()
        {
            var faults = new List<HouseFault>
            {
                Fault(6),
                Fault(-1, HouseFaultType.CameraDuplicateSn), // 舱号未知 → 不算坏舱
                Fault(8),
            };
            var bad = StartupFaultPolicy.BadHouseSns(faults);
            Assert.Equal(new[] { 6, 8 }, bad.OrderBy(x => x).ToArray());
        }

        [Fact]
        public void RunnableHouses_Excludes_Bad_And_Sorts()
        {
            var discovered = new[] { 2, 4, 6, 7, 8, 9, 11 };
            var faults = new List<HouseFault> { Fault(6), Fault(8) };
            var run = StartupFaultPolicy.RunnableHouses(discovered, faults);
            Assert.Equal(new[] { 2, 4, 7, 9, 11 }, run.ToArray());
        }

        [Fact]
        public void RunnableHouses_With_No_Faults_Returns_All_Discovered()
        {
            var discovered = new[] { 2, 4, 6 };
            var run = StartupFaultPolicy.RunnableHouses(discovered, new List<HouseFault>());
            Assert.Equal(new[] { 2, 4, 6 }, run.ToArray());
        }

        [Fact]
        public void RunnableHouses_UnknownHouseFault_Does_Not_Exclude_Anyone()
        {
            var discovered = new[] { 2, 4 };
            var faults = new List<HouseFault> { Fault(-1, HouseFaultType.CameraReadFailed) };
            var run = StartupFaultPolicy.RunnableHouses(discovered, faults);
            Assert.Equal(new[] { 2, 4 }, run.ToArray());
        }

        [Fact]
        public void IsFatal_True_Only_When_No_Runnable_House()
        {
            Assert.True(StartupFaultPolicy.IsFatal(new List<int>()));
            Assert.False(StartupFaultPolicy.IsFatal(new List<int> { 11 }));
            Assert.False(StartupFaultPolicy.IsFatal(new List<int> { 2, 4 }));
        }

        [Fact]
        public void Partial_Failure_Is_Never_Fatal()
        {
            // 7 个舱里坏 2 个 → 仍 5 个可跑 → 不致命(核心需求)
            var discovered = new[] { 2, 4, 6, 7, 8, 9, 11 };
            var faults = new List<HouseFault> { Fault(6), Fault(8) };
            var run = StartupFaultPolicy.RunnableHouses(discovered, faults);
            Assert.False(StartupFaultPolicy.IsFatal(run));
        }
    }
}
  • Step 2: 跑测试确认失败

Run: dotnet test ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/IvfTl.ControlHost.Tests.csproj --filter StartupFaultPolicyTests Expected: FAIL —— StartupFaultPolicy 未定义。

  • Step 3: 写最小实现

ivf_tl_Control/StartupFaultPolicy.cs:

using System.Collections.Generic;
using System.Linq;
using IvfTl.Control.Entity.InitEntitys;

namespace ivf_tl_Control
{
    /// <summary>
    /// 启动期坏舱决策(无 I/O,纯函数,可单测)。
    /// 规则:坏舱=故障清单中已知舱号(HouseSn>0);可跑舱=发现的舱−坏舱;致命=零可跑舱。
    /// 单舱/部分舱坏绝不致命 —— 落实 spec §34。
    /// </summary>
    public static class StartupFaultPolicy
    {
        public static HashSet<int> BadHouseSns(IEnumerable<HouseFault> faults)
            => faults.Where(f => f.HouseSn > 0).Select(f => f.HouseSn).ToHashSet();

        public static List<int> RunnableHouses(IEnumerable<int> discovered, IEnumerable<HouseFault> faults)
        {
            var bad = BadHouseSns(faults);
            return discovered.Where(sn => !bad.Contains(sn)).Distinct().OrderBy(x => x).ToList();
        }

        public static bool IsFatal(IReadOnlyList<int> runnableHouses)
            => runnableHouses == null || runnableHouses.Count == 0;
    }
}
  • Step 4: 跑测试确认通过

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

  • [ ] Step 5: 提交

    git add ivf_tl_operate_2.0/control/ivf_tl_Control/StartupFaultPolicy.cs \
        ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/StartupFaultPolicyTests.cs
    git commit -m "feat(house-fault): StartupFaultPolicy 坏舱剔除/致命判定(TDD 真值表 6 绿)"
    

Task 3: SerialBin 登记结构化坏舱清单(改启动核心,真机门控)

Files:

  • Modify: ivf_tl_operate_2.0/control/ivf_tl_Com/SerialBin.cs

说明: control 版 SerialBin 现有 4 处 errorlist.Add(camera 重复 SN :121、camera 读异常 catch :135、舱号重复 :212、CCDSN 缺失 :252、CCDSN 重复 :260、扫口 catch :318)。在每处旁同步登记一条 HouseFault(保留 errorlist 不动,日志兼容)。纯增量,不改任何控制流。无法纯单测(真硬件 I/O),靠 Task 7 真机拔插验证。

  • Step 1: 加 Faults 字段 + using

文件顶部已有 using IvfTl.Control.Entity.InitEntitys;(HouseInitData/HouseEEPROInfo 在此命名空间),HouseFault 同命名空间,无需加 using。在 private List<string> errorlist = new List<string>();(SerialBin.cs:32)下方加:

        /// <summary>结构化坏舱清单(与 errorlist 并存,errorlist 仅日志兼容)。</summary>
        public List<HouseFault> Faults { get; } = new List<HouseFault>();
  • Step 2: camera 重复 SN(GetCameraSn:121)

errorlist.Add($"ccdid:{i};ccdsn:{SnNumber}"); 这一行之后追加(同一 lock 块内):

                            Faults.Add(new HouseFault
                            {
                                HouseSn = -1, Type = HouseFaultType.CameraDuplicateSn,
                                Reason = $"相机SN重复 ccdid:{i};ccdsn:{SnNumber}",
                                Stage = "相机枚举", At = DateTime.UtcNow
                            });
  • Step 3: camera 读异常(GetCameraSn catch 现 :135)

errorlist.Add($"获取{i}号相机ccdSn异常"); 之后追加(同 lock 块内):

                    Faults.Add(new HouseFault
                    {
                        HouseSn = -1, Type = HouseFaultType.CameraReadFailed,
                        Reason = $"获取{i}号相机ccdSn异常", Stage = "相机枚举", At = DateTime.UtcNow
                    });
  • Step 4: 舱号重复(GetHouseInfo:212)

errorlist.Add($"从{serialModel.housePort}串口...模块号{itemModel.houseSn}重复"); 之后追加(同 lock 块内,goto CC; 之前):

                        Faults.Add(new HouseFault
                        {
                            HouseSn = serialModel.houseSn, Port = serialModel.housePort,
                            Type = HouseFaultType.HouseSnDuplicate,
                            Reason = $"模块号{serialModel.houseSn}在{serialModel.housePort}与{itemModel.housePort}重复",
                            Stage = "扫口握手", At = DateTime.UtcNow
                        });
  • Step 5: CCDSN 缺失(GetHouseInfo:252)

errorlist.Add($"相机列表中不存在仓室的CCDSN{currentCCDSN}"); 之后追加(同 lock 块内):

                                Faults.Add(new HouseFault
                                {
                                    HouseSn = serialModel.houseSn, Port = serialModel.housePort,
                                    Type = HouseFaultType.CcdSnMissing,
                                    Reason = $"相机列表中不存在仓室的CCDSN{currentCCDSN}",
                                    Stage = "扫口握手", At = DateTime.UtcNow
                                });
  • Step 6: CCDSN 重复(GetHouseInfo:260)

errorlist.Add($"从{serialModel.houseSn}号模块...{itemModel.ccdSn}重复"); 之后追加(同 lock 块内):

                            Faults.Add(new HouseFault
                            {
                                HouseSn = serialModel.houseSn, Port = serialModel.housePort,
                                Type = HouseFaultType.CcdSnDuplicate,
                                Reason = $"舱{serialModel.houseSn}的CCDSN{currentCCDSN}与{itemModel.housePort}重复",
                                Stage = "扫口握手", At = DateTime.UtcNow
                            });
  • Step 7: 扫口异常 catch(GetHouseInfo catch 现 :318)

errorlist.Add($"获取端口信息异常{portName}:{ex.Message}"); 之后追加(同 lock 块内):

                    Faults.Add(new HouseFault
                    {
                        HouseSn = -1, Port = portName, Type = HouseFaultType.SerialReadException,
                        Reason = $"获取端口信息异常{portName}:{ex.Message}", Stage = "扫口握手", At = DateTime.UtcNow
                    });
  • Step 8: 编译确认 0 错

Run: dotnet build ivf_tl_operate_2.0/control/ivf_tl_Control.sln -c Debug Expected: 0 错(纯增量,Faults 只被写、还没被读)。

  • [ ] Step 9: 提交

    git add ivf_tl_operate_2.0/control/ivf_tl_Com/SerialBin.cs
    git commit -m "feat(house-fault): SerialBin 在 4 处 errorlist 旁登记结构化 HouseFault(保留 errorlist)"
    

Task 4: StartMain.InitTL 改坏舱剔除(改启动核心,真机门控)

Files:

  • Modify: ivf_tl_operate_2.0/control/ivf_tl_Control/StartMain.cs

说明: 把"errorList 非空即整体 return 空"改为"算可跑舱、仅零可跑才中止、坏舱存 AppData"。InitTL 自身有真硬件 I/O 不纯单测;决策已被 Task 2 单测覆盖,这里只做接线,行为靠 Task 7 真机验证。

  • Step 1: 加 using

StartMain.cs 顶部 using IvfTl.Control.Entity.InitEntitys; 已存在(用于 TLInitData 等),HouseFault 同命名空间可直接用,无需加 using。

  • Step 2: 相机 errorlist 不再中止(现 :78-83)

把:

                var errorList = serialBin.UpdataCamera();
                AppData.LogService.TLLog($"相机信息:{JsonConvert.SerializeObject(serialBin.CCDidSn)}", LogEnum.RunRecord);
                if (errorList.Any())
                {
                    errora = $"获取相机Id和CCDSN错误{JsonConvert.SerializeObject(errorList)}";
                    AppData.LogService.TLLog(errora, LogEnum.RunRecord);
                    return (new TLInitControllerResult(), new List<int>());
                }

改为(相机重复/读失败只记日志,不中止 —— 受影响舱稍后在 CCDSN 配对时自然落坏舱被剔除):

                var errorList = serialBin.UpdataCamera();
                AppData.LogService.TLLog($"相机信息:{JsonConvert.SerializeObject(serialBin.CCDidSn)}", LogEnum.RunRecord);
                if (errorList.Any())
                {
                    AppData.LogService.TLLog($"相机枚举有异常(不中止,受影响舱将按坏舱剔除):{JsonConvert.SerializeObject(errorList)}", LogEnum.RunRecord);
                }
  • Step 3: 串口 errorlist 改坏舱剔除(现 :89-94)

把:

                if (errorList.Any())
                {
                    errora = $"获取串口信息错误{JsonConvert.SerializeObject(errorList)}";
                    AppData.LogService.TLLog(errora, LogEnum.RunRecord);
                    return (new TLInitControllerResult(), new List<int>());
                }

改为:

                if (errorList.Any())
                {
                    AppData.LogService.TLLog($"扫口有异常(将剔除坏舱后继续):{JsonConvert.SerializeObject(errorList)}", LogEnum.RunRecord);
                }
  • Step 4: 用 Policy 算可跑舱、零可跑才中止、坏舱存 AppData(现 :120)

把:

                List<int> listIntRunHoues = serialBin.SerialModels.Select(x => x.houseSn).ToList();

改为:

                var discovered = serialBin.SerialModels.Select(x => x.houseSn).ToList();
                List<int> listIntRunHoues = StartupFaultPolicy.RunnableHouses(discovered, serialBin.Faults);

                // 坏舱清单存 AppData(供 /status 快照透出 + 上报告警);已知舱标记已隔离。
                foreach (var f in serialBin.Faults) if (f.HouseSn > 0) f.Isolated = true;
                AppData.StartupFaults = serialBin.Faults;
                if (serialBin.Faults.Any())
                    AppData.LogService.TLLog($"启动坏舱清单:{JsonConvert.SerializeObject(serialBin.Faults)};可跑舱:{string.Join(",", listIntRunHoues)}", LogEnum.RunRecord);

                // 仅零可跑舱才整机中止(单舱/部分舱坏绝不中止 —— spec §34)。
                if (StartupFaultPolicy.IsFatal(listIntRunHoues))
                {
                    errora = "所有舱室初始化失败,无可运行舱室";
                    AppData.LogService.TLLog(errora, LogEnum.RunRecord);
                    return (new TLInitControllerResult(), new List<int>());
                }

                // 上报启动排除告警(每坏舱一条;失败不影响启动)。
                try { AppData.ReportStartupFaults(); } catch { }

注:AppData.StartupFaultsAppData.ReportStartupFaults() 在 Task 6 实现;本 Task 先写调用,Task 6 补定义后整体编译通过。执行顺序:Task 4 与 Task 6 同一提交前一起编译(或先做 Task 6 再做 Task 4)。建议:先做 Task 6 再回填 Task 4 的两处调用,避免中途不编译。

  • Step 5: 编译确认 0 错(在 Task 6 完成后)

Run: dotnet build ivf_tl_operate_2.0/control/ivf_tl_Control.sln -c Debug Expected: 0 错。

  • [ ] Step 6: 提交

    git add ivf_tl_operate_2.0/control/ivf_tl_Control/StartMain.cs
    git commit -m "feat(house-fault): InitTL 改坏舱剔除——零可跑才中止,坏舱存AppData+上报告警"
    

Task 5: InitHouse 逐舱 try-catch(单舱构造异常只跳过该舱,真机门控)

Files:

  • Modify: ivf_tl_operate_2.0/control/ivf_tl_Control/StartMain.cs(InitHouse:160-259)

说明:InitHouse 是一个大 try-catch —— 任一 HouseBinN 构造抛异常 → 整个 InitHouse return false → 所有舱都起不来。改为每舱构造 + StartTask 各自 try-catch,单舱异常只记 HouseFault{InitException} 并跳过(spec §35)。10 个舱重复,用本地 helper DRY。

  • [ ] Step 1: 在 InitHouse 内加本地 helper(方法体开头,TLSetting tLSetting = ... 之前)

                // 单舱构造/启动各自兜底:抛异常只登记坏舱+跳过,不拖垮其余舱。
                void TryHouse(int sn, Action build, bool run)
                {
                    try
                    {
                        build();
                        if (run) { /* StartTask 在下方按 runHouses 统一触发,这里只构造 */ }
                    }
                    catch (Exception ex)
                    {
                        AppData.LogService.ExceptionLog(ex, $"舱{sn}构造失败(已隔离,其余舱继续)", null, LogEnum.RunException);
                        AppData.StartupFaults?.Add(new HouseFault
                        {
                            HouseSn = sn, Type = HouseFaultType.InitException,
                            Reason = $"舱{sn}构造异常:{ex.Message}", Stage = "舱初始化",
                            At = DateTime.UtcNow, Isolated = true
                        });
                    }
                }
    

注:using System;(Action/DateTime)在 StartMain 顶部已隐式可用(已 using System.Diagnostics; 等;若 Action 报未定义,加 using System;)。

  • Step 2: 每舱构造包进 TryHouse

把现有每舱两行(以舱 2 为例)

                housesn = 2;
                AppData.HouseBin2 = new HouseBin(tLSetting, tLInitControllerResult.HouseList.FirstOrDefault(x => x.houseSn == housesn), allList.Dishes.FirstOrDefault(x => x.houseSn == housesn), allList.BalanceList.FirstOrDefault(x => x.houseSn == housesn), tLInitControllerResult.HouseWellList.Where(x => x.houseSn == housesn).ToList());
                AppData.InitHouseBinEvent(AppData.HouseBin2);

改为:

                housesn = 2;
                TryHouse(2, () => {
                    AppData.HouseBin2 = new HouseBin(tLSetting, tLInitControllerResult.HouseList.FirstOrDefault(x => x.houseSn == 2), allList.Dishes.FirstOrDefault(x => x.houseSn == 2), allList.BalanceList.FirstOrDefault(x => x.houseSn == 2), tLInitControllerResult.HouseWellList.Where(x => x.houseSn == 2).ToList());
                    AppData.InitHouseBinEvent(AppData.HouseBin2);
                }, false);

对舱 1~10 同样处理(缓冲瓶 BufferBottleBin/舱 11 沿用原写法,11 失败由 InitTL 的可跑舱判定兜底,不在此包裹)。CamNum 赋值段(现 :215-224)保持不变,但需对可能为 null 的 HouseBinN 加判空:AppData.HouseBin2?.CamNum = CamNum; 不合法 —— 改为 if (AppData.HouseBin2 != null) AppData.HouseBin2.CamNum = CamNum;(10 舱逐个)。

  • Step 3: StartTask 段对 null 兜底(现 :226-250)

把每行 if (runHouses.Contains(2)) AppData.HouseBin2.StartTask(); 改为:

                    if (runHouses.Contains(2) && AppData.HouseBin2 != null)
                        try { AppData.HouseBin2.StartTask(); }
                        catch (Exception ex) { AppData.LogService.ExceptionLog(ex, "舱2 StartTask 失败(已隔离)", null, LogEnum.RunException); }

对舱 1/3/4/5/6/7/8/9/10 与 BufferBottleBin(11)同样加 != null 判空 + try-catch。

  • Step 4: 编译确认 0 错

Run: dotnet build ivf_tl_operate_2.0/control/ivf_tl_Control.sln -c Debug Expected: 0 错。

  • [ ] Step 5: 提交

    git add ivf_tl_operate_2.0/control/ivf_tl_Control/StartMain.cs
    git commit -m "feat(house-fault): InitHouse 逐舱 try-catch——单舱构造/启动异常只隔离该舱"
    

Task 6: AppData 坏舱清单字段 + 快照透出 + 告警上报

Files:

  • Modify: ivf_tl_operate_2.0/control/ivf_tl_Control/AppData.cs
  • Modify: ivf_tl_operate_2.0/control/ivf_tl_Control/MonitorSnapshot.cs
  • Test: ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/MonitorSnapshotFaultTests.cs

6a. MonitorSnapshot 加 Faults 字段(纯数据,TDD)

  • Step 1: 写失败测试

IvfTl.ControlHost.Tests/MonitorSnapshotFaultTests.cs:

using System;
using ivf_tl_Control;
using Xunit;

namespace IvfTl.ControlHost.Tests
{
    public class MonitorSnapshotFaultTests
    {
        [Fact]
        public void Snapshot_Faults_Defaults_Empty_Not_Null()
        {
            var s = new MonitorSnapshot();
            Assert.NotNull(s.Faults);
            Assert.Empty(s.Faults);
        }

        [Fact]
        public void HouseFaultRow_Holds_Fields()
        {
            var row = new HouseFaultRow
            {
                HouseSn = 6, FaultType = "CcdSnMissing",
                Reason = "相机列表中不存在仓室的CCDSN12345",
                Stage = "扫口握手", At = new DateTime(2026, 6, 23), Isolated = true
            };
            Assert.Equal(6, row.HouseSn);
            Assert.Equal("CcdSnMissing", row.FaultType);
            Assert.True(row.Isolated);
        }
    }
}
  • Step 2: 跑测试确认失败

Run: dotnet test ...IvfTl.ControlHost.Tests.csproj --filter MonitorSnapshotFaultTests Expected: FAIL —— MonitorSnapshot.Faults/HouseFaultRow 未定义。

  • Step 3: 实现 —— MonitorSnapshot.cs 末尾(MonitorSnapshot 类内加字段 + 文件内加 HouseFaultRow 类)

MonitorSnapshot 类内 Houses 字段后加:

        /// <summary>舱故障列表(启动排除 + 运行期突发),供 operate 监控页红色高亮展示。</summary>
        public List<HouseFaultRow> Faults { get; set; } = new List<HouseFaultRow>();

在文件内 HouseMonitorRow 类后(命名空间内)加:

    /// <summary>单条舱故障只读行(operate/front 展示用,字符串化枚举便于跨端)。</summary>
    public class HouseFaultRow
    {
        public int HouseSn { get; set; }
        public string FaultType { get; set; }
        public string Reason { get; set; }
        public string Stage { get; set; }
        public DateTime At { get; set; }
        public bool Isolated { get; set; }
    }
  • Step 4: 跑测试确认通过

Run: dotnet test ...IvfTl.ControlHost.Tests.csproj --filter MonitorSnapshotFaultTests Expected: PASS(2 绿)。

6b. AppData.StartupFaults + GetMonitorSnapshot 透出 + ReportStartupFaults

  • Step 5: AppData 加 StartupFaults 字段

AppData.csMqttService 等属性附近(如 CurrentUser 属性后)加:

        /// <summary>启动期坏舱清单(InitTL 写入,GetMonitorSnapshot 透出,ReportStartupFaults 上报)。</summary>
        public List<HouseFault> StartupFaults { get; set; } = new List<HouseFault>();

确认文件顶部已 using IvfTl.Control.Entity.InitEntitys;(AppData 大量用 InitEntitys);若无则加。

  • Step 6: GetMonitorSnapshot 透出 Faults(AppData.cs:222 附近,构造 MonitorSnapshot 处)

GetMonitorSnapshot 内、new MonitorSnapshot{...} 赋值各字段后(或返回前),把 StartupFaults 映射进快照:

            // 舱故障透出(StartupFaults → HouseFaultRow,字符串化枚举跨端)。
            try
            {
                snapshot.Faults = (StartupFaults ?? new List<HouseFault>())
                    .Select(f => new HouseFaultRow
                    {
                        HouseSn = f.HouseSn, FaultType = f.Type.ToString(),
                        Reason = f.Reason, Stage = f.Stage, At = f.At, Isolated = f.Isolated
                    }).ToList();
            }
            catch { }

snapshot 为该方法内构造的 MonitorSnapshot 局部变量名;若现有代码用对象初始化器一次性 return new MonitorSnapshot{...},改为先赋给局部 var snapshot = new MonitorSnapshot{...}; 再补上面映射,最后 return snapshot;。需要 using System.Linq;(AppData 已用 Linq)。

  • Step 7: AppData 加 ReportStartupFaults(复用现有告警通道)

参照现有 MqttAlarm(AppData.cs:1350,走 HttpService.AlarmApi1)与 AlarmApi(HttpService.cs:54,POST /api/tl/control/alarm/reportCloudAlarm,带 houseSn/wellSn)。新增:

        /// <summary>
        /// 上报启动期坏舱告警(每坏舱一条)。复用 HttpService.AlarmApi(reportCloudAlarm)。
        /// alarmTypeKey 按故障类型映射;paramList 携带原因/阶段。全 try 兜底,失败不影响启动。
        /// 注:新 alarmTypeKey 需在 Java 告警字典登记(否则落库被拒)—— 见计划「跨端依赖」。
        /// </summary>
        public void ReportStartupFaults()
        {
            if (StartupFaults == null || StartupFaults.Count == 0) return;
            string tlSn = "";
            try { tlSn = TLSetting?.tlSn ?? ""; } catch { }
            foreach (var f in StartupFaults)
            {
                try
                {
                    string key = f.Type == HouseFaultType.CcdSnMissing || f.Type == HouseFaultType.CameraDuplicateSn || f.Type == HouseFaultType.CameraReadFailed
                        ? "HOUSE_CAMERA_FAULT"
                        : (f.Type == HouseFaultType.SerialReadException ? "HOUSE_SERIAL_FAULT" : "HOUSE_INIT_EXCLUDED");
                    HttpService.AlarmApi(tlSn, f.HouseSn > 0 ? f.HouseSn : 0, 0, key,
                        new List<string> { f.Type.ToString(), f.Reason, f.Stage });
                }
                catch (Exception ex) { ExLog(ex, "ReportStartupFaults"); }
            }
        }
  • Step 8: 编译 + 跑全量单测

Run: dotnet build ivf_tl_operate_2.0/control/ivf_tl_Control.sln -c Debug → 0 错 Run: dotnet test ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/IvfTl.ControlHost.Tests.csproj Expected: 既有 27 + 新增(HouseFault 2 + Policy 6 + Snapshot 2)= 37 绿 0 失败

  • [ ] Step 9: 提交

    git add ivf_tl_operate_2.0/control/ivf_tl_Control/AppData.cs \
        ivf_tl_operate_2.0/control/ivf_tl_Control/MonitorSnapshot.cs \
        ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/MonitorSnapshotFaultTests.cs
    git commit -m "feat(house-fault): AppData.StartupFaults+快照透出+ReportStartupFaults;MonitorSnapshot.Faults(TDD 37绿)"
    

Task 7: 真机拔插验收(Claude 自主,UAC 提权)

前置: 当前无活体培养(拔插不伤生物);UAC 可静默提权起停 control(见记忆 [[control-realmachine-smoke-startup]]);control 须从 operate 输出根的 control\ 子目录跑(..\tl-shared.config 才解析得到,见交接卡 2026-06-23 真机段第 4 点)。

  • Step 1: 编译 Release + 部署

Run: dotnet build ivf_tl_operate_2.0/control/ivf_tl_Control.sln -c Release(真机/连内网必须 Release);确认 control\ 子目录布局 + tl-shared.config 在 operate 根。

  • [ ] Step 2: 基线(全好舱) —— 提权起 control,/statussnapshot.Faults 为空、Houses 含全部扫到的舱、started:true。记录可跑舱清单。

  • [ ] Step 3: 制造一个"半坏"舱 —— 物理拔掉某有效舱的相机 USB(该舱串口仍活)→ 重启 control。预期:

    • control 不中止started:true;
    • 该舱出现在 snapshot.Faults(FaultType=HOUSE_CAMERA_FAULT/CcdSnMissingIsolated:true),且不在可跑舱清单;
    • 其余舱全部正常 StartAsync(读温≈36℃、握手正常)。
  • [ ] Step 4: 告警落库核实 —— 用 JDBC(临时文件/DbSql.java 模式)连 108 查 aivfo-tl-control 告警表,确认该坏舱告警入库(alarmTypeKey + houseSn + 原因)。若被拒,记录:需 Java 告警字典登记新 alarmTypeKey(见「跨端依赖」),作为 Phase 4 门控项。

  • [ ] Step 5: 恢复 + 复测 —— 插回相机重启 control,确认该舱回到可跑、Faults 不再含它。

  • [ ] Step 6: 回写文档(见回写协议)+ 提交。

    git add 项目文档/进度/进度状态.yaml 项目文档/进度/交接卡.md 项目文档/进度/工作计划表.md 项目文档/进度/待验证清单.md 项目文档/进度/进度数据.js
    git commit -m "docs(house-fault): 第一阶段 control 坏舱剔除真机拔插验收回写"
    

后续阶段(提纲,第一阶段落地后单独拆细)

第二阶段:运行期故障升级 + 去抖(control,可自主)

  • HouseBin/BufferBottleBin.MainThreadComBin.StartSendCommandThreadcatch 里:同舱连续失败计数达阈值(去抖,避免每圈刷屏)→ 升级一条 HouseFault{RuntimeFault}AppData.StartupFaults(改名/或新增 RuntimeFaults)+ ReportStartupFaults 同款上报 HOUSE_RUNTIME_FAULT;失败计数清零条件 = 该舱恢复一次成功通讯。
  • 纯逻辑(计数/阈值/去抖)可 TDD;升级动作真机验证(运行中拔串口/相机)。

第三阶段:operate 监控页"舱故障"区(真机门控)

  • ivf_tl_Operate 监控页(读 ControlClient/statussnapshot.Faults)新增"舱故障"区:红色高亮列出每故障舱(舱号/类型/时间/已隔离),与现有监控三块并列。
  • 复用现有轮询;UI 真机门控(需 operate 外壳跑起来,受僵尸 operate Mutex 影响,需先清)。

第四阶段:front 告警展示(真机门控)+ 跨端依赖

  • 跨端依赖(必须先确认): 新 alarmTypeKey(HOUSE_INIT_EXCLUDED/HOUSE_CAMERA_FAULT/HOUSE_SERIAL_FAULT/HOUSE_RUNTIME_FAULT)需在 Java 告警字典/告警类型配置登记,否则 reportCloudAlarm 落库被拒、front 也无从展示。Task 7 Step 4 已先探;此阶段落地登记。
  • front 舱位图/告警列表:故障舱标红 + 统一文案("X 号舱相机故障,已隔离,其余舱正常");接现有 alarm_data + MQTT 流。

第五阶段:真机拔插整体验收(spec §48-51 三条)

  • ① 启动期半坏舱排除、其余正常;② 运行期拔串口/相机隔离、其余采集不中断;③ 双端(operate 监控页 + front)都明确显示故障舱(舱号/类型/时间)。
  • 逐条回写 待验证清单.md

自查(写完对照 spec)

  • spec §27 启动期按舱容错 → Task 2(Policy 剔除)+ Task 3(SerialBin 登记)+ Task 4(InitTL 不再一刀切)+ Task 5(InitHouse 逐舱兜底)。✓
  • spec §28 双端明确提示 → Task 6(快照 Faults + ReportStartupFaults 上报)覆盖 control 侧出口;operate/front 展示在第三/四阶段。✓(第一阶段做到"故障可被双端拿到",展示分阶段)
  • spec §33-35 设计要点(区分致命/单舱、剔除坏舱、逐舱 try-catch) → Task 2/3/4/5 一一对应。✓
  • spec §38-41 统一上报(复用 alarm_data+MQTT、/status 加舱故障字段、运行期去抖) → Task 6(复用 reportCloudAlarm + 快照字段)+ 第二阶段(去抖)。✓
  • spec §48-51 真机验收 → Task 7(启动期)+ 第五阶段(运行期+双端)。✓
  • 类型一致性自查: HouseFault(Entity)字段贯穿 SerialBin/InitTL/AppData;HouseFaultRow(MonitorSnapshot)与 AppData 映射字段名一致(HouseSn/FaultType/Reason/Stage/At/Isolated);StartupFaultPolicy.RunnableHouses/IsFatal/BadHouseSns 签名在 Task2 定义、Task4 调用一致;AppData.StartupFaults/ReportStartupFaults Task4 调用、Task6 定义一致(执行注:先做 Task6 再回填 Task4 调用,保证中途可编译)。✓
  • 占位扫描: 无 TBD/TODO;每代码步均有完整代码块。真机行为类步骤(Task3/4/5/7)给出确切编辑点 file:line 与验证预期,非"自行处理"。✓