diff --git a/Directory.Build.targets b/Directory.Build.targets index 0196905..468cd84 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -31,22 +31,14 @@ Condition="Exists('$(TargetDir)leegKeys-sdk.dll')" /> + - - - - - - diff --git a/StandardScene.Signal/Logic/CarScanWorker.cs b/StandardScene.Signal/Logic/CarScanWorker.cs index dcafc26..ddb25b0 100644 --- a/StandardScene.Signal/Logic/CarScanWorker.cs +++ b/StandardScene.Signal/Logic/CarScanWorker.cs @@ -9,17 +9,43 @@ using StandardScene.Signal.Plc; namespace StandardScene.Signal.Logic { - /// 1s 扫全场车,对齐反编译 DefaultPlugin.ExcuteThreadMethod + StarSignManage。 + /// + /// 1s 扫全场车,对齐反编译 DefaultPlugin.ExcuteThreadMethod + StarSignManage。 + /// + /// + /// 每辆车每拍只看当前站点: + /// + /// 停稳:先按握手点类型分发 Handler,再执行放行点(放行点不读 PLC,可能把还在等允许进入的车开走,站点不要两边都配)。 + /// 行驶中:仅 JGControl + RequestIn 调用 RequestInJudge,PLC 未允许进入则停车。 + /// + /// 握手点解析优先站点 fields(),没有再查 JSON。 + /// public sealed class CarScanWorker : IDisposable { + /// 取当前全部机构运行态;由 Mission 提供,内部会拷贝列表。 private readonly Func> _runtimes; + + /// 扫车间隔,下限 100ms,默认跟进程 TickMs=1000。 private readonly int _tickMs; + + /// 保护握手点 / 放行点字典,热替换配置时与扫车拍互斥。 private readonly object _configLock = new object(); + + /// 站点 → 握手点。同一站点多条配置只留第一条。 private Dictionary _handshake = new Dictionary(); + + /// 站点 → 放行点。同一站点多条配置只留第一条。 private Dictionary _release = new Dictionary(); + + /// 限流区域上次记日志的车号,避免同一区域每秒刷屏。 private readonly Dictionary _rateFlowLog = new Dictionary(); + + /// 车辆没有 GetChargeTime 时,用首次看到「充电中」的时刻估算秒数。 private readonly Dictionary _chargeStarted = new Dictionary(); + + /// 未知 SignalType 的日志节流(同站点 10s 一次)。 private readonly Dictionary _unknownTypeLog = new Dictionary(); + private CancellationTokenSource _cts; private Thread _thread; private int _tickCount; @@ -30,8 +56,13 @@ namespace StandardScene.Signal.Logic _tickMs = Math.Max(100, tickMs); } + /// 已完成的扫车拍数,写入进程 status 供监视。 public int TickCount => _tickCount; + /// + /// 热替换握手点 / 放行点。过滤掉站点号无效或信号类型为空的握手点。 + /// 不中断线程,下一拍生效。 + /// public void ReplaceConfig(IEnumerable handshake, IEnumerable release) { lock (_configLock) @@ -47,6 +78,7 @@ namespace StandardScene.Signal.Logic } } + /// 启动后台扫车线程。重复 Start 若线程仍活则忽略。 public void Start() { if (_thread != null && _thread.IsAlive) return; @@ -60,6 +92,7 @@ namespace StandardScene.Signal.Logic Diagnosis.Log("扫车握手线程已启动", "Signal", true); } + /// 取消令牌并最多等 2s 退出。不抛异常,供进程停止时调用。 public void Stop() { try { _cts?.Cancel(); } @@ -92,6 +125,10 @@ namespace StandardScene.Signal.Logic } } + /// + /// 一拍:拷贝配置字典 → 按机构名查找闭包 → 遍历全部车。 + /// 查找机构时忽略大小写,与 JSON 里 JgName 对齐。 + /// internal void ScanOnce() { Dictionary handshake; @@ -130,6 +167,10 @@ namespace StandardScene.Signal.Logic } } + /// + /// 站点 fields 有 SignalType 则用地图点配置(老 SignalStop); + /// 否则用 handshake-points.json。fields 存在但类型为空视为本站不握手。 + /// private SignalStopSpec ResolveSpec(int sit, IReadOnlyDictionary handshake) { if (sit <= 0) return null; @@ -147,6 +188,10 @@ namespace StandardScene.Signal.Logic return null; } + /// + /// 停稳分发。TimedCharging 优先:充电点不应再走机构逻辑。 + /// 未识别的 SignalType 按站点节流打日志,避免刷屏。 + /// private void DispatchStopped(Car car, SignalStopSpec spec, Func find) { if (spec.Is("TimedCharging")) diff --git a/StandardScene.Signal/Logic/JgControlHandler.cs b/StandardScene.Signal/Logic/JgControlHandler.cs index 214d017..e9382d4 100644 --- a/StandardScene.Signal/Logic/JgControlHandler.cs +++ b/StandardScene.Signal/Logic/JgControlHandler.cs @@ -8,9 +8,28 @@ using StandardScene.Signal.Plc; namespace StandardScene.Signal.Logic { - /// 对齐反编译 DefaultPlugin.JGControl / JGJudge / RequestIn / Arrived。 + /// + /// 机构进出站握手。对齐反编译 DefaultPlugin.JGControl / JGJudge / RequestIn / Arrived。 + /// + /// + /// + /// SignalParams 分号三段:机构名;动作;限流。 + /// 动作 RequestIn(请求进入,配在机构口外等待点)或 Arrived(到位,配在机构内地标)。 + /// 第三段可选,格式与独立限流不同:区域-车组-限额-点1,点2(短横分隔,见 )。 + /// + /// + /// RequestIn 放行条件(需同时满足):PLC 读字节=允许进入、尚无请求进入车、写字节=无状态。 + /// 下线机构额外要求 JgPointId 上没有其它车。通过后写「进入中」、记下 RequestInAgvNo、停充并启动。 + /// + /// + /// 车已在跑但本车不是请求进入车、且 PLC 不是允许进入时,立即停车(停稳拍与行驶拍都会判)。 + /// Arrived:若 InAgvNo 还不是本车,写「到位」、登记 InAgvNo、清空 RequestInAgvNo。 + /// 之后的「离开中 / 已离开」由 PLC Worker 的 推进。 + /// + /// public static class JgControlHandler { + /// 停稳:可选限流 → RequestIn 或 Arrived。 public static void OnStopped(Car car, SignalStopSpec spec, Func find, IDictionary rateFlowLog) { var jgName = spec.Part(0).Trim(); @@ -34,6 +53,7 @@ namespace StandardScene.Signal.Logic Arrived(jgName, car, find); } + /// 行驶中只做 RequestIn 安全闸:未允许进入且不是本车占用请求槽则停车。 public static void OnMoving(Car car, SignalStopSpec spec, Func find) { var jgName = spec.Part(0).Trim(); @@ -44,6 +64,10 @@ namespace StandardScene.Signal.Logic RequestInJudge(jgName, car, find); } + /// + /// 请求进入。锁机构运行态,避免与 PLC Worker 同时改写字节 / 车号。 + /// 后半段与 相同,覆盖「刚放行后又被调度层启动」的竞态。 + /// private static void RequestIn(string jgName, Car car, Func find) { var rt = find(jgName); @@ -89,6 +113,7 @@ namespace StandardScene.Signal.Logic } } + /// 行驶中的 RequestIn 安全闸,条件与 RequestIn 后半段一致。 private static void RequestInJudge(string jgName, Car car, Func find) { var rt = find(jgName); @@ -110,6 +135,9 @@ namespace StandardScene.Signal.Logic } } + /// + /// 到位。InAgvNo 已是本车则认为已报到,避免每秒重复写「到位」把 WriteSuccess 清掉。 + /// private static void Arrived(string jgName, Car car, Func find) { var rt = find(jgName); diff --git a/StandardScene.Signal/Logic/PointControlHandler.cs b/StandardScene.Signal/Logic/PointControlHandler.cs index aa6d130..1ff2fb1 100644 --- a/StandardScene.Signal/Logic/PointControlHandler.cs +++ b/StandardScene.Signal/Logic/PointControlHandler.cs @@ -6,9 +6,18 @@ using StandardScene.Signal.Plc; namespace StandardScene.Signal.Logic { - /// 对齐反编译 DefaultPlugin.PointControl:牵引棒升/降到位后再放行。 + /// + /// 销钉 / 牵引棒升降后再放行。对齐反编译 DefaultPlugin.PointControl。 + /// + /// + /// SignalParams动作;机构名。 + /// 动作:UP1 升销钉1,UP2 升销钉1+2,Down1 / Down2 对应下降。 + /// 机构名可选:填了则必须等该机构读字节=允许离开才启动;空则只等销钉动作完成。 + /// 销钉指令通过 反射调用车体 MethodMember(如 SetPin1Up)。 + /// public static class PointControlHandler { + /// 停稳:先判机构是否允许离开,再下发销钉,全部到位后启动。 public static void OnStopped(Car car, SignalStopSpec spec, Func find) { var action = spec.Part(0).Trim(); @@ -48,6 +57,10 @@ namespace StandardScene.Signal.Logic } } + /// + /// 任一销钉未完成则本拍只下发指令、不启动; + /// 全部完成且(无需等离开 或 已允许离开)才 Start。 + /// private static void PinThenGo(Car car, int pos, bool waitLeave, string jgName, params string[] actions) { var done = true; diff --git a/StandardScene.Signal/Logic/RateFlowHandler.cs b/StandardScene.Signal/Logic/RateFlowHandler.cs index a514fee..720a5eb 100644 --- a/StandardScene.Signal/Logic/RateFlowHandler.cs +++ b/StandardScene.Signal/Logic/RateFlowHandler.cs @@ -8,11 +8,17 @@ using StandardScene.Signal.Plc; namespace StandardScene.Signal.Logic { /// - /// 对齐反编译 DefaultPlugin.RateFlowControl。 - /// 独立类型用分号:区域;车组;限额;点1,点2。JGControl 第三段用短横:区域-车组-限额-点1,点2。 + /// 区域车组限流。对齐反编译 DefaultPlugin.RateFlowControl。 /// + /// + /// 独立握手类型 RateFlowControl 用分号:区域;车组;限额;点1,点2。 + /// 挂在 JGControl 第三段时用短横:区域-车组-限额-点1,点2(因为前两段机构名/动作已经占用分号)。 + /// 统计「站点列表上、同车组、且不是本车」的数量,小于限额才放行。 + /// 解析异常时放行(return true),避免配错把产线卡死。 + /// public static class RateFlowHandler { + /// 独立限流点:通过则停充并启动。 public static void OnStopped(Car car, SignalStopSpec spec, IDictionary logByArea) { if (AllowPass(car, spec.SignalParams, logByArea, sep: ';')) @@ -22,9 +28,11 @@ namespace StandardScene.Signal.Logic } } + /// JGControl 第三段限流,短横分隔。不通过时调用方应直接 return,不启动。 public static bool AllowPass(Car car, string rateFlowString, IDictionary logByArea) => AllowPass(car, rateFlowString, logByArea, sep: '-'); + /// 独立类型用 ';',JGControl 挂载用 '-'。 private static bool AllowPass(Car car, string raw, IDictionary logByArea, char sep) { try @@ -71,6 +79,7 @@ namespace StandardScene.Signal.Logic } } + /// 同一限流区域仅在「决策车号变化」时再打一条带原始参数的日志。 private static void Note(IDictionary logByArea, string area, Car car, string raw) { if (logByArea == null) return; diff --git a/StandardScene.Signal/Logic/ReleasePointHandler.cs b/StandardScene.Signal/Logic/ReleasePointHandler.cs index de88af6..7e37ed1 100644 --- a/StandardScene.Signal/Logic/ReleasePointHandler.cs +++ b/StandardScene.Signal/Logic/ReleasePointHandler.cs @@ -5,9 +5,17 @@ using StandardScene.Signal.Plc; namespace StandardScene.Signal.Logic { - /// 对齐反编译 StarSignManage:配置点停稳则停充并放行。 + /// + /// 无条件放行点。对齐反编译 StarSignManage / StartSignModel。 + /// + /// + /// 车在配置站点停稳且 为 true 时:停充并启动,不读 PLC。 + /// 用于任务在某点停住、充完电或人工暂停后需要自动续跑的站点。 + /// 机构口应配握手点,不要同时配放行点——扫车线程在握手 Handler 之后仍会执行本方法,可能提前开走。 + /// public static class ReleasePointHandler { + /// 按当前站点号查放行表;未配置或未启用则什么都不做。 public static void OnStopped(Car car, int sit, IReadOnlyDictionary points) { if (points == null || !points.TryGetValue(sit, out var p) || p == null || !p.IsUse) diff --git a/StandardScene.Signal/Logic/SignalStopSpec.cs b/StandardScene.Signal/Logic/SignalStopSpec.cs index f2f8374..03d0e85 100644 --- a/StandardScene.Signal/Logic/SignalStopSpec.cs +++ b/StandardScene.Signal/Logic/SignalStopSpec.cs @@ -3,13 +3,31 @@ using StandardScene.Signal.Model; namespace StandardScene.Signal.Logic { - /// 握手点解析结果。参数格式与反编译 SignalStop.SignalParams 一致。 + /// + /// 一条握手点配置的解析结果。参数格式与反编译 SignalStop.SignalParams 一致。 + /// + /// + /// 按分号切成 ,缺段时 返回空串。 + /// 各类型约定: + /// + /// JGControl:机构名;RequestIn|Arrived;[区域-车组-限额-点列表] + /// PointControl:UP1|UP2|Down1|Down2;机构名 + /// TimedCharging:最长秒数;上限电量;下限电量 + /// RateFlowControl:区域;车组;限额;点1,点2 + /// + /// public sealed class SignalStopSpec { + /// 握手类型名,与 Handler 的 比较时忽略大小写。 public string SignalType { get; init; } = ""; + + /// 原始参数串,限流独立类型会整段交给 public string SignalParams { get; init; } = ""; + + /// 按 ';' 切分,保留空段(与 Split None 一致,方便 Part(n) 按下标取)。 public string[] Parts { get; init; } = Array.Empty(); + /// 取第 段;越界或 null 返回空串。 public string Part(int index) { if (index < 0 || index >= Parts.Length) @@ -17,6 +35,7 @@ namespace StandardScene.Signal.Logic return Parts[index] ?? ""; } + /// 从类型名和参数串构造;类型会 Trim,参数保持原样以便日志对照现场 JSON。 public static SignalStopSpec Parse(string signalType, string signalParams) { var type = (signalType ?? "").Trim(); @@ -29,6 +48,7 @@ namespace StandardScene.Signal.Logic }; } + /// 从握手点 Model 构造;model 为 null 时返回 null。 public static SignalStopSpec From(HandshakePointModel model) { if (model == null) @@ -36,6 +56,7 @@ namespace StandardScene.Signal.Logic return Parse(model.SignalType, model.SignalParams); } + /// 类型名是否匹配(忽略大小写)。 public bool Is(string type) => string.Equals(SignalType, type, StringComparison.OrdinalIgnoreCase); } diff --git a/StandardScene.Signal/Logic/TimedChargeHandler.cs b/StandardScene.Signal/Logic/TimedChargeHandler.cs index 96c0c05..8392130 100644 --- a/StandardScene.Signal/Logic/TimedChargeHandler.cs +++ b/StandardScene.Signal/Logic/TimedChargeHandler.cs @@ -6,9 +6,18 @@ using StandardScene.Signal.Plc; namespace StandardScene.Signal.Logic { - /// 对齐反编译 DefaultPlugin.TimedCharging:时长 / 上限电量 / 下限电量。 + /// + /// 站点定时充电。对齐反编译 DefaultPlugin.TimedCharging。 + /// + /// + /// SignalParams最长秒数;上限电量;下限电量,某段填 0 表示该条件不启用。 + /// 正在充电:电量 ≥ 上限则停充;时长到且电量已过下限则停充;时长到但电量仍 ≤ 下限则继续充。 + /// 已停充且车仍停着:视为充电流程结束,启动放行。 + /// 充电秒数优先反射 GetChargeTime,没有则用 字典估算。 + /// public static class TimedChargeHandler { + /// 停稳且本站是 TimedCharging 时由扫车线程调用。 public static void OnStopped(Car car, SignalStopSpec spec, IDictionary chargeStarted) { int.TryParse(spec.Part(0), out var maxSeconds); diff --git a/StandardScene.Signal/Mission/SignalInteractMission.cs b/StandardScene.Signal/Mission/SignalInteractMission.cs index cac96d7..33db39c 100644 --- a/StandardScene.Signal/Mission/SignalInteractMission.cs +++ b/StandardScene.Signal/Mission/SignalInteractMission.cs @@ -16,51 +16,129 @@ using StandardScene.Utils; namespace StandardScene.Signal { /// - /// 第三方信号交互进程:PLC Worker(200ms)+ 扫车握手(1s)。 + /// 第三方信号交互进程(场景 scene.signal 的唯一 Mission)。 /// + /// + /// + /// 对齐梅山吉利反编译插件 CoreClass30.A20050.Plugin.DefaultPlugin: + /// 一边用西门子 S7 和机构 PLC 交换进出站字节,一边按站点对停稳 AGV 做握手 / 放行。 + /// + /// + /// 两条独立循环,不要合成一次 HTTP 或任务编排节点: + /// + /// + /// + /// PLC Worker + /// + /// 每个机构一条后台线程,默认 200ms:心跳翻转、读「允许进入/离开」、回读写字节、按需写入、 + /// 推进离站状态机。 + /// + /// + /// + /// 扫车线程 + /// + /// 默认 1s 扫全场车。停稳则按握手点类型分发给 + /// JGControl / PointControl / TimedCharging / RateFlowControl,再执行放行点; + /// 行驶中仅对 JGControl 的 RequestIn 做「未允许进入则急停」。 + /// + /// + /// + /// + /// 配置三张 JSON(工作目录 Config/Signal/,可由迷毂数据中心改,保存后点「重新加载配置」): + /// 机构 stations.json、握手点 handshake-points.json、放行点 release-points.json。 + /// 站点 fields 若已写 SignalType/SignalParams,优先于握手点表。 + /// + /// + /// CycleGUI 进程工具栏必须点「信号交互进程」,不要打开开放泛型编辑器。 + /// 列表窗体只改 JSON,不经过本进程的 HTTP;改完需本进程已启动才会重启 Worker。 + /// + /// [MissionType(Name = "信号交互进程", editor = typeof(SignalInteractMission))] [I18N.DocumentTranslation(Name = "Signal Interact Mission", locale = "en")] public class SignalInteractMission : Mission { + /// 扫车周期(毫秒)。对齐反编译 DefaultPlugin 主循环,现场一般保持 1000。 [FieldMember] public int TickMs = 1000; + /// 单机构 PLC 轮询周期(毫秒)。对齐 PLCManager,现场一般保持 200。 [FieldMember] public int PlcPollMs = 200; + /// + /// 西门子系列,传给 IoTClient SiemensVersion。 + /// 常用 S7_1500 / S7_1200 / S7_300;连字符会当成下划线解析。 + /// [FieldMember] public string PlcVersion = "S7_1500"; + /// 机构未单独填 Port 时使用的 S7 端口,默认 102。 [FieldMember] public int PlcPort = 102; + /// S7 机架槽号,S7-1500 常见为 1;写入 [FieldMember] public int PlcSlot = 1; + /// 机构表路径。相对路径只解析工作目录 Config/Signal,不读插件目录。 [FieldMember] public string StationsPath = @"Config\Signal\stations.json"; + /// 放行点表路径。车停在这些站点且启用时,无条件停充并启动,不读 PLC。 [FieldMember] public string ReleasePointsPath = @"Config\Signal\release-points.json"; + /// 握手点表路径。按站点配置 JGControl / PointControl / TimedCharging / RateFlowControl。 [FieldMember] public string HandshakePointsPath = @"Config\Signal\handshake-points.json"; + /// Execute 成功后为 true;ReloadConfig 仅在已启动时重启 Worker / 扫车。 [JsonIgnore] private bool _started; + + /// 保护 :启动、停止、状态快照、扫车查找机构时共用。 [JsonIgnore] private readonly object _workerLock = new object(); + + /// 当前在跑的机构 Worker,与 stations.json 一一对应(空名称的机构不会创建)。 [JsonIgnore] private readonly List _workers = new List(); + + /// 全场扫车握手线程;停止进程时置空。 [JsonIgnore] private CarScanWorker _scan; + + /// PLC 每 tick 都会回调刷新状态;用计数把 UI 刷新降到约每 5 个 poll 一次。 [JsonIgnore] private int _statusTick; + /// 进程监视面板展示的运行摘要(机构连接数、扫车次数、各站读写字节)。 public class SignalInteractMissionStatus : MissionStatus { + /// 当前 Worker 数量,等于已加载且名称非空的机构数。 public int StationCount { get; set; } + + /// 最近一次快照里 IsConnected == true 的机构数。 public int ConnectedCount { get; set; } + + /// 放行点 JSON 条数(含未启用)。 public int ReleasePointCount { get; set; } + + /// 握手点 JSON 条数。 public int HandshakePointCount { get; set; } + + /// ,用于确认扫车线程是否在跑。 public int ScanTickCount { get; set; } + + /// 实际解析到的机构文件绝对路径。 public string StationsFile { get; set; } + + /// 实际解析到的放行点文件绝对路径。 public string ReleasePointsFile { get; set; } + + /// 实际解析到的握手点文件绝对路径。 public string HandshakePointsFile { get; set; } + + /// 各机构运行态快照,供监视列表展示。 public List Stations { get; set; } = new List(); } public override MissionStatus status { get; set; } = new SignalInteractMissionStatus(); + /// SimpleLite 按 MissionType 反射创建实例时调用。 public static Mission Create() => new SignalInteractMission(); + /// + /// 加载三张 JSON,拉起全部 PLC Worker 和扫车线程。 + /// 重复点击会先停再建;失败时 status 为「启动失败」并记诊断。 + /// [MethodMember(Name = "启动进程", Description = "加载配置,启动 PLC Worker 与扫车握手")] public override void Execute() { @@ -83,6 +161,11 @@ namespace StandardScene.Signal } } + /// + /// 重新读 JSON 并刷新 status 里的路径/条数。 + /// 进程已启动时:重建 PLC Worker,并向扫车线程热替换握手点/放行点(不必停进程)。 + /// 迷毂数据中心改完文件后应调本方法,否则内存里仍是旧表。 + /// [MethodMember(Name = "重新加载配置", Description = "重新读取 JSON;若进程已启动则重启 Worker/扫车")] public void ReloadConfig() { @@ -113,6 +196,7 @@ namespace StandardScene.Signal } } + /// 停扫车、停全部 PLC 连接。隐藏了基类 Stop,工具栏显示本方法。 [MethodMember(Name = "停止进程")] public new void Stop() { @@ -125,6 +209,7 @@ namespace StandardScene.Signal Diagnosis.Post("信号交互进程已停止", "Signal", true); } + /// CycleGUI 独立窗体编辑机构表;保存立刻写 JSON,需「重新加载配置」才进 Worker。 [MethodMember(Name = "打开机构列表", Description = "按 PlcStationModel 增删改机构配置")] public void OpenStations() { @@ -139,6 +224,7 @@ namespace StandardScene.Signal } } + /// CycleGUI 独立窗体编辑握手点;主键展示为「站点号 + 信号类型」。 [MethodMember(Name = "打开握手点列表", Description = "按站点配置 JGControl / PointControl / TimedCharging / RateFlowControl")] public void OpenHandshakePoints() { @@ -156,6 +242,7 @@ namespace StandardScene.Signal } } + /// CycleGUI 独立窗体编辑放行点;主键为站点号。 [MethodMember(Name = "打开放行点列表", Description = "按 ReleasePointModel 增删改放行点")] public void OpenReleasePoints() { @@ -170,6 +257,10 @@ namespace StandardScene.Signal } } + /// + /// 扫车 Handler 用机构名查找运行态。返回拷贝列表,避免长时间持锁; + /// 运行态对象本身仍由各 Worker 持有,读写需 。 + /// internal IReadOnlyList Runtimes { get @@ -179,6 +270,7 @@ namespace StandardScene.Signal } } + /// 停旧扫车线程,按当前 JSON 新建并启动。 private void RestartScan() { try { _scan?.Stop(); } @@ -191,6 +283,10 @@ namespace StandardScene.Signal _scan.Start(); } + /// + /// 先停全部旧连接,再按机构表为每个非空名称建 。 + /// 机构 Port<=0 时回退进程级 。 + /// private void RestartWorkers() { StopWorkers(); @@ -219,6 +315,7 @@ namespace StandardScene.Signal PushStatus(); } + /// 清空列表后再 Stop,避免扫车在停连接过程中仍拿到旧 Runtime。 private void StopWorkers() { List copy; @@ -235,6 +332,7 @@ namespace StandardScene.Signal } } + /// PLC Worker 每周期回调;约每 5 次才刷一次监视面板,避免 200ms 打满 UI。 private void RefreshStatus() { if (Interlocked.Increment(ref _statusTick) % 5 != 0) @@ -242,6 +340,7 @@ namespace StandardScene.Signal PushStatus(); } + /// 把各机构快照和扫车次数写进 MissionStatus,供 SimpleLite 进程监视。 private void PushStatus() { List snaps; diff --git a/StandardScene.Signal/Model/HandshakePointModel.cs b/StandardScene.Signal/Model/HandshakePointModel.cs index bcb5a00..57b3b9e 100644 --- a/StandardScene.Signal/Model/HandshakePointModel.cs +++ b/StandardScene.Signal/Model/HandshakePointModel.cs @@ -4,16 +4,37 @@ namespace StandardScene.Signal.Model { /// /// 握手点配置。对应老地图点上的 SignalStop(SignalType + SignalParams)。 - /// 站点 fields 若已填写同名键,优先用站点字段。 /// + /// + /// 站点 fields 若已填写同名键,扫车优先用站点字段,本表该站点不再生效。 + /// SignalParams 按分号切分,各类型格式见 。 + /// 列表主键展示为「站点号 + 类型」,同一站点建议只配一条。 + /// + [Category("握手点数据管理")] + [DisplayName("握手点配置列表")] public class HandshakePointModel { + /// 地图站点号,与车 PositionId(siteID / GetLastSite)对应。 [DisplayName("站点")] public int Sit { get; set; } + /// + /// 握手类型:JGControl 机构进出、 + /// PointControl 销钉升降、 + /// TimedCharging 定时充电、 + /// RateFlowControl 区域限流。 + /// 其它字符串会每 10s 打一次「未找到业务模块」日志。 + /// [DisplayName("信号类型")] public string SignalType { get; set; } = "JGControl"; + /// + /// 类型参数。示例: + /// JGControl 上线机构1;RequestIn / 上线机构1;Arrived / 第三段限流 缓存区-A-2-60,61; + /// PointControl UP1;下线机构1; + /// TimedCharging 300;95;20; + /// RateFlowControl 缓存区;A;2;60,61,62。 + /// [DisplayName("信号参数")] public string SignalParams { get; set; } = ""; } diff --git a/StandardScene.Signal/Model/PlcStationModel.cs b/StandardScene.Signal/Model/PlcStationModel.cs index 762c57d..e215aff 100644 --- a/StandardScene.Signal/Model/PlcStationModel.cs +++ b/StandardScene.Signal/Model/PlcStationModel.cs @@ -3,40 +3,72 @@ using System.ComponentModel; namespace StandardScene.Signal.Model { /// - /// PLC 机构配置。对应反编译 PLCModel 的可存档字段;读写运行态不进本类。 + /// PLC 机构静态配置,对应反编译 PLCModel 可存档字段。 + /// 读写运行态(当前字节、机构内车号)在 ,不进本类、不进 JSON。 + /// 机构名称不能重复,列表编辑器用 JgName 做主键。 /// + [Category("PLC数据管理")] + [DisplayName("PLC配置列表(机构名称不能重复)")] public class PlcStationModel { + /// 逻辑名,握手点 SignalParams 第一段、Worker 线程名都用它。不能空、不能重复。 [DisplayName("机构名称")] public string JgName { get; set; } = ""; + /// 西门子 PLC IP。 [DisplayName("机构IP")] public string Ip { get; set; } = "127.0.0.1"; + /// S7 端口。填 0 则用进程字段 PlcPort(默认 102)。 [DisplayName("端口")] public int Port { get; set; } = 102; + /// + /// 读字节地址(PLC→调度:允许进入/离开)。 + /// 支持 DB1.DBB0 或 Hsl 风格 DB1.0。空则跳过读取。 + /// [DisplayName("读地址")] public string ReadDb { get; set; } = ""; + /// + /// 写字节地址(调度→PLC:进入中/到位/离开中/已离开)。 + /// Worker 会先回读再按需写入。空则跳过写和回读。 + /// [DisplayName("写地址")] public string WriteDb { get; set; } = ""; + /// + /// 心跳位地址,如 DB1.DBX2.0。 + /// Worker 每拍读当前值再写反码,让 PLC 判断调度进程存活。空则不做心跳。 + /// [DisplayName("写心跳地址")] public string HeartDb { get; set; } = ""; + /// 机构进入地标(预留,与老 PLCModel 字段对齐;上线跟车主要用 JgInContains)。 [DisplayName("机构进入地标")] public int JgInPointId { get; set; } + /// + /// 进入过程包含的站点列表(逗号/分号/空格分隔)。 + /// 上线:写字节=进入中 且车已不在此列表 → 视为到位。 + /// [DisplayName("机构进入包含地标")] public string JgInContains { get; set; } = ""; + /// + /// 机构工作地标。下线 RequestIn 用它查「机构内是否已有车」; + /// 下线/等待通行离站时,车必须停在此点才响应允许离开。 + /// [DisplayName("机构地标")] public int JgPointId { get; set; } + /// + /// 机构占用包含的站点列表。车已不在此列表且写字节还不是已离开 → 写已离开。 + /// [DisplayName("机构包含地标")] public string JgContains { get; set; } = ""; + /// 上线 / 单牵引下线 / 双牵引下线 / 等待通行,决定 AutoLeave 分支。 [DisplayName("上下机构")] public SignalLineType LineType { get; set; } = SignalLineType.未知; } diff --git a/StandardScene.Signal/Model/PlcStationSnapshot.cs b/StandardScene.Signal/Model/PlcStationSnapshot.cs index 32a7ea5..5a5b827 100644 --- a/StandardScene.Signal/Model/PlcStationSnapshot.cs +++ b/StandardScene.Signal/Model/PlcStationSnapshot.cs @@ -2,18 +2,40 @@ using System.ComponentModel; namespace StandardScene.Signal.Model { - /// 单个机构运行态快照,供 MissionStatus / 监视面板展示。 + /// + /// 单个机构运行态快照,供 MissionStatus / 监视面板展示。 + /// 由 拷贝,UI 不要直接绑 Runtime。 + /// public class PlcStationSnapshot { + /// 机构逻辑名。 [DisplayName("机构")] public string JgName { get; set; } = ""; + + /// 最近一拍 PLC 读写是否成功。 [DisplayName("连接")] public bool IsConnected { get; set; } + + /// PLC→调度读字节的枚举名(允许进入 / 允许离开 / 无状态)。 [DisplayName("读信号")] public string ReadSignal { get; set; } = ""; + + /// 调度目标写字节的枚举名。 [DisplayName("写信号")] public string WriteSignal { get; set; } = ""; + + /// 从写地址回读到的枚举名,用于确认 PLC 是否已吃进目标值。 [DisplayName("回读写值")] public string ReadWriteSignal { get; set; } = ""; + + /// 已报到在机构内的 AGV 号,0 表示没有。 [DisplayName("机构内车")] public int InAgvNo { get; set; } + + /// 已获准进入、正在往机构开的 AGV 号,0 表示没有。 [DisplayName("请求进入车")] public int RequestInAgvNo { get; set; } + + /// 目标写字节是否已与回读一致。 [DisplayName("写入成功")] public bool WriteSuccess { get; set; } + + /// Worker 内存心跳翻转,表示线程在跑。 [DisplayName("心跳")] public bool Heart { get; set; } + + /// 最近一次断线或周期异常原因。 [DisplayName("最近错误")] public string LastError { get; set; } = ""; } } diff --git a/StandardScene.Signal/Model/ReleasePointModel.cs b/StandardScene.Signal/Model/ReleasePointModel.cs index 4a400ea..ed36a1d 100644 --- a/StandardScene.Signal/Model/ReleasePointModel.cs +++ b/StandardScene.Signal/Model/ReleasePointModel.cs @@ -2,12 +2,23 @@ using System.ComponentModel; namespace StandardScene.Signal.Model { - /// 放行点配置。对应反编译 StartSignModel + /// + /// 放行点配置。对应反编译 StartSignModel。 + /// + /// + /// 车停在 为 true 时,进程停充并启动,不读 PLC。 + /// 同一站点不要同时配握手点:扫车先握手再放行,可能把还在等允许进入的车开走。 + /// 站点号不能重复,列表编辑器用 Sit 做主键。 + /// + [Category("放行点数据管理")] + [DisplayName("放行点配置列表")] public class ReleasePointModel { + /// 要自动放行的地图站点号。 [DisplayName("放行站点")] public int Sit { get; set; } + /// false 时该条保留在 JSON 里但不生效,方便现场临时关掉某个点。 [DisplayName("启用")] public bool IsUse { get; set; } = true; } diff --git a/StandardScene.Signal/Model/SignalEnums.cs b/StandardScene.Signal/Model/SignalEnums.cs index b1bde67..20bb6a3 100644 --- a/StandardScene.Signal/Model/SignalEnums.cs +++ b/StandardScene.Signal/Model/SignalEnums.cs @@ -1,30 +1,62 @@ namespace StandardScene.Signal.Model { - /// PLC → 调度:读字节含义(与反编译 ENUM_READ_SIGNAL 对齐)。 + /// + /// PLC → 调度:读字节含义。对齐反编译 ENUM_READ_SIGNAL。 + /// 机构用这一个字节告诉 AGV 现在能不能进、能不能出。 + /// public enum SignalReadValue : byte { + /// 0:空闲 / 未允许。RequestIn 必须看到写字节也是无状态才放行。 无状态 = 0, + + /// 1:允许进入机构。扫车 RequestIn 的放行条件之一。 允许进入 = 1, + + /// 2:允许离开机构。下线升销钉、等待通行机构、PointControl 等会等这个值。 允许离开 = 2 } - /// 调度 → PLC:写字节含义(与反编译 ENUM_WRITE_SIGNAL 对齐)。 + /// + /// 调度 → PLC:写字节含义。对齐反编译 ENUM_WRITE_SIGNAL。 + /// AGV 用这一个字节向机构汇报当前处于进站流程的哪一步。 + /// public enum SignalWriteValue : byte { + /// 0:空闲。离站清空后回到此值,下一辆才能 RequestIn。 无状态 = 0, + + /// 1:已获准进入、正在往机构开。RequestIn 成功时写入。 进入中 = 1, + + /// 2:已到达机构内地标。Arrived 或上线 AutoLeave 检测到离开进入包含区时写入。 到位 = 2, + + /// 3:正在离开机构。上线看到车在跑、下线/等待通行启动后写入。 离开中 = 3, + + /// 4:已离开包含地标。PLC 确认后调度清零写字节和车号。 已离开 = 4 } - /// 机构类型(与反编译 ENUM_JGTYPE 对齐)。 + /// + /// 机构类型。对齐反编译 ENUM_JGTYPE。 + /// 决定 走哪套离站,以及 RequestIn 要不要查机构内是否有车。 + /// public enum SignalLineType : byte { + /// 0:未配置,AutoLeave 不处理。 未知 = 0, + + /// 1:上线。进站靠扫车 RequestIn/Arrived,离站跟车位自动推进到位/离开中/已离开。 上线机构 = 1, + + /// 2:单牵引下线。RequestIn 时若 JgPointId 上已有车则不放行;离站要升销钉1。 单牵引下线机构 = 2, + + /// 3:双牵引下线。同单牵引,离站还需销钉2到位。 双牵引下线机构 = 3, + + /// 4:等待通行。无销钉,允许离开后停充启动即可。 等待通行机构 = 4 } } diff --git a/StandardScene.Signal/Plc/PlcStationLogic.cs b/StandardScene.Signal/Plc/PlcStationLogic.cs index c93d448..0b09f86 100644 --- a/StandardScene.Signal/Plc/PlcStationLogic.cs +++ b/StandardScene.Signal/Plc/PlcStationLogic.cs @@ -5,9 +5,17 @@ using StandardScene.Signal.Model; namespace StandardScene.Signal.Plc { - /// 读写字节转换与四套自动离站,对齐反编译 PLCManager。 + /// + /// PLC 字节含义转换,以及按机构类型自动推进离站状态机。对齐反编译 PLCManager。 + /// + /// + /// 由 每 200ms 在已持有 时调用。 + /// 写字节含义见 :无状态 → 进入中 → 到位 → 离开中 → 已离开 → 清零。 + /// 「已离开」可由本侧写成功,或 PLC 回读写值变成已离开(对方清过)触发清空。 + /// public static class PlcStationLogic { + /// PLC 读字节 → 调度侧枚举。仅 1=允许进入、2=允许离开,其余当无状态。 public static SignalReadValue ConvertRead(byte val) => val switch { 1 => SignalReadValue.允许进入, @@ -15,6 +23,7 @@ namespace StandardScene.Signal.Plc _ => SignalReadValue.无状态 }; + /// PLC 写地址回读字节 → 调度侧枚举。1~4 对应进入中/到位/离开中/已离开。 public static SignalWriteValue ConvertWrite(byte val) => val switch { 1 => SignalWriteValue.进入中, @@ -24,6 +33,10 @@ namespace StandardScene.Signal.Plc _ => SignalWriteValue.无状态 }; + /// + /// 判断站点是否落在机构配置的包含列表里。 + /// 列表支持逗号、分号、空格分隔,与 JSON 里 JgInContains / JgContains 一致。 + /// public static bool ContainsSite(string csv, int siteId) { if (string.IsNullOrWhiteSpace(csv) || siteId <= 0) @@ -32,6 +45,7 @@ namespace StandardScene.Signal.Plc .Any(s => int.TryParse(s.Trim(), out var n) && n == siteId); } + /// 分发四套离站逻辑;未知类型不处理。 public static void AutoLeave(PlcStationRuntime rt) { if (rt?.Config == null) return; @@ -52,6 +66,10 @@ namespace StandardScene.Signal.Plc } } + /// + /// 离站完成:写字节清无状态、清空机构内车号。 + /// 若仍挂着 RequestInAgvNo(上线途中 PLC 已报已离开),一并清掉以免卡死下一辆。 + /// private static void ClearLeft(PlcStationRuntime rt, bool fromReadback) { rt.WriteSignal = SignalWriteValue.无状态; @@ -67,6 +85,12 @@ namespace StandardScene.Signal.Plc Diagnosis.Log($"{rt.Config.JgName} 已离开,清空信号", "JGControl", true); } + /// + /// 上线机构:进站过程由扫车 RequestIn/Arrived 启动,离站由本方法跟车位推进。 + /// 进入中且车已离开 JgInContains → 视为到位; + /// 到位且车在跑 → 离开中; + /// 离开中且车已不在 JgContains → 已离开。 + /// private static void AutoLeaveUpLine(PlcStationRuntime rt) { if (rt.WriteSuccess && rt.WriteSignal == SignalWriteValue.已离开) @@ -100,6 +124,11 @@ namespace StandardScene.Signal.Plc } } + /// + /// 下线机构:车已在机构内(InAgvNo),等允许离开后升销钉、停充、启动,写离开中; + /// 车离开 JgContains 后写已离开。 + /// 为 true 时双牵引,需销钉1+2都到位。 + /// private static void AutoLeaveDownLine(PlcStationRuntime rt, bool pin2) { if (rt.WriteSuccess && rt.WriteSignal == SignalWriteValue.已离开) @@ -144,6 +173,10 @@ namespace StandardScene.Signal.Plc } } + /// + /// 等待通行机构:无销钉,允许离开且停在 JgPointId 时停充并启动,写离开中; + /// 离开包含地标后写已离开。 + /// private static void AutoLeaveWaitSit(PlcStationRuntime rt) { if (rt.WriteSuccess && rt.WriteSignal == SignalWriteValue.已离开) diff --git a/StandardScene.Signal/Plc/PlcStationRuntime.cs b/StandardScene.Signal/Plc/PlcStationRuntime.cs index cb7de53..d949260 100644 --- a/StandardScene.Signal/Plc/PlcStationRuntime.cs +++ b/StandardScene.Signal/Plc/PlcStationRuntime.cs @@ -4,7 +4,14 @@ using StandardScene.Signal.Model; namespace StandardScene.Signal.Plc { - /// 机构运行态。写信号赋值会清 WriteSuccess,驱动 Worker 重写 PLC。 + /// + /// 单个机构的运行态(不进 JSON)。扫车线程改写目标写字节,PLC Worker 负责真正下发。 + /// + /// + /// 赋值 会把 置 false,下一拍 Worker 发现 + /// 目标与回读不一致就会重写 PLC。回读与目标一致后再标成功,AutoLeave 才继续往下一状态走。 + /// 所有读写应在 内进行。 + /// public sealed class PlcStationRuntime { private SignalWriteValue _writeSignal; @@ -16,11 +23,13 @@ namespace StandardScene.Signal.Plc Config = config ?? throw new ArgumentNullException(nameof(config)); } + /// 本机构静态配置(IP、DB 地址、地标、机构类型)。 public PlcStationModel Config { get; } /// 扫车线程与 PLC Worker 共用运行态,写入时加此锁。 public object Sync { get; } = new object(); + /// PLC→调度:允许进入 / 允许离开 / 无状态。变化时打诊断。 public SignalReadValue ReadSignal { get => _readSignal; @@ -32,6 +41,9 @@ namespace StandardScene.Signal.Plc } } + /// + /// 调度→PLC 的目标写字节。赋值即视为尚未写成功,驱动 Worker 重写。 + /// public SignalWriteValue WriteSignal { get => _writeSignal; @@ -44,6 +56,7 @@ namespace StandardScene.Signal.Plc } } + /// 从写地址回读到的当前值,用来确认 PLC 是否已吃进目标字节。 public SignalWriteValue ReadWriteSignal { get => _readWriteSignal; @@ -55,13 +68,25 @@ namespace StandardScene.Signal.Plc } } + /// 已报到在机构内的 AGV 号;0 表示没有。Arrived / 上线自动到位 时写入。 public int InAgvNo { get; set; } + + /// 已获准进入、正在往机构开的 AGV 号;到位或离站清空后为 0。 public int RequestInAgvNo { get; set; } + + /// 最近一拍 PLC 读写是否成功。心跳或读写失败会立刻变 false。 public bool IsConnected { get; set; } + + /// 目标写字节是否已与 PLC 回读一致。改 WriteSignal 时自动清掉。 public bool WriteSuccess { get; set; } + + /// 内存侧心跳翻转标志,供监视面板看到 Worker 在跑;真正写 PLC 的是 HeartDb 位翻转。 public bool Heart { get; set; } + + /// 最近一次断线或周期异常原因,连接正常时清空。 public string LastError { get; set; } = ""; + /// 拷贝一份给 MissionStatus / 监视列表,避免 UI 直接绑运行态。 public PlcStationSnapshot ToSnapshot() => new PlcStationSnapshot { JgName = Config.JgName, diff --git a/StandardScene.Signal/Plc/PlcStationWorker.cs b/StandardScene.Signal/Plc/PlcStationWorker.cs index 53a8d0b..93566b8 100644 --- a/StandardScene.Signal/Plc/PlcStationWorker.cs +++ b/StandardScene.Signal/Plc/PlcStationWorker.cs @@ -9,6 +9,15 @@ namespace StandardScene.Signal.Plc /// 单机构 200ms 循环:心跳、读允许进出、回读写值、按需写入、自动离站。 /// 对齐反编译 PLCManager.ExcuteThreadMethod。 /// + /// + /// 一拍顺序固定,失败则本拍标断线并 return,避免用脏数据推进状态机: + /// 1. 翻转内存心跳标志;若配置了 HeartDb,读当前位再写反码(PLC 侧看翻转判断调度活着)。 + /// 2. 读 ReadDb → 。 + /// 3. 回读 WriteDb → ReadWriteSignal;与目标 WriteSignal 一致则 WriteSuccess=true。 + /// 4. 目标与回读不一致且尚未成功时写入目标字节。 + /// 5. 。 + /// 读写均在 内,与扫车 Handler 互斥。 + /// public sealed class PlcStationWorker : IDisposable { private readonly SiemensPlcSession _session; @@ -16,6 +25,8 @@ namespace StandardScene.Signal.Plc private readonly Action _onTick; private CancellationTokenSource _cts; private Thread _thread; + + /// 断线日志节流起点,避免 200ms 打满诊断。 private DateTime _lastFailLog = DateTime.MinValue; public PlcStationWorker(PlcStationRuntime runtime, SiemensPlcSession session, int pollMs, Action onTick = null) @@ -26,8 +37,10 @@ namespace StandardScene.Signal.Plc _onTick = onTick; } + /// 本机构运行态,扫车线程按 JgName 查找后加 Sync 读写。 public PlcStationRuntime Runtime { get; } + /// 启动后台线程。重复 Start 若仍存活则忽略。 public void Start() { if (_thread != null && _thread.IsAlive) return; @@ -41,6 +54,7 @@ namespace StandardScene.Signal.Plc Diagnosis.Log($"{Runtime.Config.JgName} Worker 已启动 → {Runtime.Config.Ip}:{Runtime.Config.Port}", "PLC", true); } + /// 取消循环、最多等 2s、关闭 S7 连接并标断线。 public void Stop() { try { _cts?.Cancel(); } @@ -80,6 +94,7 @@ namespace StandardScene.Signal.Plc } } + /// 单拍 PLC 交互。心跳地址为空则跳过心跳;读写地址为空则跳过对应步骤。 internal void Tick() { var cfg = Runtime.Config; @@ -145,6 +160,7 @@ namespace StandardScene.Signal.Plc LogFail($"{Runtime.Config.JgName} {reason}"); } + /// 同一机构失败日志最少间隔 5 秒。 private void LogFail(string msg) { if ((DateTime.Now - _lastFailLog).TotalSeconds < 5) return; diff --git a/StandardScene.Signal/Plc/SiemensPlcSession.cs b/StandardScene.Signal/Plc/SiemensPlcSession.cs index 136faeb..0063919 100644 --- a/StandardScene.Signal/Plc/SiemensPlcSession.cs +++ b/StandardScene.Signal/Plc/SiemensPlcSession.cs @@ -5,9 +5,17 @@ using IoTClient.Common.Enums; namespace StandardScene.Signal.Plc { - /// 西门子 S7 长连接。地址兼容 Hsl 的 DB1.0 与 IoTClient 的 DB1.DBB0。 + /// + /// 西门子 S7 长连接封装(IoTClient)。一个机构一个实例,由对应 Worker 独占。 + /// + /// + /// 字节地址兼容老 Hsl 写法 DB1.0 与 IoTClient 写法 DB1.DBB0; + /// 心跳位地址保持原样(如 DB1.DBX2.0),不做 DBB 转换。 + /// 连接失败时读/写返回 false,由 Worker 标断线,不在这里抛给扫车线程。 + /// public sealed class SiemensPlcSession : IDisposable { + /// 匹配 Hsl 风格「DB块号.字节偏移」,捕获组 1=DB,2=偏移。 private static readonly Regex BareDbByte = new Regex(@"^DB(\d+)\.(\d+)$", RegexOptions.IgnoreCase | RegexOptions.Compiled); private readonly SiemensVersion _version; @@ -17,6 +25,10 @@ namespace StandardScene.Signal.Plc private SiemensClient _client; private readonly object _sync = new object(); + /// 进程字段 PlcVersion,如 S7_1500。 + /// 机构 IP。 + /// S7 端口,通常 102。 + /// 机架槽号,S7-1500 常见 1。 public SiemensPlcSession(string version, string ip, int port, int slot) { _version = ParseVersion(version); @@ -25,6 +37,7 @@ namespace StandardScene.Signal.Plc _slot = (byte)Math.Max(0, slot); } + /// 当前客户端是否已 Open 成功。未创建客户端时为 false。 public bool Connected { get @@ -33,6 +46,7 @@ namespace StandardScene.Signal.Plc } } + /// 惰性创建客户端并 Open。已连接则直接 true;失败返回 false 不抛。 public bool EnsureOpen() { lock (_sync) @@ -53,6 +67,7 @@ namespace StandardScene.Signal.Plc } } + /// 读一字节。地址会先规范化;未连接或失败时 value=0 且返回 false。 public bool TryReadByte(string address, out byte value) { value = 0; @@ -72,6 +87,7 @@ namespace StandardScene.Signal.Plc } } + /// 写一字节(进入中/到位等)。失败返回 false。 public bool TryWriteByte(string address, byte value) { if (string.IsNullOrWhiteSpace(address) || !EnsureOpen()) @@ -87,6 +103,7 @@ namespace StandardScene.Signal.Plc } } + /// 读心跳位。地址不做 DBB 转换。 public bool TryReadBool(string address, out bool value) { value = false; @@ -106,6 +123,7 @@ namespace StandardScene.Signal.Plc } } + /// 写心跳位(通常写当前值的反码,让 PLC 看到翻转)。 public bool TryWriteBool(string address, bool value) { if (string.IsNullOrWhiteSpace(address) || !EnsureOpen()) @@ -121,6 +139,7 @@ namespace StandardScene.Signal.Plc } } + /// 关闭并丢弃客户端,下次 EnsureOpen 会新建。 public void Close() { lock (_sync) @@ -133,6 +152,9 @@ namespace StandardScene.Signal.Plc public void Dispose() => Close(); + /// + /// DB1.0DB1.DBB0;已是 DBB/DBW/DBX 的地址原样返回。 + /// internal static string NormalizeByteAddress(string address) { var addr = address.Trim(); @@ -140,6 +162,7 @@ namespace StandardScene.Signal.Plc return m.Success ? $"DB{m.Groups[1].Value}.DBB{m.Groups[2].Value}" : addr; } + /// 空或无法解析时默认 S7_1500。允许把 S7-1500 写成带连字符。 internal static SiemensVersion ParseVersion(string version) { if (string.IsNullOrWhiteSpace(version)) diff --git a/StandardScene.Signal/Plc/SignalCarAdapter.cs b/StandardScene.Signal/Plc/SignalCarAdapter.cs index 31b456e..8c15b50 100644 --- a/StandardScene.Signal/Plc/SignalCarAdapter.cs +++ b/StandardScene.Signal/Plc/SignalCarAdapter.cs @@ -12,11 +12,16 @@ using StandardScene.Signal.Site; namespace StandardScene.Signal.Plc { /// - /// 不引用 Magnetic.dll:按方法名反射 Start/Stop/牵引棒。 - /// MagCar / Mag2Car 有对应 MethodMember 时生效;没有则视为动作已完成。 + /// 信号插件访问 AGV / 站点的适配层。不引用 Magnetic.dll,按方法名反射启停和牵引棒。 /// + /// + /// MagCar / Mag2Car 上有对应 MethodMember(StartMag2Car、SetPin1Up 等)时生效; + /// 没有 IsActionFinish 则视为动作已完成,避免非磁导航车卡在销钉等待。 + /// 启停回退顺序:StartMag2Car / StartMagCar → SendStart(0)。 + /// public static class SignalCarAdapter { + /// 当前内核里的全部车;SimpleLib 异常时返回空,避免扫车线程崩掉。 public static IEnumerable All() { try @@ -29,12 +34,17 @@ namespace StandardScene.Signal.Plc } } + /// 按车号取车;id≤0 返回 null。 public static Car Find(int id) { if (id <= 0) return null; return SimpleLib.GetCar(id) as Car; } + /// + /// 当前站点:优先 siteID,否则 GetLastSite()。 + /// 握手 / 放行 / 限流都用这个号去对 JSON 里的 Sit。 + /// public static int PositionId(Car car) { if (car == null) return 0; @@ -50,6 +60,7 @@ namespace StandardScene.Signal.Plc } } + /// 读车 status.enums["State"],没有则空串。 public static string GetState(Car car) { if (car?.status?.enums != null && @@ -59,6 +70,7 @@ namespace StandardScene.Signal.Plc return ""; } + /// State 为「运行中」或 Running。 public static bool IsMoving(Car car) { var state = GetState(car); @@ -66,7 +78,10 @@ namespace StandardScene.Signal.Plc string.Equals(state, "Running", StringComparison.OrdinalIgnoreCase); } - /// 对齐反编译 RunStatus==停止:非运行中即视为停稳,含充电中。 + /// + /// 对齐反编译 RunStatus==停止:非运行中即视为可握手,含充电中 / 停止中 / WaitPass。 + /// 充电点必须把「充电中」算停稳,否则 TimedCharging 不会进 Handler。 + /// public static bool IsStopped(Car car) { if (car == null) return false; @@ -80,6 +95,7 @@ namespace StandardScene.Signal.Plc return !IsRunning(car); } + /// 正在充电(中英文 State)。 public static bool IsCharging(Car car) { var state = GetState(car); @@ -87,6 +103,10 @@ namespace StandardScene.Signal.Plc string.Equals(state, "Charging", StringComparison.OrdinalIgnoreCase); } + /// + /// 车仍占资源:运行中,或有 pendingLocks,或 tags 含 occupied。 + /// 上线机构「到位后开始离开」用这个判断,比单纯看 State 更早切到离开中。 + /// public static bool IsRunning(Car car) { if (car == null) return false; @@ -98,6 +118,7 @@ namespace StandardScene.Signal.Plc return false; } + /// 电量百分比,读 enums["Soc"];解析失败当 0。 public static double Soc(Car car) { if (car?.status?.enums != null && @@ -107,6 +128,7 @@ namespace StandardScene.Signal.Plc return 0; } + /// 限流用的车组:fields["group"] 或 ["GroupID"]。 public static string GroupId(Car car) { if (car?.fields == null) return ""; @@ -117,6 +139,7 @@ namespace StandardScene.Signal.Plc return ""; } + /// 当前停在给定站点集合上的全部车(按 PositionId)。 public static IReadOnlyList CarsAt(IEnumerable siteIds) { var set = new HashSet(siteIds ?? Array.Empty()); @@ -125,12 +148,14 @@ namespace StandardScene.Signal.Plc return All().Where(c => set.Contains(PositionId(c))).ToList(); } + /// 当前停在单个站点上的车。下线 RequestIn 用 JgPointId 查机构内是否已有车。 public static IReadOnlyList CarsAt(int siteId) { if (siteId <= 0) return Array.Empty(); return CarsAt(new[] { siteId }); } + /// 读站点 fields 指定键。站点不存在或值为空返回 false。 public static bool TryReadSiteField(int siteId, string key, out string value) { value = ""; @@ -152,6 +177,10 @@ namespace StandardScene.Signal.Plc } } + /// + /// 地图点是否自带 SignalStop(SignalType 字段存在且非空)。 + /// SignalParams 可缺省。有类型则扫车不再查 handshake-points.json。 + /// public static bool TryReadSignalStop(int siteId, out string signalType, out string signalParams) { signalType = ""; @@ -162,6 +191,10 @@ namespace StandardScene.Signal.Plc return true; } + /// + /// 已充电秒数。优先反射 GetChargeTime(int/double/TimeSpan); + /// 没有则从首次看到充电中的时刻算,离开充电态时清字典。 + /// public static int ChargeSeconds(Car car, IDictionary startedAt) { if (car == null) return 0; @@ -191,6 +224,7 @@ namespace StandardScene.Signal.Plc return 0; } + /// 放行:Mag2 → Mag → SendStart(0)。 public static void Start(Car car) { if (car == null) return; @@ -199,6 +233,7 @@ namespace StandardScene.Signal.Plc TryInvoke(car, "SendStart", new object[] { (ushort)0 }); } + /// 急停:Mag2 → Mag → SendStop(0)。机构未允许进入时调用。 public static void Stop(Car car) { if (car == null) return; @@ -207,12 +242,17 @@ namespace StandardScene.Signal.Plc TryInvoke(car, "SendStop", new object[] { (ushort)0 }); } + /// 停充。握手放行前通常先停充再启动。 public static void StopCharge(Car car) { if (car == null) return; TryInvoke(car, "StopCharge"); } + /// + /// 销钉/停充等动作是否完成。车类型没有 IsActionFinish 时返回 true(非磁导航不阻塞)。 + /// 反射异常也当完成,避免把产线卡死。 + /// public static bool IsActionFinish(Car car, string action) { if (car == null) return false; @@ -231,6 +271,7 @@ namespace StandardScene.Signal.Plc } } + /// 下发动作:先按动作名直接调 MethodMember,没有再 ImmediateCommand(action)。 public static void Control(Car car, string action) { if (car == null || string.IsNullOrWhiteSpace(action)) return; @@ -238,6 +279,10 @@ namespace StandardScene.Signal.Plc TryInvoke(car, "ImmediateCommand", new object[] { action }); } + /// + /// 按名称找实例方法。无参与带参分开匹配,重载参数个数不对就跳过。 + /// 调用抛错返回 false,让上层试下一个名字。 + /// private static bool TryInvoke(object target, string name, object[] args = null) { var type = target.GetType(); diff --git a/StandardScene.Signal/SignalConfigStore.cs b/StandardScene.Signal/SignalConfigStore.cs index 91ef077..a5191cc 100644 --- a/StandardScene.Signal/SignalConfigStore.cs +++ b/StandardScene.Signal/SignalConfigStore.cs @@ -6,9 +6,18 @@ using Newtonsoft.Json.Converters; namespace StandardScene.Signal { - /// 信号插件 JSON 配置的路径解析、种子拷贝与读写。 + /// + /// 信号插件三张 JSON 的路径解析与读写。 + /// + /// + /// 只使用 SimpleLite 工作目录 Config/Signal/*.json,不读、不拷 plugins/Config/Signal。 + /// 文件不存在时 Load 返回空表;Save 会创建目录。 + /// 迷毂数据中心走同一套文件;保存后需在本进程点「重新加载配置」。 + /// 枚举按名字序列化(),与样例里 "上线机构" 一致。 + /// public static class SignalConfigStore { + /// 保存时串行写盘,避免 CycleGUI 与迷毂 API 同时 Save 互相覆盖一半。 private static readonly object FileLock = new object(); private static readonly JsonSerializerSettings JsonSettings = new JsonSerializerSettings @@ -19,8 +28,8 @@ namespace StandardScene.Signal }; /// - /// 相对路径依次尝试:工作目录、插件目录旁的 Config。 - /// 工作目录文件不存在时,若插件目录有样例则拷过去。 + /// 相对路径固定落到工作目录(SimpleLite.exe 所在目录)下的 Config/Signal。 + /// 已是绝对路径则原样返回。文件不存在也返回该路径,不回退插件目录。 /// public static string Resolve(string configuredPath) { @@ -30,31 +39,12 @@ namespace StandardScene.Signal if (Path.IsPathRooted(configuredPath)) return configuredPath; - var working = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, configuredPath); - if (File.Exists(working)) - return working; - - var sample = Path.Combine( - Path.GetDirectoryName(typeof(SignalConfigStore).Assembly.Location) ?? "", - configuredPath); - if (File.Exists(sample)) - { - try - { - var dir = Path.GetDirectoryName(working); - if (!string.IsNullOrEmpty(dir)) - Directory.CreateDirectory(dir); - File.Copy(sample, working, overwrite: false); - } - catch - { - return File.Exists(working) ? working : sample; - } - } - - return working; + return Path.Combine(AppDomain.CurrentDomain.BaseDirectory, configuredPath); } + /// + /// 反序列化为列表。文件不存在、空、或 JSON 损坏时返回空列表,不抛给启动流程。 + /// public static List Load(string path) where T : class { try @@ -72,6 +62,7 @@ namespace StandardScene.Signal } } + /// 缩进写入;自动建目录。与 Load 共用枚举按名序列化。 public static void Save(string path, IReadOnlyList items) { var json = JsonConvert.SerializeObject(items ?? Array.Empty(), JsonSettings); diff --git a/StandardScene.Signal/Site/SignalSiteFields.cs b/StandardScene.Signal/Site/SignalSiteFields.cs index 81bc40f..3dca11a 100644 --- a/StandardScene.Signal/Site/SignalSiteFields.cs +++ b/StandardScene.Signal/Site/SignalSiteFields.cs @@ -1,12 +1,19 @@ namespace StandardScene.Signal.Site { /// - /// 站点 fields 键名,替代老地图 SignalStop。 - /// 地图编辑器若未挂字段袋,仍可手工写入这两个键;未写则回退 handshake-points.json。 + /// 站点 fields 键名,替代老地图 SignalStop 对象。 /// + /// + /// 地图编辑器若未挂字段袋,仍可手工在站点 fields 写入这两个键。 + /// 扫车解析顺序:站点有非空 SignalType → 用地图点;否则回退 handshake-points.json。 + /// 键名必须与迷毂/地图侧约定完全一致,改这里要同步改地图字段袋。 + /// public static class SignalSiteFields { + /// 握手类型,取值同 public const string SignalType = "SignalType"; + + /// 握手参数,取值同 public const string SignalParams = "SignalParams"; } } diff --git a/StandardScene.Signal/StandardScene.Signal.csproj b/StandardScene.Signal/StandardScene.Signal.csproj index 71946da..db5711c 100644 --- a/StandardScene.Signal/StandardScene.Signal.csproj +++ b/StandardScene.Signal/StandardScene.Signal.csproj @@ -1,3 +1,9 @@ + @@ -20,7 +26,8 @@ - + + diff --git a/StandardScene.Signal/Ui/ModelListPanel.cs b/StandardScene.Signal/Ui/ModelListPanel.cs index 7fc5436..6038cfd 100644 --- a/StandardScene.Signal/Ui/ModelListPanel.cs +++ b/StandardScene.Signal/Ui/ModelListPanel.cs @@ -1,51 +1,36 @@ using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Linq; -using System.Reflection; using CycleGUI; -using Newtonsoft.Json; -using StandardScene.Utils; namespace StandardScene.Signal.Ui { /// - /// 按 Model 的 反射生成可增删改列表(CycleGUI)。 - /// 跳过 = false 与 。 + /// CycleGUI 独立窗口打开某一类 Model 列表(进程工具栏「打开机构/握手点/放行点列表」触发)。 /// + /// + /// 每种 T 共用一个静态窗口:已打开则前置,关掉后下次再 DeclarePanel。 + /// 真正的增删改在 ,保存直接写 JSON, + /// 不自动 Reload 进程内 Worker,需再到进程上点「重新加载配置」。 + /// public static class ModelListPanel where T : class, new() { + /// 当前已声明的面板;关闭或异常后置 null,允许再次 Open。 private static Panel _panel; - private static List _items = new List(); - private static int _selected = -1; - private static string _status = ""; - private static string _title = ""; - private static string _path = ""; - private static Func _keySelector = _ => ""; - private static readonly Dictionary Text = new Dictionary(); - private static readonly Dictionary Bools = new Dictionary(); - private static readonly Dictionary EnumIdx = new Dictionary(); - private static PropertyInfo[] Props => _props ??= Discover(); - private static PropertyInfo[] _props; + private static readonly ModelTableEditor Editor = new ModelTableEditor(); + /// 窗口标题。 + /// JSON 绝对路径(应先经 SignalConfigStore.Resolve)。 + /// 主键,添加/保存时查重(机构名、站点号等)。 public static void Open(string title, string path, Func keySelector) { + Editor.Bind(path, keySelector ?? (_ => ""), typeof(T).Name); + if (_panel != null) { try { _panel.BringToFront(); return; } catch { _panel = null; } } - _title = title; - _path = path; - _keySelector = keySelector ?? (_ => ""); - _items = SignalConfigStore.Load(path); - _selected = -1; - _status = $"已加载 {_items.Count} 条 · {_path}"; - ClearEditor(); - - var typeKey = typeof(T).Name; var panel = GUI.DeclarePanel() .ShowTitle(title) .SetDefaultDocking(Panel.Docking.None) @@ -58,291 +43,12 @@ namespace StandardScene.Signal.Ui { if (pb.Closing()) { - SignalConfigStore.Save(_path, _items); panel.Exit(); _panel = null; return; } - var headers = Props.Select(DisplayOf).Concat(new[] { "行操作" }).ToArray(); - pb.SeparatorText($"列表({_items.Count})"); - pb.Table($"model-list-{typeKey}", headers, _items.Count, (row, i) => - { - var item = _items[i]; - foreach (var p in Props) - row.Label(FormatCell(p.GetValue(item))); - if (row.ButtonGroup(new[] { "选择" }, new[] { "选择该行" }) == 0) - { - _selected = i; - LoadEditor(item); - } - }, height: 10, enableSearch: true); - - pb.SeparatorText(_selected >= 0 ? $"编辑第 {_selected + 1} 行" : "编辑(未选中则「添加」写入新行)"); - DrawEditor(pb); - - if (pb.Button("添加", distinct: $"ml-{typeKey}-add")) Add(); - pb.SameLine(8); - if (pb.Button("保存当前行", distinct: $"ml-{typeKey}-save")) SaveRow(); - pb.SameLine(8); - if (pb.Button("删除当前行", distinct: $"ml-{typeKey}-del")) ConfirmDelete(); - pb.SameLine(8); - if (pb.Button("清空编辑区", distinct: $"ml-{typeKey}-clear")) - { - _selected = -1; - ClearEditor(); - _status = "已清空编辑区,可添加新行"; - } - - if (!string.IsNullOrEmpty(_status)) - { - pb.Separator(); - pb.Label(_status); - } - }); - } - - private static PropertyInfo[] Discover() - { - return typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance) - .Where(p => p.CanRead && p.CanWrite && p.GetIndexParameters().Length == 0) - .Where(p => p.GetCustomAttribute()?.Browsable != false) - .Where(p => p.GetCustomAttribute() == null) - .OrderBy(p => p.MetadataToken) - .ToArray(); - } - - private static string DisplayOf(PropertyInfo p) => - p.GetCustomAttribute()?.DisplayName ?? p.Name; - - private static string FormatCell(object value) - { - if (value == null) return ""; - if (value is bool b) return b ? "是" : "否"; - return Convert.ToString(value) ?? ""; - } - - private static void DrawEditor(PanelBuilder pb) - { - for (var i = 0; i < Props.Length; i++) - { - var p = Props[i]; - var label = $"{i + 1}. {DisplayOf(p)}"; - var type = p.PropertyType; - if (type == typeof(bool)) - { - if (!Bools.ContainsKey(p.Name)) Bools[p.Name] = false; - var v = Bools[p.Name]; - pb.CheckBox(label, ref v); - Bools[p.Name] = v; - } - else if (type.IsEnum) - { - var names = Enum.GetNames(type); - if (names.Length == 0) continue; - if (!EnumIdx.ContainsKey(p.Name)) EnumIdx[p.Name] = 0; - var idx = EnumIdx[p.Name]; - if (idx < 0 || idx >= names.Length) idx = 0; - pb.DropdownBox(label, names, ref idx); - EnumIdx[p.Name] = idx; - } - else - { - if (!Text.ContainsKey(p.Name)) Text[p.Name] = ""; - var (text, _) = pb.TextInput(label, Text[p.Name], alwaysReturnString: true); - Text[p.Name] = text ?? ""; - } - } - } - - private static void LoadEditor(T item) - { - foreach (var p in Props) - { - var value = p.GetValue(item); - if (p.PropertyType == typeof(bool)) - Bools[p.Name] = value is true; - else if (p.PropertyType.IsEnum) - { - var names = Enum.GetNames(p.PropertyType); - var name = value?.ToString() ?? names.FirstOrDefault() ?? ""; - var idx = Array.IndexOf(names, name); - EnumIdx[p.Name] = idx >= 0 ? idx : 0; - } - else - Text[p.Name] = value == null ? "" : Convert.ToString(value) ?? ""; - } - } - - private static void ClearEditor() - { - Text.Clear(); - Bools.Clear(); - EnumIdx.Clear(); - foreach (var p in Props) - { - if (p.PropertyType == typeof(bool)) - Bools[p.Name] = false; - else if (p.PropertyType.IsEnum) - EnumIdx[p.Name] = 0; - else if (p.PropertyType == typeof(int) || p.PropertyType == typeof(byte) || p.PropertyType == typeof(ushort)) - Text[p.Name] = "0"; - else - Text[p.Name] = DefaultText(p); - } - } - - private static string DefaultText(PropertyInfo p) - { - try - { - var fresh = new T(); - var v = p.GetValue(fresh); - return v == null ? "" : Convert.ToString(v) ?? ""; - } - catch - { - return ""; - } - } - - private static bool TryBuild(out T item, out string err) - { - item = new T(); - err = ""; - foreach (var p in Props) - { - try - { - object boxed; - if (p.PropertyType == typeof(bool)) - boxed = Bools.TryGetValue(p.Name, out var b) && b; - else if (p.PropertyType.IsEnum) - { - var names = Enum.GetNames(p.PropertyType); - var idx = EnumIdx.TryGetValue(p.Name, out var e) ? e : 0; - if (idx < 0 || idx >= names.Length) idx = 0; - boxed = Enum.Parse(p.PropertyType, names[idx]); - } - else if (p.PropertyType == typeof(int)) - { - var raw = Text.TryGetValue(p.Name, out var t) ? t : "0"; - if (!int.TryParse(string.IsNullOrWhiteSpace(raw) ? "0" : raw.Trim(), out var n)) - { - err = $"{DisplayOf(p)} 必须是整数"; - return false; - } - boxed = n; - } - else if (p.PropertyType == typeof(ushort)) - { - var raw = Text.TryGetValue(p.Name, out var t) ? t : "0"; - if (!ushort.TryParse(string.IsNullOrWhiteSpace(raw) ? "0" : raw.Trim(), out var n)) - { - err = $"{DisplayOf(p)} 必须是 0-65535"; - return false; - } - boxed = n; - } - else if (p.PropertyType == typeof(byte)) - { - var raw = Text.TryGetValue(p.Name, out var t) ? t : "0"; - if (!byte.TryParse(string.IsNullOrWhiteSpace(raw) ? "0" : raw.Trim(), out var n)) - { - err = $"{DisplayOf(p)} 必须是 0-255"; - return false; - } - boxed = n; - } - else - boxed = Text.TryGetValue(p.Name, out var s) ? s ?? "" : ""; - p.SetValue(item, boxed); - } - catch (Exception ex) - { - err = $"{DisplayOf(p)} 写入失败:{ex.Message}"; - return false; - } - } - - var key = (_keySelector(item) ?? "").Trim(); - if (string.IsNullOrEmpty(key)) - { - err = "主键不能为空"; - return false; - } - - return true; - } - - private static void Add() - { - if (!TryBuild(out var item, out var err)) - { - CycleUiHelper.Alert("错误", err); - return; - } - - var key = _keySelector(item); - if (_items.Any(x => string.Equals(_keySelector(x), key, StringComparison.OrdinalIgnoreCase))) - { - CycleUiHelper.Alert("错误", $"主键「{key}」已存在"); - return; - } - - _items.Add(item); - _selected = _items.Count - 1; - SignalConfigStore.Save(_path, _items); - _status = $"已添加 {key}"; - _panel?.Repaint(); - } - - private static void SaveRow() - { - if (_selected < 0 || _selected >= _items.Count) - { - CycleUiHelper.Alert("提示", "请先选择要保存的行,或用「添加」写入新行"); - return; - } - - if (!TryBuild(out var item, out var err)) - { - CycleUiHelper.Alert("错误", err); - return; - } - - var key = _keySelector(item); - if (_items.Where((_, i) => i != _selected) - .Any(x => string.Equals(_keySelector(x), key, StringComparison.OrdinalIgnoreCase))) - { - CycleUiHelper.Alert("错误", $"主键「{key}」已存在"); - return; - } - - _items[_selected] = item; - SignalConfigStore.Save(_path, _items); - _status = $"已保存 {key}"; - CycleUiHelper.Alert("提示", "保存成功"); - _panel?.Repaint(); - } - - private static void ConfirmDelete() - { - if (_selected < 0 || _selected >= _items.Count) - { - CycleUiHelper.Alert("提示", "请先选择要删除的行"); - return; - } - - var key = _keySelector(_items[_selected]); - CycleUiHelper.ConfirmThen($"删除「{key}」?", () => - { - _items.RemoveAt(_selected); - _selected = -1; - ClearEditor(); - SignalConfigStore.Save(_path, _items); - _status = $"已删除 {key}"; - _panel?.Repaint(); + Editor.Render(pb); }); } } diff --git a/StandardScene.Signal/Ui/ModelTableEditor.cs b/StandardScene.Signal/Ui/ModelTableEditor.cs new file mode 100644 index 0000000..ec516f8 --- /dev/null +++ b/StandardScene.Signal/Ui/ModelTableEditor.cs @@ -0,0 +1,355 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Reflection; +using CycleGUI; +using Newtonsoft.Json; +using StandardScene.Utils; + +namespace StandardScene.Signal.Ui +{ + /// + /// 按 Model 的 反射生成可增删改表格。 + /// + /// + /// 列顺序取属性 MetadataToken(源码声明顺序)。 + /// 跳过不可写、索引器、 为 false、 的属性。 + /// 编辑区用独立字典暂存,点「添加」或「保存当前行」才反写对象并 。 + /// CycleGUI 控件 id 带 _idPrefix,三种表同时开窗时不会抢同一组控件状态。 + /// + public sealed class ModelTableEditor where T : class, new() + { + private static PropertyInfo[] Props => _props ??= Discover(); + private static PropertyInfo[] _props; + + private List _items = new List(); + + /// -1 表示未选中,此时「保存/删除」会提示先选行,「添加」写入新行。 + private int _selected = -1; + + private string _status = ""; + private string _path = ""; + private string _idPrefix = typeof(T).Name; + private Func _keySelector = _ => ""; + + /// 字符串/数值编辑缓存,键为属性名。 + private readonly Dictionary _text = new Dictionary(); + + private readonly Dictionary _bools = new Dictionary(); + + /// 枚举下拉当前下标,写入时按 Enum.GetNames 转回枚举值。 + private readonly Dictionary _enumIdx = new Dictionary(); + + public int Count => _items.Count; + + public string Status => _status; + + /// 绑定 JSON 路径并立即 Reload。同一编辑器换文件时调用。 + public void Bind(string path, Func keySelector, string idPrefix) + { + _path = path ?? ""; + _keySelector = keySelector ?? (_ => ""); + _idPrefix = string.IsNullOrWhiteSpace(idPrefix) ? typeof(T).Name : idPrefix; + Reload(); + } + + /// 从磁盘重读,丢弃未保存的编辑区内容。 + public void Reload() + { + _items = SignalConfigStore.Load(_path); + _selected = -1; + _status = $"已加载 {_items.Count} 条 · {_path}"; + ClearEditor(); + } + + /// 列表 + 编辑区 + 添加/保存/删除/清空/刷新。每帧由面板 Define 调用。 + public void Render(PanelBuilder pb) + { + var headers = Props.Select(DisplayOf).Concat(new[] { "行操作" }).ToArray(); + pb.SeparatorText($"列表({_items.Count})"); + pb.Table($"dc-list-{_idPrefix}", headers, _items.Count, (row, i) => + { + var item = _items[i]; + foreach (var p in Props) + row.Label(FormatCell(p.GetValue(item))); + if (row.ButtonGroup(new[] { "选择" }, new[] { "选择该行" }) == 0) + { + _selected = i; + LoadEditor(item); + } + }, height: 10, enableSearch: true); + + pb.SeparatorText(_selected >= 0 ? $"编辑第 {_selected + 1} 行" : "编辑(未选中则「添加」写入新行)"); + DrawEditor(pb); + + if (pb.Button("添加", distinct: $"dc-{_idPrefix}-add")) Add(); + pb.SameLine(8); + if (pb.Button("保存当前行", distinct: $"dc-{_idPrefix}-save")) SaveRow(); + pb.SameLine(8); + if (pb.Button("删除当前行", distinct: $"dc-{_idPrefix}-del")) ConfirmDelete(); + pb.SameLine(8); + if (pb.Button("清空编辑区", distinct: $"dc-{_idPrefix}-clear")) + { + _selected = -1; + ClearEditor(); + _status = "已清空编辑区,可添加新行"; + } + pb.SameLine(8); + if (pb.Button("从文件刷新", distinct: $"dc-{_idPrefix}-reload")) + Reload(); + + if (!string.IsNullOrEmpty(_status)) + { + pb.Separator(); + pb.Label(_status); + } + } + + /// 发现可编辑列。Browsable=false 或 JsonIgnore 的运行态字段不会进表。 + private static PropertyInfo[] Discover() + { + return typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.CanRead && p.CanWrite && p.GetIndexParameters().Length == 0) + .Where(p => p.GetCustomAttribute()?.Browsable != false) + .Where(p => p.GetCustomAttribute() == null) + .OrderBy(p => p.MetadataToken) + .ToArray(); + } + + private static string DisplayOf(PropertyInfo p) => + p.GetCustomAttribute()?.DisplayName ?? p.Name; + + private static string FormatCell(object value) + { + if (value == null) return ""; + if (value is bool b) return b ? "是" : "否"; + return Convert.ToString(value) ?? ""; + } + + private void DrawEditor(PanelBuilder pb) + { + for (var i = 0; i < Props.Length; i++) + { + var p = Props[i]; + var label = $"{_idPrefix}-{i + 1}. {DisplayOf(p)}"; + var type = p.PropertyType; + if (type == typeof(bool)) + { + if (!_bools.ContainsKey(p.Name)) _bools[p.Name] = false; + var v = _bools[p.Name]; + pb.CheckBox(label, ref v); + _bools[p.Name] = v; + } + else if (type.IsEnum) + { + var names = Enum.GetNames(type); + if (names.Length == 0) continue; + if (!_enumIdx.ContainsKey(p.Name)) _enumIdx[p.Name] = 0; + var idx = _enumIdx[p.Name]; + if (idx < 0 || idx >= names.Length) idx = 0; + pb.DropdownBox(label, names, ref idx); + _enumIdx[p.Name] = idx; + } + else + { + if (!_text.ContainsKey(p.Name)) _text[p.Name] = ""; + var (text, _) = pb.TextInput(label, _text[p.Name], alwaysReturnString: true); + _text[p.Name] = text ?? ""; + } + } + } + + private void LoadEditor(T item) + { + foreach (var p in Props) + { + var value = p.GetValue(item); + if (p.PropertyType == typeof(bool)) + _bools[p.Name] = value is true; + else if (p.PropertyType.IsEnum) + { + var names = Enum.GetNames(p.PropertyType); + var name = value?.ToString() ?? names.FirstOrDefault() ?? ""; + var idx = Array.IndexOf(names, name); + _enumIdx[p.Name] = idx >= 0 ? idx : 0; + } + else + _text[p.Name] = value == null ? "" : Convert.ToString(value) ?? ""; + } + } + + private void ClearEditor() + { + _text.Clear(); + _bools.Clear(); + _enumIdx.Clear(); + foreach (var p in Props) + { + if (p.PropertyType == typeof(bool)) + _bools[p.Name] = false; + else if (p.PropertyType.IsEnum) + _enumIdx[p.Name] = 0; + else if (p.PropertyType == typeof(int) || p.PropertyType == typeof(byte) || p.PropertyType == typeof(ushort)) + _text[p.Name] = "0"; + else + _text[p.Name] = DefaultText(p); + } + } + + private static string DefaultText(PropertyInfo p) + { + try + { + var fresh = new T(); + var v = p.GetValue(fresh); + return v == null ? "" : Convert.ToString(v) ?? ""; + } + catch + { + return ""; + } + } + + /// 把编辑区打成对象。主键空、整数解析失败时返回 false 并给出中文 err。 + private bool TryBuild(out T item, out string err) + { + item = new T(); + err = ""; + foreach (var p in Props) + { + try + { + object boxed; + if (p.PropertyType == typeof(bool)) + boxed = _bools.TryGetValue(p.Name, out var b) && b; + else if (p.PropertyType.IsEnum) + { + var names = Enum.GetNames(p.PropertyType); + var idx = _enumIdx.TryGetValue(p.Name, out var e) ? e : 0; + if (idx < 0 || idx >= names.Length) idx = 0; + boxed = Enum.Parse(p.PropertyType, names[idx]); + } + else if (p.PropertyType == typeof(int)) + { + var raw = _text.TryGetValue(p.Name, out var t) ? t : "0"; + if (!int.TryParse(string.IsNullOrWhiteSpace(raw) ? "0" : raw.Trim(), out var n)) + { + err = $"{DisplayOf(p)} 必须是整数"; + return false; + } + boxed = n; + } + else if (p.PropertyType == typeof(ushort)) + { + var raw = _text.TryGetValue(p.Name, out var t) ? t : "0"; + if (!ushort.TryParse(string.IsNullOrWhiteSpace(raw) ? "0" : raw.Trim(), out var n)) + { + err = $"{DisplayOf(p)} 必须是 0-65535"; + return false; + } + boxed = n; + } + else if (p.PropertyType == typeof(byte)) + { + var raw = _text.TryGetValue(p.Name, out var t) ? t : "0"; + if (!byte.TryParse(string.IsNullOrWhiteSpace(raw) ? "0" : raw.Trim(), out var n)) + { + err = $"{DisplayOf(p)} 必须是 0-255"; + return false; + } + boxed = n; + } + else + boxed = _text.TryGetValue(p.Name, out var s) ? s ?? "" : ""; + p.SetValue(item, boxed); + } + catch (Exception ex) + { + err = $"{DisplayOf(p)} 写入失败:{ex.Message}"; + return false; + } + } + + var key = (_keySelector(item) ?? "").Trim(); + if (string.IsNullOrEmpty(key)) + { + err = "主键不能为空"; + return false; + } + + return true; + } + + /// 主键已存在则拒绝。成功后立刻写盘并把选中行指到新行。 + private void Add() + { + if (!TryBuild(out var item, out var err)) + { + CycleUiHelper.Alert("错误", err); + return; + } + + var key = _keySelector(item); + if (_items.Any(x => string.Equals(_keySelector(x), key, StringComparison.OrdinalIgnoreCase))) + { + CycleUiHelper.Alert("错误", $"主键「{key}」已存在"); + return; + } + + _items.Add(item); + _selected = _items.Count - 1; + SignalConfigStore.Save(_path, _items); + _status = $"已添加 {key}"; + } + + /// 覆盖当前选中行。主键与其它行冲突则拒绝。 + private void SaveRow() + { + if (_selected < 0 || _selected >= _items.Count) + { + CycleUiHelper.Alert("提示", "请先选择要保存的行,或用「添加」写入新行"); + return; + } + + if (!TryBuild(out var item, out var err)) + { + CycleUiHelper.Alert("错误", err); + return; + } + + var key = _keySelector(item); + if (_items.Where((_, i) => i != _selected) + .Any(x => string.Equals(_keySelector(x), key, StringComparison.OrdinalIgnoreCase))) + { + CycleUiHelper.Alert("错误", $"主键「{key}」已存在"); + return; + } + + _items[_selected] = item; + SignalConfigStore.Save(_path, _items); + _status = $"已保存 {key}"; + CycleUiHelper.Alert("提示", "保存成功"); + } + + /// 确认框通过后删除选中行并写盘。 + private void ConfirmDelete() + { + if (_selected < 0 || _selected >= _items.Count) + { + CycleUiHelper.Alert("提示", "请先选择要删除的行"); + return; + } + + var key = _keySelector(_items[_selected]); + CycleUiHelper.ConfirmThen($"删除「{key}」?", () => + { + _items.RemoveAt(_selected); + _selected = -1; + ClearEditor(); + SignalConfigStore.Save(_path, _items); + _status = $"已删除 {key}"; + }); + } + } +} diff --git a/_inspect_datacenter.csx b/_inspect_datacenter.csx new file mode 100644 index 0000000..717f076 --- /dev/null +++ b/_inspect_datacenter.csx @@ -0,0 +1,18 @@ +#r "D:\工作\stand\SimpleLite\SimpleLite.dll" +using System; +using System.Linq; +using System.Reflection; + +var asm = typeof(SimpleLite.RCS.Mission).Assembly; +Console.WriteLine("asm=" + asm.FullName); +foreach (var t in asm.GetTypes().OrderBy(x => x.FullName)) +{ + var n = t.FullName ?? ""; + if (n.IndexOf("Data", StringComparison.OrdinalIgnoreCase) >= 0 + || n.IndexOf("Json", StringComparison.OrdinalIgnoreCase) >= 0 + || n.IndexOf("Table", StringComparison.OrdinalIgnoreCase) >= 0 + || n.IndexOf("Center", StringComparison.OrdinalIgnoreCase) >= 0 + || n.IndexOf("Store", StringComparison.OrdinalIgnoreCase) >= 0 + || n.Contains("UI")) + Console.WriteLine(n); +}