From 12c7c1d51865029f2f1ae9f5f2b5690973adcc02 Mon Sep 17 00:00:00 2001 From: ykkokluo <984278063@qq.com> Date: Fri, 14 Aug 2026 14:43:11 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20scene.signal=20=E4=BF=A1?= =?UTF-8?q?=E5=8F=B7=E4=BA=A4=E4=BA=92=E6=8F=92=E4=BB=B6=EF=BC=8C=E8=90=BD?= =?UTF-8?q?=E5=9C=B0=20PLC=20=E6=8F=A1=E6=89=8B=E4=B8=8E=E6=89=AB=E8=BD=A6?= =?UTF-8?q?=E6=94=BE=E8=A1=8C=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- Directory.Build.targets | 15 +- .../Properties/InternalsVisibleTo.cs | 1 + .../Config/Signal/handshake-points.json | 32 ++ .../Config/Signal/release-points.json | 10 + .../Config/Signal/stations.json | 28 ++ StandardScene.Signal/Logic/CarScanWorker.cs | 183 +++++++++ .../Logic/JgControlHandler.cs | 133 +++++++ .../Logic/PointControlHandler.cs | 77 ++++ StandardScene.Signal/Logic/RateFlowHandler.cs | 83 +++++ .../Logic/ReleasePointHandler.cs | 19 + StandardScene.Signal/Logic/SignalStopSpec.cs | 42 +++ .../Logic/TimedChargeHandler.cs | 57 +++ .../Mission/SignalInteractMission.cs | 260 +++++++++++++ .../Model/HandshakePointModel.cs | 20 + StandardScene.Signal/Model/PlcStationModel.cs | 43 +++ .../Model/PlcStationSnapshot.cs | 19 + .../Model/ReleasePointModel.cs | 14 + StandardScene.Signal/Model/SignalEnums.cs | 30 ++ StandardScene.Signal/Plc/PlcStationLogic.cs | 183 +++++++++ StandardScene.Signal/Plc/PlcStationRuntime.cs | 79 ++++ StandardScene.Signal/Plc/PlcStationWorker.cs | 155 ++++++++ StandardScene.Signal/Plc/SiemensPlcSession.cs | 152 ++++++++ StandardScene.Signal/Plc/SignalCarAdapter.cs | 264 +++++++++++++ StandardScene.Signal/SignalConfigStore.cs | 85 +++++ StandardScene.Signal/Site/SignalSiteFields.cs | 12 + .../StandardScene.Signal.csproj | 57 +++ .../StandardScene.Signal.scene.json | 10 + StandardScene.Signal/Ui/ModelListPanel.cs | 349 ++++++++++++++++++ StandardScene.sln | 14 + 29 files changed, 2425 insertions(+), 1 deletion(-) create mode 100644 StandardScene.Signal/Config/Signal/handshake-points.json create mode 100644 StandardScene.Signal/Config/Signal/release-points.json create mode 100644 StandardScene.Signal/Config/Signal/stations.json create mode 100644 StandardScene.Signal/Logic/CarScanWorker.cs create mode 100644 StandardScene.Signal/Logic/JgControlHandler.cs create mode 100644 StandardScene.Signal/Logic/PointControlHandler.cs create mode 100644 StandardScene.Signal/Logic/RateFlowHandler.cs create mode 100644 StandardScene.Signal/Logic/ReleasePointHandler.cs create mode 100644 StandardScene.Signal/Logic/SignalStopSpec.cs create mode 100644 StandardScene.Signal/Logic/TimedChargeHandler.cs create mode 100644 StandardScene.Signal/Mission/SignalInteractMission.cs create mode 100644 StandardScene.Signal/Model/HandshakePointModel.cs create mode 100644 StandardScene.Signal/Model/PlcStationModel.cs create mode 100644 StandardScene.Signal/Model/PlcStationSnapshot.cs create mode 100644 StandardScene.Signal/Model/ReleasePointModel.cs create mode 100644 StandardScene.Signal/Model/SignalEnums.cs create mode 100644 StandardScene.Signal/Plc/PlcStationLogic.cs create mode 100644 StandardScene.Signal/Plc/PlcStationRuntime.cs create mode 100644 StandardScene.Signal/Plc/PlcStationWorker.cs create mode 100644 StandardScene.Signal/Plc/SiemensPlcSession.cs create mode 100644 StandardScene.Signal/Plc/SignalCarAdapter.cs create mode 100644 StandardScene.Signal/SignalConfigStore.cs create mode 100644 StandardScene.Signal/Site/SignalSiteFields.cs create mode 100644 StandardScene.Signal/StandardScene.Signal.csproj create mode 100644 StandardScene.Signal/StandardScene.Signal.scene.json create mode 100644 StandardScene.Signal/Ui/ModelListPanel.cs diff --git a/Directory.Build.targets b/Directory.Build.targets index e895908..0196905 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -1,7 +1,7 @@ $(MSBuildThisFileDirectory) @@ -12,6 +12,7 @@ true true true + true + + + + + + + diff --git a/StandardScene.Core/Properties/InternalsVisibleTo.cs b/StandardScene.Core/Properties/InternalsVisibleTo.cs index f356e92..550d247 100644 --- a/StandardScene.Core/Properties/InternalsVisibleTo.cs +++ b/StandardScene.Core/Properties/InternalsVisibleTo.cs @@ -6,5 +6,6 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("StandardScene.Devices")] [assembly: InternalsVisibleTo("StandardScene.Magnetic")] [assembly: InternalsVisibleTo("StandardScene.QrLidar")] +[assembly: InternalsVisibleTo("StandardScene.Signal")] // SimpleLite 反射 API 需读取 internal 字段袋(BasicTrackFields / KivaSiteFields 等)的 public 字段定义。 [assembly: InternalsVisibleTo("SimpleLite")] diff --git a/StandardScene.Signal/Config/Signal/handshake-points.json b/StandardScene.Signal/Config/Signal/handshake-points.json new file mode 100644 index 0000000..d732469 --- /dev/null +++ b/StandardScene.Signal/Config/Signal/handshake-points.json @@ -0,0 +1,32 @@ +[ + { + "Sit": 98, + "SignalType": "JGControl", + "SignalParams": "上线机构1;RequestIn" + }, + { + "Sit": 100, + "SignalType": "JGControl", + "SignalParams": "上线机构1;Arrived" + }, + { + "Sit": 199, + "SignalType": "JGControl", + "SignalParams": "下线机构1;RequestIn" + }, + { + "Sit": 201, + "SignalType": "PointControl", + "SignalParams": "UP1;下线机构1" + }, + { + "Sit": 50, + "SignalType": "TimedCharging", + "SignalParams": "300;95;20" + }, + { + "Sit": 60, + "SignalType": "RateFlowControl", + "SignalParams": "缓存区;A;2;60,61,62" + } +] diff --git a/StandardScene.Signal/Config/Signal/release-points.json b/StandardScene.Signal/Config/Signal/release-points.json new file mode 100644 index 0000000..d427146 --- /dev/null +++ b/StandardScene.Signal/Config/Signal/release-points.json @@ -0,0 +1,10 @@ +[ + { + "Sit": 10, + "IsUse": true + }, + { + "Sit": 20, + "IsUse": false + } +] diff --git a/StandardScene.Signal/Config/Signal/stations.json b/StandardScene.Signal/Config/Signal/stations.json new file mode 100644 index 0000000..004df13 --- /dev/null +++ b/StandardScene.Signal/Config/Signal/stations.json @@ -0,0 +1,28 @@ +[ + { + "JgName": "上线机构1", + "Ip": "192.168.1.10", + "Port": 102, + "ReadDb": "DB1.DBB0", + "WriteDb": "DB1.DBB1", + "HeartDb": "DB1.DBX2.0", + "JgInPointId": 99, + "JgInContains": "99,100", + "JgPointId": 100, + "JgContains": "100,101,102", + "LineType": "上线机构" + }, + { + "JgName": "下线机构1", + "Ip": "192.168.1.11", + "Port": 102, + "ReadDb": "DB1.DBB0", + "WriteDb": "DB1.DBB1", + "HeartDb": "DB1.DBX2.0", + "JgInPointId": 200, + "JgInContains": "200", + "JgPointId": 201, + "JgContains": "201,202", + "LineType": "单牵引下线机构" + } +] diff --git a/StandardScene.Signal/Logic/CarScanWorker.cs b/StandardScene.Signal/Logic/CarScanWorker.cs new file mode 100644 index 0000000..dcafc26 --- /dev/null +++ b/StandardScene.Signal/Logic/CarScanWorker.cs @@ -0,0 +1,183 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using SimpleCore.Library; +using SimpleLite.RCS.CarTypes; +using StandardScene.Signal.Model; +using StandardScene.Signal.Plc; + +namespace StandardScene.Signal.Logic +{ + /// 1s 扫全场车,对齐反编译 DefaultPlugin.ExcuteThreadMethod + StarSignManage。 + public sealed class CarScanWorker : IDisposable + { + private readonly Func> _runtimes; + 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(); + private readonly Dictionary _chargeStarted = new Dictionary(); + private readonly Dictionary _unknownTypeLog = new Dictionary(); + private CancellationTokenSource _cts; + private Thread _thread; + private int _tickCount; + + public CarScanWorker(Func> runtimes, int tickMs) + { + _runtimes = runtimes ?? throw new ArgumentNullException(nameof(runtimes)); + _tickMs = Math.Max(100, tickMs); + } + + public int TickCount => _tickCount; + + public void ReplaceConfig(IEnumerable handshake, IEnumerable release) + { + lock (_configLock) + { + _handshake = (handshake ?? Array.Empty()) + .Where(x => x != null && x.Sit > 0 && !string.IsNullOrWhiteSpace(x.SignalType)) + .GroupBy(x => x.Sit) + .ToDictionary(g => g.Key, g => g.First()); + _release = (release ?? Array.Empty()) + .Where(x => x != null && x.Sit > 0) + .GroupBy(x => x.Sit) + .ToDictionary(g => g.Key, g => g.First()); + } + } + + public void Start() + { + if (_thread != null && _thread.IsAlive) return; + _cts = new CancellationTokenSource(); + _thread = new Thread(Loop) + { + IsBackground = true, + Name = "SignalScan" + }; + _thread.Start(); + Diagnosis.Log("扫车握手线程已启动", "Signal", true); + } + + public void Stop() + { + try { _cts?.Cancel(); } + catch { } + try { _thread?.Join(2000); } + catch { } + _thread = null; + Diagnosis.Log("扫车握手线程已停止", "Signal", true); + } + + public void Dispose() => Stop(); + + private void Loop() + { + var token = _cts.Token; + while (!token.IsCancellationRequested) + { + try + { + ScanOnce(); + Interlocked.Increment(ref _tickCount); + } + catch (Exception ex) + { + Diagnosis.Log($"检测信号停止的AGV是否可以放行异常:{ex.Message}", "Signal", true); + } + + try { Thread.Sleep(_tickMs); } + catch (ThreadInterruptedException) { break; } + } + } + + internal void ScanOnce() + { + Dictionary handshake; + Dictionary release; + lock (_configLock) + { + handshake = _handshake; + release = _release; + } + + PlcStationRuntime Find(string name) + { + if (string.IsNullOrWhiteSpace(name)) + return null; + var list = _runtimes() ?? Array.Empty(); + return list.FirstOrDefault(x => + string.Equals(x.Config.JgName, name, StringComparison.OrdinalIgnoreCase)); + } + + foreach (var car in SignalCarAdapter.All()) + { + if (car == null) continue; + var sit = SignalCarAdapter.PositionId(car); + var spec = ResolveSpec(sit, handshake); + + if (SignalCarAdapter.IsStopped(car)) + { + if (spec != null) + DispatchStopped(car, spec, Find); + ReleasePointHandler.OnStopped(car, sit, release); + } + else if (spec != null && spec.Is("JGControl")) + { + JgControlHandler.OnMoving(car, spec, Find); + } + } + } + + private SignalStopSpec ResolveSpec(int sit, IReadOnlyDictionary handshake) + { + if (sit <= 0) return null; + + if (SignalCarAdapter.TryReadSignalStop(sit, out var type, out var parms)) + { + if (string.IsNullOrWhiteSpace(type)) + return null; + return SignalStopSpec.Parse(type, parms); + } + + if (handshake != null && handshake.TryGetValue(sit, out var model)) + return SignalStopSpec.From(model); + + return null; + } + + private void DispatchStopped(Car car, SignalStopSpec spec, Func find) + { + if (spec.Is("TimedCharging")) + { + TimedChargeHandler.OnStopped(car, spec, _chargeStarted); + return; + } + + if (spec.Is("JGControl")) + { + JgControlHandler.OnStopped(car, spec, find, _rateFlowLog); + return; + } + + if (spec.Is("PointControl")) + { + PointControlHandler.OnStopped(car, spec, find); + return; + } + + if (spec.Is("RateFlowControl")) + { + RateFlowHandler.OnStopped(car, spec, _rateFlowLog); + return; + } + + var sit = SignalCarAdapter.PositionId(car); + if (_unknownTypeLog.TryGetValue(sit, out var last) && (DateTime.Now - last).TotalSeconds < 10) + return; + _unknownTypeLog[sit] = DateTime.Now; + Diagnosis.Log($"信号类型【{spec.SignalType}】未找到匹配的业务处理模块", "Signal", true); + } + } +} diff --git a/StandardScene.Signal/Logic/JgControlHandler.cs b/StandardScene.Signal/Logic/JgControlHandler.cs new file mode 100644 index 0000000..214d017 --- /dev/null +++ b/StandardScene.Signal/Logic/JgControlHandler.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using SimpleCore.Library; +using SimpleLite.RCS.CarTypes; +using StandardScene.Signal.Model; +using StandardScene.Signal.Plc; + +namespace StandardScene.Signal.Logic +{ + /// 对齐反编译 DefaultPlugin.JGControl / JGJudge / RequestIn / Arrived。 + public static class JgControlHandler + { + public static void OnStopped(Car car, SignalStopSpec spec, Func find, IDictionary rateFlowLog) + { + var jgName = spec.Part(0).Trim(); + var action = spec.Part(1).Trim(); + var rateFlow = spec.Part(2).Trim(); + if (string.IsNullOrEmpty(jgName)) + { + Diagnosis.Log($"AGV{car.id}停在地标{SignalCarAdapter.PositionId(car)},读取配置机构名称为空", "JGControl", true); + return; + } + + if (!string.IsNullOrEmpty(rateFlow) && !RateFlowHandler.AllowPass(car, rateFlow, rateFlowLog)) + { + Diagnosis.Log($"AGV{car.id}停在地标{SignalCarAdapter.PositionId(car)},限流禁止通行", "JGControl", true); + return; + } + + if (string.Equals(action, "RequestIn", StringComparison.OrdinalIgnoreCase)) + RequestIn(jgName, car, find); + else if (string.Equals(action, "Arrived", StringComparison.OrdinalIgnoreCase)) + Arrived(jgName, car, find); + } + + public static void OnMoving(Car car, SignalStopSpec spec, Func find) + { + var jgName = spec.Part(0).Trim(); + var action = spec.Part(1).Trim(); + if (string.IsNullOrEmpty(jgName)) + return; + if (string.Equals(action, "RequestIn", StringComparison.OrdinalIgnoreCase)) + RequestInJudge(jgName, car, find); + } + + private static void RequestIn(string jgName, Car car, Func find) + { + var rt = find(jgName); + if (rt == null) + { + Diagnosis.Log($"{jgName} 未能找到机构", "JGControl", true); + return; + } + + lock (rt.Sync) + { + if (rt.ReadSignal == SignalReadValue.允许进入 && + rt.RequestInAgvNo == 0 && + rt.ReadWriteSignal == SignalWriteValue.无状态) + { + if (rt.Config.LineType is SignalLineType.单牵引下线机构 or SignalLineType.双牵引下线机构) + { + var inside = SignalCarAdapter.CarsAt(rt.Config.JgPointId); + if (inside.Count > 0) + { + Diagnosis.Log( + $"{jgName} 机构内有AGV【{string.Join(",", inside.Select(x => x.id))}】暂不放行AGV{car.id}", + "JGControl", true); + return; + } + } + + Diagnosis.Log($"{jgName} 机构允许进入,通知AGV{car.id}启动,通知机构AGV进入中", "JGControl", true); + rt.WriteSignal = SignalWriteValue.进入中; + rt.RequestInAgvNo = car.id; + SignalCarAdapter.StopCharge(car); + SignalCarAdapter.Start(car); + return; + } + + if (rt.ReadSignal != SignalReadValue.允许进入 && + rt.RequestInAgvNo != car.id && + SignalCarAdapter.IsMoving(car)) + { + SignalCarAdapter.Stop(car); + Diagnosis.Log($"{jgName} 机构不允许进入,监控到AGV启动,暂停AGV{car.id}", "JGControl", true); + } + } + } + + private static void RequestInJudge(string jgName, Car car, Func find) + { + var rt = find(jgName); + if (rt == null) + { + Diagnosis.Log($"{jgName} 未能找到机构", "JGControl", true); + return; + } + + lock (rt.Sync) + { + if (rt.ReadSignal != SignalReadValue.允许进入 && + rt.RequestInAgvNo != car.id && + SignalCarAdapter.IsMoving(car)) + { + SignalCarAdapter.Stop(car); + Diagnosis.Log($"{jgName} 机构不允许进入,监控到AGV启动,暂停AGV{car.id}", "JGControl", true); + } + } + } + + private static void Arrived(string jgName, Car car, Func find) + { + var rt = find(jgName); + if (rt == null) + { + Diagnosis.Log($"{jgName} 未能找到机构", "JGControl", true); + return; + } + + lock (rt.Sync) + { + if (rt.InAgvNo == car.id) + return; + Diagnosis.Log($"AGV{car.id}已到达机构{jgName}", "JGControl", true); + rt.WriteSignal = SignalWriteValue.到位; + rt.InAgvNo = car.id; + rt.RequestInAgvNo = 0; + } + } + } +} diff --git a/StandardScene.Signal/Logic/PointControlHandler.cs b/StandardScene.Signal/Logic/PointControlHandler.cs new file mode 100644 index 0000000..aa6d130 --- /dev/null +++ b/StandardScene.Signal/Logic/PointControlHandler.cs @@ -0,0 +1,77 @@ +using System; +using SimpleCore.Library; +using SimpleLite.RCS.CarTypes; +using StandardScene.Signal.Model; +using StandardScene.Signal.Plc; + +namespace StandardScene.Signal.Logic +{ + /// 对齐反编译 DefaultPlugin.PointControl:牵引棒升/降到位后再放行。 + public static class PointControlHandler + { + public static void OnStopped(Car car, SignalStopSpec spec, Func find) + { + var action = spec.Part(0).Trim(); + var jgName = spec.Part(1).Trim(); + if (string.IsNullOrEmpty(action)) + { + Diagnosis.Log($"AGV{car.id}停在地标{SignalCarAdapter.PositionId(car)},读取配置特殊点名称为空", "SetPin", true); + return; + } + + var waitLeave = false; + if (!string.IsNullOrEmpty(jgName)) + { + var rt = find(jgName); + if (rt != null) + { + lock (rt.Sync) + waitLeave = rt.ReadSignal != SignalReadValue.允许离开; + } + } + + var pos = SignalCarAdapter.PositionId(car); + switch (action) + { + case "UP1": + PinThenGo(car, pos, waitLeave, jgName, "SetPin1Up"); + break; + case "UP2": + PinThenGo(car, pos, waitLeave, jgName, "SetPin1Up", "SetPin2Up"); + break; + case "Down1": + PinThenGo(car, pos, waitLeave, jgName, "SetPin1Down"); + break; + case "Down2": + PinThenGo(car, pos, waitLeave, jgName, "SetPin1Down", "SetPin2Down"); + break; + } + } + + private static void PinThenGo(Car car, int pos, bool waitLeave, string jgName, params string[] actions) + { + var done = true; + foreach (var a in actions) + { + if (!SignalCarAdapter.IsActionFinish(car, a)) + done = false; + } + + if (!done) + { + foreach (var a in actions) + SignalCarAdapter.Control(car, a); + return; + } + + if (waitLeave) + { + Diagnosis.Log($"AGV{car.id}停在地标{pos},需要读取{jgName}工位的允许离开", "SetPin", true); + return; + } + + SignalCarAdapter.Start(car); + Diagnosis.Log($"AGV{car.id}停在地标{pos},牵引棒动作到位,已通知AGV启动", "SetPin", true); + } + } +} diff --git a/StandardScene.Signal/Logic/RateFlowHandler.cs b/StandardScene.Signal/Logic/RateFlowHandler.cs new file mode 100644 index 0000000..a514fee --- /dev/null +++ b/StandardScene.Signal/Logic/RateFlowHandler.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using SimpleCore.Library; +using SimpleLite.RCS.CarTypes; +using StandardScene.Signal.Plc; + +namespace StandardScene.Signal.Logic +{ + /// + /// 对齐反编译 DefaultPlugin.RateFlowControl。 + /// 独立类型用分号:区域;车组;限额;点1,点2。JGControl 第三段用短横:区域-车组-限额-点1,点2。 + /// + public static class RateFlowHandler + { + public static void OnStopped(Car car, SignalStopSpec spec, IDictionary logByArea) + { + if (AllowPass(car, spec.SignalParams, logByArea, sep: ';')) + { + SignalCarAdapter.StopCharge(car); + SignalCarAdapter.Start(car); + } + } + + public static bool AllowPass(Car car, string rateFlowString, IDictionary logByArea) => + AllowPass(car, rateFlowString, logByArea, sep: '-'); + + private static bool AllowPass(Car car, string raw, IDictionary logByArea, char sep) + { + try + { + var parts = (raw ?? "").Split(sep); + var area = parts.Length > 0 ? parts[0] : ""; + var group = parts.Length > 1 ? parts[1] : ""; + var limit = 0; + if (parts.Length > 2) + int.TryParse(parts[2], out limit); + var sites = new List(); + if (parts.Length > 3) + { + foreach (var s in parts[3].Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)) + { + if (int.TryParse(s.Trim(), out var id)) + sites.Add(id); + } + } + + var others = SignalCarAdapter.CarsAt(sites) + .Where(x => x.id != car.id && string.Equals(SignalCarAdapter.GroupId(x), group, StringComparison.Ordinal)) + .ToList(); + var count = others.Count; + if (count < limit) + { + Diagnosis.Log( + $"限流区域{area}限制AGV组{group}流量{limit}辆AGV,当前停放{count},放行AGV{car.id}", + "RateFlow", true); + Note(logByArea, area, car, raw); + return true; + } + + Diagnosis.Log( + $"限流区域{area}限制AGV组{group}流量{limit}辆AGV,当前停放{count},无法放行", + "RateFlow", true); + Note(logByArea, area, car, raw); + return false; + } + catch (Exception ex) + { + Diagnosis.Log($"处理区域限流业务异常:{ex.Message}", "RateFlow", true); + return true; + } + } + + private static void Note(IDictionary logByArea, string area, Car car, string raw) + { + if (logByArea == null) return; + var changed = !logByArea.TryGetValue(area, out var last) || last != car.id; + logByArea[area] = car.id; + if (changed) + Diagnosis.Log($"AGV{car.id}停在地标{SignalCarAdapter.PositionId(car)},配置参数:【{raw}】", "RateFlow", true); + } + } +} diff --git a/StandardScene.Signal/Logic/ReleasePointHandler.cs b/StandardScene.Signal/Logic/ReleasePointHandler.cs new file mode 100644 index 0000000..de88af6 --- /dev/null +++ b/StandardScene.Signal/Logic/ReleasePointHandler.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using SimpleLite.RCS.CarTypes; +using StandardScene.Signal.Model; +using StandardScene.Signal.Plc; + +namespace StandardScene.Signal.Logic +{ + /// 对齐反编译 StarSignManage:配置点停稳则停充并放行。 + 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) + return; + SignalCarAdapter.StopCharge(car); + SignalCarAdapter.Start(car); + } + } +} diff --git a/StandardScene.Signal/Logic/SignalStopSpec.cs b/StandardScene.Signal/Logic/SignalStopSpec.cs new file mode 100644 index 0000000..f2f8374 --- /dev/null +++ b/StandardScene.Signal/Logic/SignalStopSpec.cs @@ -0,0 +1,42 @@ +using System; +using StandardScene.Signal.Model; + +namespace StandardScene.Signal.Logic +{ + /// 握手点解析结果。参数格式与反编译 SignalStop.SignalParams 一致。 + public sealed class SignalStopSpec + { + public string SignalType { get; init; } = ""; + public string SignalParams { get; init; } = ""; + public string[] Parts { get; init; } = Array.Empty(); + + public string Part(int index) + { + if (index < 0 || index >= Parts.Length) + return ""; + return Parts[index] ?? ""; + } + + public static SignalStopSpec Parse(string signalType, string signalParams) + { + var type = (signalType ?? "").Trim(); + var raw = signalParams ?? ""; + return new SignalStopSpec + { + SignalType = type, + SignalParams = raw, + Parts = raw.Split(new[] { ';' }, StringSplitOptions.None) + }; + } + + public static SignalStopSpec From(HandshakePointModel model) + { + if (model == null) + return null; + 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 new file mode 100644 index 0000000..96c0c05 --- /dev/null +++ b/StandardScene.Signal/Logic/TimedChargeHandler.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using SimpleCore.Library; +using SimpleLite.RCS.CarTypes; +using StandardScene.Signal.Plc; + +namespace StandardScene.Signal.Logic +{ + /// 对齐反编译 DefaultPlugin.TimedCharging:时长 / 上限电量 / 下限电量。 + public static class TimedChargeHandler + { + public static void OnStopped(Car car, SignalStopSpec spec, IDictionary chargeStarted) + { + int.TryParse(spec.Part(0), out var maxSeconds); + int.TryParse(spec.Part(1), out var maxSoc); + int.TryParse(spec.Part(2), out var minSoc); + + var charged = SignalCarAdapter.ChargeSeconds(car, chargeStarted); + var soc = SignalCarAdapter.Soc(car); + var pos = SignalCarAdapter.PositionId(car); + + if (SignalCarAdapter.IsCharging(car)) + { + Diagnosis.Log( + $"AGV{car.id}实时电量{soc}停在地标{pos}上已充电{charged}秒,信号停止类型为定时充电,参数为:{maxSeconds},{maxSoc},{minSoc}", + "Charge", true); + + if (maxSoc != 0 && soc >= maxSoc) + { + Diagnosis.Log($"AGV{car.id}电量大于配置的最高充电电量,故停止充电", "Charge", true); + SignalCarAdapter.StopCharge(car); + return; + } + + if (maxSeconds != 0 && charged >= maxSeconds) + { + if (minSoc != 0 && soc <= minSoc) + { + Diagnosis.Log($"AGV{car.id}充电时长已到达{maxSeconds}秒,但仍未充电至{minSoc}继续充电", "Charge", true); + return; + } + + Diagnosis.Log($"AGV{car.id}充电时长已到达{maxSeconds}秒,故停止充电", "Charge", true); + SignalCarAdapter.StopCharge(car); + } + + return; + } + + if (SignalCarAdapter.IsStopped(car)) + { + Diagnosis.Log($"AGV{car.id}已经停止充电,故放行AGV", "Charge", true); + SignalCarAdapter.Start(car); + } + } + } +} diff --git a/StandardScene.Signal/Mission/SignalInteractMission.cs b/StandardScene.Signal/Mission/SignalInteractMission.cs new file mode 100644 index 0000000..cac96d7 --- /dev/null +++ b/StandardScene.Signal/Mission/SignalInteractMission.cs @@ -0,0 +1,260 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using LessokajiWeaverUtilities.Utilities; +using Newtonsoft.Json; +using SimpleCore; +using SimpleCore.Library; +using SimpleLite.RCS; +using StandardScene.Signal.Logic; +using StandardScene.Signal.Model; +using StandardScene.Signal.Plc; +using StandardScene.Signal.Ui; +using StandardScene.Utils; + +namespace StandardScene.Signal +{ + /// + /// 第三方信号交互进程:PLC Worker(200ms)+ 扫车握手(1s)。 + /// + [MissionType(Name = "信号交互进程", editor = typeof(SignalInteractMission))] + [I18N.DocumentTranslation(Name = "Signal Interact Mission", locale = "en")] + public class SignalInteractMission : Mission + { + [FieldMember] public int TickMs = 1000; + + [FieldMember] public int PlcPollMs = 200; + + [FieldMember] public string PlcVersion = "S7_1500"; + + [FieldMember] public int PlcPort = 102; + + [FieldMember] public int PlcSlot = 1; + + [FieldMember] public string StationsPath = @"Config\Signal\stations.json"; + + [FieldMember] public string ReleasePointsPath = @"Config\Signal\release-points.json"; + + [FieldMember] public string HandshakePointsPath = @"Config\Signal\handshake-points.json"; + + [JsonIgnore] private bool _started; + [JsonIgnore] private readonly object _workerLock = new object(); + [JsonIgnore] private readonly List _workers = new List(); + [JsonIgnore] private CarScanWorker _scan; + [JsonIgnore] private int _statusTick; + + public class SignalInteractMissionStatus : MissionStatus + { + public int StationCount { get; set; } + public int ConnectedCount { get; set; } + public int ReleasePointCount { get; set; } + 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(); + + public static Mission Create() => new SignalInteractMission(); + + [MethodMember(Name = "启动进程", Description = "加载配置,启动 PLC Worker 与扫车握手")] + public override void Execute() + { + try + { + ReloadConfig(); + _started = true; + RestartWorkers(); + RestartScan(); + var st = (SignalInteractMissionStatus)status; + Diagnosis.Post( + $"信号交互进程已启动,机构={st.StationCount},握手点={st.HandshakePointCount},放行点={st.ReleasePointCount}", + "Signal", true); + } + catch (Exception ex) + { + status.status = "启动失败"; + Diagnosis.Post($"信号交互进程启动失败:{ExceptionFormatter.FormatEx(ex)}", "Signal", true); + throw; + } + } + + [MethodMember(Name = "重新加载配置", Description = "重新读取 JSON;若进程已启动则重启 Worker/扫车")] + public void ReloadConfig() + { + var stationsFile = SignalConfigStore.Resolve(StationsPath); + var releaseFile = SignalConfigStore.Resolve(ReleasePointsPath); + var handshakeFile = SignalConfigStore.Resolve(HandshakePointsPath); + var stations = SignalConfigStore.Load(stationsFile); + var points = SignalConfigStore.Load(releaseFile); + var handshake = SignalConfigStore.Load(handshakeFile); + + var st = (SignalInteractMissionStatus)status; + st.StationCount = stations.Count; + st.ReleasePointCount = points.Count; + st.HandshakePointCount = handshake.Count; + st.StationsFile = stationsFile; + st.ReleasePointsFile = releaseFile; + st.HandshakePointsFile = handshakeFile; + st.Stations = stations.Select(s => new PlcStationSnapshot { JgName = s.JgName }).ToList(); + status.status = _started ? "运行中" : "配置已加载"; + Diagnosis.Post( + $"信号配置已加载:机构 {stations.Count};握手点 {handshake.Count} @ {handshakeFile};放行点 {points.Count} @ {releaseFile}", + "Signal", true); + + if (_started) + { + RestartWorkers(); + _scan?.ReplaceConfig(handshake, points); + } + } + + [MethodMember(Name = "停止进程")] + public new void Stop() + { + _started = false; + try { _scan?.Stop(); } + catch { } + _scan = null; + StopWorkers(); + status.status = "已停止"; + Diagnosis.Post("信号交互进程已停止", "Signal", true); + } + + [MethodMember(Name = "打开机构列表", Description = "按 PlcStationModel 增删改机构配置")] + public void OpenStations() + { + try + { + var path = SignalConfigStore.Resolve(StationsPath); + ModelListPanel.Open("PLC 机构配置", path, x => x?.JgName ?? ""); + } + catch (Exception ex) + { + CycleUiHelper.Alert("错误", $"打开机构列表失败:{ex.Message}"); + } + } + + [MethodMember(Name = "打开握手点列表", Description = "按站点配置 JGControl / PointControl / TimedCharging / RateFlowControl")] + public void OpenHandshakePoints() + { + try + { + var path = SignalConfigStore.Resolve(HandshakePointsPath); + ModelListPanel.Open( + "握手点配置", + path, + x => x == null ? "" : $"{x.Sit} {x.SignalType}"); + } + catch (Exception ex) + { + CycleUiHelper.Alert("错误", $"打开握手点列表失败:{ex.Message}"); + } + } + + [MethodMember(Name = "打开放行点列表", Description = "按 ReleasePointModel 增删改放行点")] + public void OpenReleasePoints() + { + try + { + var path = SignalConfigStore.Resolve(ReleasePointsPath); + ModelListPanel.Open("放行点配置", path, x => x == null ? "" : x.Sit.ToString()); + } + catch (Exception ex) + { + CycleUiHelper.Alert("错误", $"打开放行点列表失败:{ex.Message}"); + } + } + + internal IReadOnlyList Runtimes + { + get + { + lock (_workerLock) + return _workers.Select(w => w.Runtime).ToList(); + } + } + + private void RestartScan() + { + try { _scan?.Stop(); } + catch { } + + var handshake = SignalConfigStore.Load(SignalConfigStore.Resolve(HandshakePointsPath)); + var release = SignalConfigStore.Load(SignalConfigStore.Resolve(ReleasePointsPath)); + _scan = new CarScanWorker(() => Runtimes, TickMs); + _scan.ReplaceConfig(handshake, release); + _scan.Start(); + } + + private void RestartWorkers() + { + StopWorkers(); + var stationsFile = SignalConfigStore.Resolve(StationsPath); + var stations = SignalConfigStore.Load(stationsFile) + .Where(s => !string.IsNullOrWhiteSpace(s.JgName)) + .ToList(); + + var created = new List(); + lock (_workerLock) + { + foreach (var station in stations) + { + var port = station.Port > 0 ? station.Port : PlcPort; + var runtime = new PlcStationRuntime(station); + var session = new SiemensPlcSession(PlcVersion, station.Ip, port, PlcSlot); + var worker = new PlcStationWorker(runtime, session, PlcPollMs, RefreshStatus); + _workers.Add(worker); + created.Add(worker); + } + } + + foreach (var worker in created) + worker.Start(); + + PushStatus(); + } + + private void StopWorkers() + { + List copy; + lock (_workerLock) + { + copy = _workers.ToList(); + _workers.Clear(); + } + + foreach (var w in copy) + { + try { w.Stop(); } + catch { } + } + } + + private void RefreshStatus() + { + if (Interlocked.Increment(ref _statusTick) % 5 != 0) + return; + PushStatus(); + } + + private void PushStatus() + { + List snaps; + lock (_workerLock) + snaps = _workers.Select(w => w.Runtime.ToSnapshot()).ToList(); + + var st = (SignalInteractMissionStatus)status; + st.Stations = snaps; + st.ConnectedCount = snaps.Count(s => s.IsConnected); + st.StationCount = snaps.Count; + st.ScanTickCount = _scan?.TickCount ?? 0; + if (_started) + status.status = $"运行中 {st.ConnectedCount}/{st.StationCount} 已连接 扫车{_scan?.TickCount ?? 0}"; + } + } +} diff --git a/StandardScene.Signal/Model/HandshakePointModel.cs b/StandardScene.Signal/Model/HandshakePointModel.cs new file mode 100644 index 0000000..bcb5a00 --- /dev/null +++ b/StandardScene.Signal/Model/HandshakePointModel.cs @@ -0,0 +1,20 @@ +using System.ComponentModel; + +namespace StandardScene.Signal.Model +{ + /// + /// 握手点配置。对应老地图点上的 SignalStop(SignalType + SignalParams)。 + /// 站点 fields 若已填写同名键,优先用站点字段。 + /// + public class HandshakePointModel + { + [DisplayName("站点")] + public int Sit { get; set; } + + [DisplayName("信号类型")] + public string SignalType { get; set; } = "JGControl"; + + [DisplayName("信号参数")] + public string SignalParams { get; set; } = ""; + } +} diff --git a/StandardScene.Signal/Model/PlcStationModel.cs b/StandardScene.Signal/Model/PlcStationModel.cs new file mode 100644 index 0000000..762c57d --- /dev/null +++ b/StandardScene.Signal/Model/PlcStationModel.cs @@ -0,0 +1,43 @@ +using System.ComponentModel; + +namespace StandardScene.Signal.Model +{ + /// + /// PLC 机构配置。对应反编译 PLCModel 的可存档字段;读写运行态不进本类。 + /// + public class PlcStationModel + { + [DisplayName("机构名称")] + public string JgName { get; set; } = ""; + + [DisplayName("机构IP")] + public string Ip { get; set; } = "127.0.0.1"; + + [DisplayName("端口")] + public int Port { get; set; } = 102; + + [DisplayName("读地址")] + public string ReadDb { get; set; } = ""; + + [DisplayName("写地址")] + public string WriteDb { get; set; } = ""; + + [DisplayName("写心跳地址")] + public string HeartDb { get; set; } = ""; + + [DisplayName("机构进入地标")] + public int JgInPointId { get; set; } + + [DisplayName("机构进入包含地标")] + public string JgInContains { get; set; } = ""; + + [DisplayName("机构地标")] + public int JgPointId { get; set; } + + [DisplayName("机构包含地标")] + public string JgContains { get; set; } = ""; + + [DisplayName("上下机构")] + public SignalLineType LineType { get; set; } = SignalLineType.未知; + } +} diff --git a/StandardScene.Signal/Model/PlcStationSnapshot.cs b/StandardScene.Signal/Model/PlcStationSnapshot.cs new file mode 100644 index 0000000..32a7ea5 --- /dev/null +++ b/StandardScene.Signal/Model/PlcStationSnapshot.cs @@ -0,0 +1,19 @@ +using System.ComponentModel; + +namespace StandardScene.Signal.Model +{ + /// 单个机构运行态快照,供 MissionStatus / 监视面板展示。 + public class PlcStationSnapshot + { + [DisplayName("机构")] public string JgName { get; set; } = ""; + [DisplayName("连接")] public bool IsConnected { get; set; } + [DisplayName("读信号")] public string ReadSignal { get; set; } = ""; + [DisplayName("写信号")] public string WriteSignal { get; set; } = ""; + [DisplayName("回读写值")] public string ReadWriteSignal { get; set; } = ""; + [DisplayName("机构内车")] public int InAgvNo { get; set; } + [DisplayName("请求进入车")] public int RequestInAgvNo { get; set; } + [DisplayName("写入成功")] public bool WriteSuccess { get; set; } + [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 new file mode 100644 index 0000000..4a400ea --- /dev/null +++ b/StandardScene.Signal/Model/ReleasePointModel.cs @@ -0,0 +1,14 @@ +using System.ComponentModel; + +namespace StandardScene.Signal.Model +{ + /// 放行点配置。对应反编译 StartSignModel + public class ReleasePointModel + { + [DisplayName("放行站点")] + public int Sit { get; set; } + + [DisplayName("启用")] + public bool IsUse { get; set; } = true; + } +} diff --git a/StandardScene.Signal/Model/SignalEnums.cs b/StandardScene.Signal/Model/SignalEnums.cs new file mode 100644 index 0000000..b1bde67 --- /dev/null +++ b/StandardScene.Signal/Model/SignalEnums.cs @@ -0,0 +1,30 @@ +namespace StandardScene.Signal.Model +{ + /// PLC → 调度:读字节含义(与反编译 ENUM_READ_SIGNAL 对齐)。 + public enum SignalReadValue : byte + { + 无状态 = 0, + 允许进入 = 1, + 允许离开 = 2 + } + + /// 调度 → PLC:写字节含义(与反编译 ENUM_WRITE_SIGNAL 对齐)。 + public enum SignalWriteValue : byte + { + 无状态 = 0, + 进入中 = 1, + 到位 = 2, + 离开中 = 3, + 已离开 = 4 + } + + /// 机构类型(与反编译 ENUM_JGTYPE 对齐)。 + public enum SignalLineType : byte + { + 未知 = 0, + 上线机构 = 1, + 单牵引下线机构 = 2, + 双牵引下线机构 = 3, + 等待通行机构 = 4 + } +} diff --git a/StandardScene.Signal/Plc/PlcStationLogic.cs b/StandardScene.Signal/Plc/PlcStationLogic.cs new file mode 100644 index 0000000..c93d448 --- /dev/null +++ b/StandardScene.Signal/Plc/PlcStationLogic.cs @@ -0,0 +1,183 @@ +using System; +using System.Linq; +using SimpleCore.Library; +using StandardScene.Signal.Model; + +namespace StandardScene.Signal.Plc +{ + /// 读写字节转换与四套自动离站,对齐反编译 PLCManager。 + public static class PlcStationLogic + { + public static SignalReadValue ConvertRead(byte val) => val switch + { + 1 => SignalReadValue.允许进入, + 2 => SignalReadValue.允许离开, + _ => SignalReadValue.无状态 + }; + + public static SignalWriteValue ConvertWrite(byte val) => val switch + { + 1 => SignalWriteValue.进入中, + 2 => SignalWriteValue.到位, + 3 => SignalWriteValue.离开中, + 4 => SignalWriteValue.已离开, + _ => SignalWriteValue.无状态 + }; + + public static bool ContainsSite(string csv, int siteId) + { + if (string.IsNullOrWhiteSpace(csv) || siteId <= 0) + return false; + return csv.Split(new[] { ',', ';', ' ' }, StringSplitOptions.RemoveEmptyEntries) + .Any(s => int.TryParse(s.Trim(), out var n) && n == siteId); + } + + public static void AutoLeave(PlcStationRuntime rt) + { + if (rt?.Config == null) return; + switch (rt.Config.LineType) + { + case SignalLineType.上线机构: + AutoLeaveUpLine(rt); + break; + case SignalLineType.单牵引下线机构: + AutoLeaveDownLine(rt, pin2: false); + break; + case SignalLineType.双牵引下线机构: + AutoLeaveDownLine(rt, pin2: true); + break; + case SignalLineType.等待通行机构: + AutoLeaveWaitSit(rt); + break; + } + } + + private static void ClearLeft(PlcStationRuntime rt, bool fromReadback) + { + rt.WriteSignal = SignalWriteValue.无状态; + rt.InAgvNo = 0; + if (rt.RequestInAgvNo > 0) + { + Diagnosis.Log( + $"{rt.Config.JgName} {(fromReadback ? "回读已离开" : "写已离开成功")},清空请求进入车 {rt.RequestInAgvNo}", + "JGControl", true); + rt.RequestInAgvNo = 0; + } + else + Diagnosis.Log($"{rt.Config.JgName} 已离开,清空信号", "JGControl", true); + } + + private static void AutoLeaveUpLine(PlcStationRuntime rt) + { + if (rt.WriteSuccess && rt.WriteSignal == SignalWriteValue.已离开) + ClearLeft(rt, fromReadback: false); + if (rt.ReadWriteSignal == SignalWriteValue.已离开) + ClearLeft(rt, fromReadback: true); + + if (rt.WriteSuccess && rt.WriteSignal == SignalWriteValue.进入中) + { + var car = SignalCarAdapter.Find(rt.RequestInAgvNo); + if (car == null) return; + if (!ContainsSite(rt.Config.JgInContains, SignalCarAdapter.PositionId(car))) + { + rt.WriteSignal = SignalWriteValue.到位; + rt.InAgvNo = car.id; + rt.RequestInAgvNo = 0; + Diagnosis.Log($"AGV{car.id} 已到达机构 {rt.Config.JgName}", "JGControl", true); + } + } + else if (rt.WriteSuccess && rt.WriteSignal == SignalWriteValue.到位) + { + var car = SignalCarAdapter.Find(rt.InAgvNo); + if (car == null || SignalCarAdapter.IsRunning(car)) + rt.WriteSignal = SignalWriteValue.离开中; + } + else if (rt.WriteSuccess && rt.WriteSignal == SignalWriteValue.离开中) + { + var car = SignalCarAdapter.Find(rt.InAgvNo); + if (car == null || !ContainsSite(rt.Config.JgContains, SignalCarAdapter.PositionId(car))) + rt.WriteSignal = SignalWriteValue.已离开; + } + } + + private static void AutoLeaveDownLine(PlcStationRuntime rt, bool pin2) + { + if (rt.WriteSuccess && rt.WriteSignal == SignalWriteValue.已离开) + { + ClearLeft(rt, fromReadback: false); + return; + } + + if (rt.ReadWriteSignal == SignalWriteValue.已离开) + ClearLeft(rt, fromReadback: true); + + if (rt.InAgvNo == 0) return; + var car = SignalCarAdapter.Find(rt.InAgvNo); + if (car == null) return; + + var pos = SignalCarAdapter.PositionId(car); + if (rt.ReadSignal == SignalReadValue.允许离开 && pos == rt.Config.JgPointId) + { + if (rt.WriteSignal == SignalWriteValue.离开中) return; + var pinOk = SignalCarAdapter.IsActionFinish(car, "SetPin1Up") + && (!pin2 || SignalCarAdapter.IsActionFinish(car, "SetPin2Up")) + && SignalCarAdapter.IsActionFinish(car, "StopCharge"); + if (pinOk) + { + SignalCarAdapter.StopCharge(car); + SignalCarAdapter.Start(car); + rt.WriteSignal = SignalWriteValue.离开中; + Diagnosis.Log($"{rt.Config.JgName} 允许离开,通知 AGV{car.id} 启动,写离开中", "JGControl", true); + } + else + { + SignalCarAdapter.Control(car, "SetPin1Up"); + if (pin2) SignalCarAdapter.Control(car, "SetPin2Up"); + SignalCarAdapter.Control(car, "StopCharge"); + } + } + else if (!string.IsNullOrEmpty(rt.Config.JgContains) + && !ContainsSite(rt.Config.JgContains, pos) + && rt.ReadWriteSignal != SignalWriteValue.已离开) + { + rt.WriteSignal = SignalWriteValue.已离开; + } + } + + private static void AutoLeaveWaitSit(PlcStationRuntime rt) + { + if (rt.WriteSuccess && rt.WriteSignal == SignalWriteValue.已离开) + { + ClearLeft(rt, fromReadback: false); + return; + } + + if (rt.ReadWriteSignal == SignalWriteValue.已离开) + ClearLeft(rt, fromReadback: true); + + if (rt.InAgvNo == 0) return; + var car = SignalCarAdapter.Find(rt.InAgvNo); + if (car == null) return; + + var pos = SignalCarAdapter.PositionId(car); + if (rt.ReadSignal == SignalReadValue.允许离开 && pos == rt.Config.JgPointId) + { + if (rt.WriteSignal == SignalWriteValue.离开中) return; + if (SignalCarAdapter.IsActionFinish(car, "StopCharge")) + { + SignalCarAdapter.Start(car); + rt.WriteSignal = SignalWriteValue.离开中; + Diagnosis.Log($"{rt.Config.JgName} 允许离开,通知 AGV{car.id} 启动,写离开中", "JGControl", true); + } + else + SignalCarAdapter.Control(car, "StopCharge"); + } + else if (!string.IsNullOrEmpty(rt.Config.JgContains) + && !ContainsSite(rt.Config.JgContains, pos) + && rt.ReadWriteSignal != SignalWriteValue.已离开) + { + rt.WriteSignal = SignalWriteValue.已离开; + } + } + } +} diff --git a/StandardScene.Signal/Plc/PlcStationRuntime.cs b/StandardScene.Signal/Plc/PlcStationRuntime.cs new file mode 100644 index 0000000..cb7de53 --- /dev/null +++ b/StandardScene.Signal/Plc/PlcStationRuntime.cs @@ -0,0 +1,79 @@ +using System; +using SimpleCore.Library; +using StandardScene.Signal.Model; + +namespace StandardScene.Signal.Plc +{ + /// 机构运行态。写信号赋值会清 WriteSuccess,驱动 Worker 重写 PLC。 + public sealed class PlcStationRuntime + { + private SignalWriteValue _writeSignal; + private SignalReadValue _readSignal; + private SignalWriteValue _readWriteSignal; + + public PlcStationRuntime(PlcStationModel config) + { + Config = config ?? throw new ArgumentNullException(nameof(config)); + } + + public PlcStationModel Config { get; } + + /// 扫车线程与 PLC Worker 共用运行态,写入时加此锁。 + public object Sync { get; } = new object(); + + public SignalReadValue ReadSignal + { + get => _readSignal; + set + { + if (_readSignal != value) + Diagnosis.Log($"{Config.JgName} 读信号 {_readSignal} → {value}", "PLC", true); + _readSignal = value; + } + } + + public SignalWriteValue WriteSignal + { + get => _writeSignal; + set + { + if (_writeSignal != value) + Diagnosis.Log($"{Config.JgName} 写信号 {_writeSignal} → {value}", "PLC", true); + _writeSignal = value; + WriteSuccess = false; + } + } + + public SignalWriteValue ReadWriteSignal + { + get => _readWriteSignal; + set + { + if (_readWriteSignal != value) + Diagnosis.Log($"{Config.JgName} 回读写值 {_readWriteSignal} → {value}", "PLC", true); + _readWriteSignal = value; + } + } + + public int InAgvNo { get; set; } + public int RequestInAgvNo { get; set; } + public bool IsConnected { get; set; } + public bool WriteSuccess { get; set; } + public bool Heart { get; set; } + public string LastError { get; set; } = ""; + + public PlcStationSnapshot ToSnapshot() => new PlcStationSnapshot + { + JgName = Config.JgName, + IsConnected = IsConnected, + ReadSignal = ReadSignal.ToString(), + WriteSignal = WriteSignal.ToString(), + ReadWriteSignal = ReadWriteSignal.ToString(), + InAgvNo = InAgvNo, + RequestInAgvNo = RequestInAgvNo, + WriteSuccess = WriteSuccess, + Heart = Heart, + LastError = LastError ?? "" + }; + } +} diff --git a/StandardScene.Signal/Plc/PlcStationWorker.cs b/StandardScene.Signal/Plc/PlcStationWorker.cs new file mode 100644 index 0000000..53a8d0b --- /dev/null +++ b/StandardScene.Signal/Plc/PlcStationWorker.cs @@ -0,0 +1,155 @@ +using System; +using System.Threading; +using SimpleCore.Library; +using StandardScene.Signal.Model; + +namespace StandardScene.Signal.Plc +{ + /// + /// 单机构 200ms 循环:心跳、读允许进出、回读写值、按需写入、自动离站。 + /// 对齐反编译 PLCManager.ExcuteThreadMethod。 + /// + public sealed class PlcStationWorker : IDisposable + { + private readonly SiemensPlcSession _session; + private readonly int _pollMs; + private readonly Action _onTick; + private CancellationTokenSource _cts; + private Thread _thread; + private DateTime _lastFailLog = DateTime.MinValue; + + public PlcStationWorker(PlcStationRuntime runtime, SiemensPlcSession session, int pollMs, Action onTick = null) + { + Runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + _session = session ?? throw new ArgumentNullException(nameof(session)); + _pollMs = Math.Max(50, pollMs); + _onTick = onTick; + } + + public PlcStationRuntime Runtime { get; } + + public void Start() + { + if (_thread != null && _thread.IsAlive) return; + _cts = new CancellationTokenSource(); + _thread = new Thread(Loop) + { + IsBackground = true, + Name = $"PlcStation:{Runtime.Config.JgName}" + }; + _thread.Start(); + Diagnosis.Log($"{Runtime.Config.JgName} Worker 已启动 → {Runtime.Config.Ip}:{Runtime.Config.Port}", "PLC", true); + } + + public void Stop() + { + try { _cts?.Cancel(); } + catch { } + try { _thread?.Join(2000); } + catch { } + _thread = null; + _session.Close(); + Runtime.IsConnected = false; + Diagnosis.Log($"{Runtime.Config.JgName} Worker 已停止", "PLC", true); + } + + public void Dispose() => Stop(); + + private void Loop() + { + var token = _cts.Token; + while (!token.IsCancellationRequested) + { + try + { + lock (Runtime.Sync) + Tick(); + } + catch (Exception ex) + { + Runtime.LastError = ex.Message; + Runtime.IsConnected = false; + LogFail($"{Runtime.Config.JgName} 周期异常 {ex.Message}"); + } + + try { _onTick?.Invoke(); } + catch { } + + try { Thread.Sleep(_pollMs); } + catch (ThreadInterruptedException) { break; } + } + } + + internal void Tick() + { + var cfg = Runtime.Config; + Runtime.Heart = !Runtime.Heart; + + if (!string.IsNullOrWhiteSpace(cfg.HeartDb)) + { + if (_session.TryReadBool(cfg.HeartDb, out var bit)) + { + _session.TryWriteBool(cfg.HeartDb, !bit); + Thread.Sleep(20); + } + else + { + MarkDisconnected("心跳读写失败"); + return; + } + } + + if (!string.IsNullOrWhiteSpace(cfg.ReadDb)) + { + if (_session.TryReadByte(cfg.ReadDb, out var rb)) + Runtime.ReadSignal = PlcStationLogic.ConvertRead(rb); + else + { + MarkDisconnected("读地址失败"); + return; + } + } + + if (!string.IsNullOrWhiteSpace(cfg.WriteDb)) + { + if (_session.TryReadByte(cfg.WriteDb, out var wb)) + { + Runtime.ReadWriteSignal = PlcStationLogic.ConvertWrite(wb); + if (Runtime.ReadWriteSignal == Runtime.WriteSignal) + Runtime.WriteSuccess = true; + } + else + { + MarkDisconnected("写地址回读失败"); + return; + } + + if (Runtime.WriteSignal != Runtime.ReadWriteSignal && !Runtime.WriteSuccess) + { + Diagnosis.Log( + $"AGV→机构 {cfg.JgName} 写入【{Runtime.WriteSignal}】请求进入{Runtime.RequestInAgvNo} 到达{Runtime.InAgvNo}", + "JGControl", true); + _session.TryWriteByte(cfg.WriteDb, (byte)Runtime.WriteSignal); + } + } + + Runtime.IsConnected = true; + Runtime.LastError = ""; + PlcStationLogic.AutoLeave(Runtime); + } + + private void MarkDisconnected(string reason) + { + Runtime.IsConnected = false; + Runtime.LastError = reason; + LogFail($"{Runtime.Config.JgName} {reason}"); + } + + private void LogFail(string msg) + { + if ((DateTime.Now - _lastFailLog).TotalSeconds < 5) return; + _lastFailLog = DateTime.Now; + Diagnosis.Log(msg, "PLC", true); + } + } +} diff --git a/StandardScene.Signal/Plc/SiemensPlcSession.cs b/StandardScene.Signal/Plc/SiemensPlcSession.cs new file mode 100644 index 0000000..136faeb --- /dev/null +++ b/StandardScene.Signal/Plc/SiemensPlcSession.cs @@ -0,0 +1,152 @@ +using System; +using System.Text.RegularExpressions; +using IoTClient.Clients.PLC; +using IoTClient.Common.Enums; + +namespace StandardScene.Signal.Plc +{ + /// 西门子 S7 长连接。地址兼容 Hsl 的 DB1.0 与 IoTClient 的 DB1.DBB0。 + public sealed class SiemensPlcSession : IDisposable + { + private static readonly Regex BareDbByte = new Regex(@"^DB(\d+)\.(\d+)$", RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private readonly SiemensVersion _version; + private readonly string _ip; + private readonly int _port; + private readonly byte _slot; + private SiemensClient _client; + private readonly object _sync = new object(); + + public SiemensPlcSession(string version, string ip, int port, int slot) + { + _version = ParseVersion(version); + _ip = ip ?? ""; + _port = port > 0 ? port : 102; + _slot = (byte)Math.Max(0, slot); + } + + public bool Connected + { + get + { + lock (_sync) return _client?.Connected == true; + } + } + + public bool EnsureOpen() + { + lock (_sync) + { + try + { + if (_client == null) + _client = new SiemensClient(_version, _ip, _port, _slot); + if (_client.Connected) + return true; + var r = _client.Open(); + return r != null && r.IsSucceed; + } + catch + { + return false; + } + } + } + + public bool TryReadByte(string address, out byte value) + { + value = 0; + if (string.IsNullOrWhiteSpace(address) || !EnsureOpen()) + return false; + try + { + var r = _client.ReadByte(NormalizeByteAddress(address)); + if (r == null || !r.IsSucceed) + return false; + value = r.Value; + return true; + } + catch + { + return false; + } + } + + public bool TryWriteByte(string address, byte value) + { + if (string.IsNullOrWhiteSpace(address) || !EnsureOpen()) + return false; + try + { + var r = _client.Write(NormalizeByteAddress(address), value); + return r != null && r.IsSucceed; + } + catch + { + return false; + } + } + + public bool TryReadBool(string address, out bool value) + { + value = false; + if (string.IsNullOrWhiteSpace(address) || !EnsureOpen()) + return false; + try + { + var r = _client.ReadBoolean(address.Trim()); + if (r == null || !r.IsSucceed) + return false; + value = r.Value; + return true; + } + catch + { + return false; + } + } + + public bool TryWriteBool(string address, bool value) + { + if (string.IsNullOrWhiteSpace(address) || !EnsureOpen()) + return false; + try + { + var r = _client.Write(address.Trim(), value); + return r != null && r.IsSucceed; + } + catch + { + return false; + } + } + + public void Close() + { + lock (_sync) + { + try { _client?.Close(); } + catch { } + _client = null; + } + } + + public void Dispose() => Close(); + + internal static string NormalizeByteAddress(string address) + { + var addr = address.Trim(); + var m = BareDbByte.Match(addr); + return m.Success ? $"DB{m.Groups[1].Value}.DBB{m.Groups[2].Value}" : addr; + } + + internal static SiemensVersion ParseVersion(string version) + { + if (string.IsNullOrWhiteSpace(version)) + return SiemensVersion.S7_1500; + return Enum.TryParse(version.Replace("-", "_"), true, out SiemensVersion parsed) + ? parsed + : SiemensVersion.S7_1500; + } + } +} diff --git a/StandardScene.Signal/Plc/SignalCarAdapter.cs b/StandardScene.Signal/Plc/SignalCarAdapter.cs new file mode 100644 index 0000000..31b456e --- /dev/null +++ b/StandardScene.Signal/Plc/SignalCarAdapter.cs @@ -0,0 +1,264 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Reflection; +using SimpleCore; +using SimpleCore.PropType; +using SimpleLite.RCS; +using SimpleLite.RCS.CarTypes; +using StandardScene.Signal.Site; + +namespace StandardScene.Signal.Plc +{ + /// + /// 不引用 Magnetic.dll:按方法名反射 Start/Stop/牵引棒。 + /// MagCar / Mag2Car 有对应 MethodMember 时生效;没有则视为动作已完成。 + /// + public static class SignalCarAdapter + { + public static IEnumerable All() + { + try + { + return SimpleLib.GetAllCars().OfType(); + } + catch + { + return Array.Empty(); + } + } + + public static Car Find(int id) + { + if (id <= 0) return null; + return SimpleLib.GetCar(id) as Car; + } + + public static int PositionId(Car car) + { + if (car == null) return 0; + try + { + if (car.siteID > 0) return car.siteID; + var last = car.GetLastSite(); + return last > 0 ? last : 0; + } + catch + { + return 0; + } + } + + public static string GetState(Car car) + { + if (car?.status?.enums != null && + car.status.enums.TryGetValue("State", out var state) && + !string.IsNullOrWhiteSpace(state)) + return state; + return ""; + } + + public static bool IsMoving(Car car) + { + var state = GetState(car); + return state == "运行中" || + string.Equals(state, "Running", StringComparison.OrdinalIgnoreCase); + } + + /// 对齐反编译 RunStatus==停止:非运行中即视为停稳,含充电中。 + public static bool IsStopped(Car car) + { + if (car == null) return false; + if (IsMoving(car)) return false; + var state = GetState(car); + if (state == "停止中" || state == "充电中" || + string.Equals(state, "Stopping", StringComparison.OrdinalIgnoreCase) || + string.Equals(state, "Charging", StringComparison.OrdinalIgnoreCase) || + string.Equals(state, "WaitPass", StringComparison.OrdinalIgnoreCase)) + return true; + return !IsRunning(car); + } + + public static bool IsCharging(Car car) + { + var state = GetState(car); + return state == "充电中" || + string.Equals(state, "Charging", StringComparison.OrdinalIgnoreCase); + } + + public static bool IsRunning(Car car) + { + if (car == null) return false; + if (IsMoving(car)) return true; + if (car.status?.pendingLocks != null && car.status.pendingLocks.Length > 0) + return true; + if (car.tags != null && car.tags.Contains("occupied")) + return true; + return false; + } + + public static double Soc(Car car) + { + if (car?.status?.enums != null && + car.status.enums.TryGetValue("Soc", out var soc) && + double.TryParse(soc, NumberStyles.Float, CultureInfo.InvariantCulture, out var value)) + return value; + return 0; + } + + public static string GroupId(Car car) + { + if (car?.fields == null) return ""; + if (car.fields.TryGetValue("group", out var g) && !string.IsNullOrWhiteSpace(g)) + return g; + if (car.fields.TryGetValue("GroupID", out var g2) && !string.IsNullOrWhiteSpace(g2)) + return g2; + return ""; + } + + public static IReadOnlyList CarsAt(IEnumerable siteIds) + { + var set = new HashSet(siteIds ?? Array.Empty()); + if (set.Count == 0) + return Array.Empty(); + return All().Where(c => set.Contains(PositionId(c))).ToList(); + } + + public static IReadOnlyList CarsAt(int siteId) + { + if (siteId <= 0) return Array.Empty(); + return CarsAt(new[] { siteId }); + } + + public static bool TryReadSiteField(int siteId, string key, out string value) + { + value = ""; + if (siteId <= 0 || string.IsNullOrWhiteSpace(key)) + return false; + try + { + var site = SimpleLib.GetSite(siteId); + if (site?.fields == null) + return false; + if (!site.fields.TryGetValue(key, out var raw) || string.IsNullOrWhiteSpace(raw)) + return false; + value = raw; + return true; + } + catch + { + return false; + } + } + + public static bool TryReadSignalStop(int siteId, out string signalType, out string signalParams) + { + signalType = ""; + signalParams = ""; + if (!TryReadSiteField(siteId, SignalSiteFields.SignalType, out signalType)) + return false; + TryReadSiteField(siteId, SignalSiteFields.SignalParams, out signalParams); + return true; + } + + public static int ChargeSeconds(Car car, IDictionary startedAt) + { + if (car == null) return 0; + var mi = car.GetType().GetMethod("GetChargeTime", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (mi != null) + { + try + { + var r = mi.Invoke(car, null); + if (r is int i) return i; + if (r is double d) return (int)d; + if (r is TimeSpan ts) return (int)ts.TotalSeconds; + } + catch + { + } + } + + if (IsCharging(car)) + { + if (!startedAt.ContainsKey(car.id)) + startedAt[car.id] = DateTime.Now; + return (int)(DateTime.Now - startedAt[car.id]).TotalSeconds; + } + + startedAt.Remove(car.id); + return 0; + } + + public static void Start(Car car) + { + if (car == null) return; + if (TryInvoke(car, "StartMag2Car") || TryInvoke(car, "StartMagCar")) + return; + TryInvoke(car, "SendStart", new object[] { (ushort)0 }); + } + + public static void Stop(Car car) + { + if (car == null) return; + if (TryInvoke(car, "StopMag2Car") || TryInvoke(car, "StopMagCar")) + return; + TryInvoke(car, "SendStop", new object[] { (ushort)0 }); + } + + public static void StopCharge(Car car) + { + if (car == null) return; + TryInvoke(car, "StopCharge"); + } + + public static bool IsActionFinish(Car car, string action) + { + if (car == null) return false; + if (string.IsNullOrWhiteSpace(action)) return true; + var mi = car.GetType().GetMethod("IsActionFinish", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (mi == null) + return true; + try + { + var r = mi.Invoke(car, new object[] { action }); + return r is true; + } + catch + { + return true; + } + } + + public static void Control(Car car, string action) + { + if (car == null || string.IsNullOrWhiteSpace(action)) return; + if (TryInvoke(car, action)) return; + TryInvoke(car, "ImmediateCommand", new object[] { action }); + } + + private static bool TryInvoke(object target, string name, object[] args = null) + { + var type = target.GetType(); + var methods = type.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .Where(m => string.Equals(m.Name, name, StringComparison.Ordinal)); + foreach (var mi in methods) + { + var ps = mi.GetParameters(); + if (args == null) + { + if (ps.Length != 0) continue; + try { mi.Invoke(target, null); return true; } + catch { return false; } + } + + if (ps.Length != args.Length) continue; + try { mi.Invoke(target, args); return true; } + catch { return false; } + } + + return false; + } + } +} diff --git a/StandardScene.Signal/SignalConfigStore.cs b/StandardScene.Signal/SignalConfigStore.cs new file mode 100644 index 0000000..91ef077 --- /dev/null +++ b/StandardScene.Signal/SignalConfigStore.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace StandardScene.Signal +{ + /// 信号插件 JSON 配置的路径解析、种子拷贝与读写。 + public static class SignalConfigStore + { + private static readonly object FileLock = new object(); + + private static readonly JsonSerializerSettings JsonSettings = new JsonSerializerSettings + { + Formatting = Formatting.Indented, + NullValueHandling = NullValueHandling.Ignore, + Converters = { new StringEnumConverter() } + }; + + /// + /// 相对路径依次尝试:工作目录、插件目录旁的 Config。 + /// 工作目录文件不存在时,若插件目录有样例则拷过去。 + /// + public static string Resolve(string configuredPath) + { + if (string.IsNullOrWhiteSpace(configuredPath)) + configuredPath = Path.Combine("Config", "Signal", "stations.json"); + + 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; + } + + public static List Load(string path) where T : class + { + try + { + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + return new List(); + var json = File.ReadAllText(path); + if (string.IsNullOrWhiteSpace(json)) + return new List(); + return JsonConvert.DeserializeObject>(json, JsonSettings) ?? new List(); + } + catch + { + return new List(); + } + } + + public static void Save(string path, IReadOnlyList items) + { + var json = JsonConvert.SerializeObject(items ?? Array.Empty(), JsonSettings); + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir)) + Directory.CreateDirectory(dir); + lock (FileLock) + File.WriteAllText(path, json); + } + } +} diff --git a/StandardScene.Signal/Site/SignalSiteFields.cs b/StandardScene.Signal/Site/SignalSiteFields.cs new file mode 100644 index 0000000..81bc40f --- /dev/null +++ b/StandardScene.Signal/Site/SignalSiteFields.cs @@ -0,0 +1,12 @@ +namespace StandardScene.Signal.Site +{ + /// + /// 站点 fields 键名,替代老地图 SignalStop。 + /// 地图编辑器若未挂字段袋,仍可手工写入这两个键;未写则回退 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 new file mode 100644 index 0000000..71946da --- /dev/null +++ b/StandardScene.Signal/StandardScene.Signal.csproj @@ -0,0 +1,57 @@ + + + + net8.0-windows + Library + StandardScene.Signal + StandardScene.Signal + latest + true + AnyCPU;x64 + x64 + true + false + disable + disable + false + $(NoWarn);NU1701;CS0618;CS0612;MSB3277;CA1416 + {HintPathFromItem};{TargetFrameworkDirectory};{RawFileName};{GAC} + + + + + + + + + + + + + + $(StandRefDir)CommonUsage.dll + + + $(StandRefDir)CycleGUI.dll + false + + + $(StandRefDir)LessokajiWeaverUtilities.dll + + + $(StandRefDir)SimpleCore.dll + + + $(StandSimpleLiteDir)SimpleLite.dll + + + $(StandRefDir)Topaz.dll + + + + + + + + + diff --git a/StandardScene.Signal/StandardScene.Signal.scene.json b/StandardScene.Signal/StandardScene.Signal.scene.json new file mode 100644 index 0000000..38cb979 --- /dev/null +++ b/StandardScene.Signal/StandardScene.Signal.scene.json @@ -0,0 +1,10 @@ +{ + "id": "scene.signal", + "displayName": "第三方信号交互", + "assembly": "StandardScene.Signal.dll", + "coreVersion": ">=1.0.0", + "requiresCore": "StandardScene.dll", + "provides": { + "missionTypes": [ "SignalInteractMission" ] + } +} diff --git a/StandardScene.Signal/Ui/ModelListPanel.cs b/StandardScene.Signal/Ui/ModelListPanel.cs new file mode 100644 index 0000000..7fc5436 --- /dev/null +++ b/StandardScene.Signal/Ui/ModelListPanel.cs @@ -0,0 +1,349 @@ +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 与 。 + /// + public static class ModelListPanel where T : class, new() + { + 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; + + public static void Open(string title, string path, Func keySelector) + { + 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) + .InitSize(1100, 720) + .InitPos(false, 0, 0, 0.5f, 0.5f, 0.5f, 0.5f); + _panel = panel; + panel.IfTerminalQuit(() => { if (_panel == panel) _panel = null; }); + + panel.Define(pb => + { + 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(); + }); + } + } +} diff --git a/StandardScene.sln b/StandardScene.sln index 50d1de0..edbff4b 100644 --- a/StandardScene.sln +++ b/StandardScene.sln @@ -17,6 +17,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandardScene.Fass2Simulato EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandardScene.Magnetic.Tests", "StandardScene.Magnetic.Tests\StandardScene.Magnetic.Tests.csproj", "{7C13685A-D666-4404-8CD7-19AA9BFC907B}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandardScene.Signal", "StandardScene.Signal\StandardScene.Signal.csproj", "{7E5050AA-0009-4A11-9C22-0A0B0C0D0E09}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -111,6 +113,18 @@ Global {7C13685A-D666-4404-8CD7-19AA9BFC907B}.Release|x64.Build.0 = Release|Any CPU {7C13685A-D666-4404-8CD7-19AA9BFC907B}.Release|x86.ActiveCfg = Release|Any CPU {7C13685A-D666-4404-8CD7-19AA9BFC907B}.Release|x86.Build.0 = Release|Any CPU + {7E5050AA-0009-4A11-9C22-0A0B0C0D0E09}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7E5050AA-0009-4A11-9C22-0A0B0C0D0E09}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7E5050AA-0009-4A11-9C22-0A0B0C0D0E09}.Debug|x64.ActiveCfg = Debug|Any CPU + {7E5050AA-0009-4A11-9C22-0A0B0C0D0E09}.Debug|x64.Build.0 = Debug|Any CPU + {7E5050AA-0009-4A11-9C22-0A0B0C0D0E09}.Debug|x86.ActiveCfg = Debug|Any CPU + {7E5050AA-0009-4A11-9C22-0A0B0C0D0E09}.Debug|x86.Build.0 = Debug|Any CPU + {7E5050AA-0009-4A11-9C22-0A0B0C0D0E09}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7E5050AA-0009-4A11-9C22-0A0B0C0D0E09}.Release|Any CPU.Build.0 = Release|Any CPU + {7E5050AA-0009-4A11-9C22-0A0B0C0D0E09}.Release|x64.ActiveCfg = Release|Any CPU + {7E5050AA-0009-4A11-9C22-0A0B0C0D0E09}.Release|x64.Build.0 = Release|Any CPU + {7E5050AA-0009-4A11-9C22-0A0B0C0D0E09}.Release|x86.ActiveCfg = Release|Any CPU + {7E5050AA-0009-4A11-9C22-0A0B0C0D0E09}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE