Cursor/add scene signal plugin #3

Merged
zhaowei.huang merged 3 commits from cursor/add-scene-signal-plugin into main 2026-08-24 15:18:48 +08:00
29 changed files with 2425 additions and 1 deletions
Showing only changes of commit 12c7c1d518 - Show all commits
+14 -1
View File
@@ -1,7 +1,7 @@
<Project>
<!--
StandardScene 插件工程编译后,自动将产物复制到 build\plugins\SimpleComposer / SimpleLite 宿主加载目录)。
适用:StandardScene.Core + Magnetic / QrLidar / Devices / Protocol.VDA5050 等卫星插件。
适用:StandardScene.Core + Magnetic / QrLidar / Devices / Protocol.VDA5050 / Signal 等卫星插件。
-->
<PropertyGroup>
<StandardSceneRepoRoot>$(MSBuildThisFileDirectory)</StandardSceneRepoRoot>
@@ -12,6 +12,7 @@
<IsStandardScenePluginCopy Condition="'$(MSBuildProjectName)' == 'StandardScene.QrLidar'">true</IsStandardScenePluginCopy>
<IsStandardScenePluginCopy Condition="'$(MSBuildProjectName)' == 'StandardScene.Devices'">true</IsStandardScenePluginCopy>
<IsStandardScenePluginCopy Condition="'$(MSBuildProjectName)' == 'StandardScene.Protocol.VDA5050'">true</IsStandardScenePluginCopy>
<IsStandardScenePluginCopy Condition="'$(MSBuildProjectName)' == 'StandardScene.Signal'">true</IsStandardScenePluginCopy>
</PropertyGroup>
<Target Name="CopyStandardScenePluginOutputs"
@@ -28,12 +29,24 @@
<!-- Devices 插件运行时依赖 -->
<StandardScenePluginCopyFile Include="$(TargetDir)leegKeys-sdk.dll"
Condition="Exists('$(TargetDir)leegKeys-sdk.dll')" />
<StandardScenePluginCopyFile Include="$(TargetDir)IoTClient.dll"
Condition="Exists('$(TargetDir)IoTClient.dll')" />
</ItemGroup>
<Copy SourceFiles="@(StandardScenePluginCopyFile)"
DestinationFolder="$(StandardScenePluginsDir)"
SkipUnchangedFiles="true" />
<ItemGroup Condition="'$(MSBuildProjectName)' == 'StandardScene.Signal'">
<StandardSceneSignalConfig Include="$(TargetDir)Config\Signal\*.json" />
</ItemGroup>
<MakeDir Directories="$(StandardScenePluginsDir)\Config\Signal"
Condition="'@(StandardSceneSignalConfig)' != ''" />
<Copy SourceFiles="@(StandardSceneSignalConfig)"
DestinationFolder="$(StandardScenePluginsDir)\Config\Signal"
SkipUnchangedFiles="true"
Condition="'@(StandardSceneSignalConfig)' != ''" />
<Message Importance="high"
Text="[StandardScene] 已复制 $(MSBuildProjectName) 插件产物到 $(StandardScenePluginsDir)" />
</Target>
@@ -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")]
@@ -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"
}
]
@@ -0,0 +1,10 @@
[
{
"Sit": 10,
"IsUse": true
},
{
"Sit": 20,
"IsUse": false
}
]
@@ -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": "单牵引下线机构"
}
]
+183
View File
@@ -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
{
/// <summary>1s 扫全场车,对齐反编译 DefaultPlugin.ExcuteThreadMethod + StarSignManage。</summary>
public sealed class CarScanWorker : IDisposable
{
private readonly Func<IReadOnlyList<PlcStationRuntime>> _runtimes;
private readonly int _tickMs;
private readonly object _configLock = new object();
private Dictionary<int, HandshakePointModel> _handshake = new Dictionary<int, HandshakePointModel>();
private Dictionary<int, ReleasePointModel> _release = new Dictionary<int, ReleasePointModel>();
private readonly Dictionary<string, int> _rateFlowLog = new Dictionary<string, int>();
private readonly Dictionary<int, DateTime> _chargeStarted = new Dictionary<int, DateTime>();
private readonly Dictionary<int, DateTime> _unknownTypeLog = new Dictionary<int, DateTime>();
private CancellationTokenSource _cts;
private Thread _thread;
private int _tickCount;
public CarScanWorker(Func<IReadOnlyList<PlcStationRuntime>> runtimes, int tickMs)
{
_runtimes = runtimes ?? throw new ArgumentNullException(nameof(runtimes));
_tickMs = Math.Max(100, tickMs);
}
public int TickCount => _tickCount;
public void ReplaceConfig(IEnumerable<HandshakePointModel> handshake, IEnumerable<ReleasePointModel> release)
{
lock (_configLock)
{
_handshake = (handshake ?? Array.Empty<HandshakePointModel>())
.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<ReleasePointModel>())
.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<int, HandshakePointModel> handshake;
Dictionary<int, ReleasePointModel> release;
lock (_configLock)
{
handshake = _handshake;
release = _release;
}
PlcStationRuntime Find(string name)
{
if (string.IsNullOrWhiteSpace(name))
return null;
var list = _runtimes() ?? Array.Empty<PlcStationRuntime>();
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<int, HandshakePointModel> 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<string, PlcStationRuntime> 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);
}
}
}
@@ -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
{
/// <summary>对齐反编译 DefaultPlugin.JGControl / JGJudge / RequestIn / Arrived。</summary>
public static class JgControlHandler
{
public static void OnStopped(Car car, SignalStopSpec spec, Func<string, PlcStationRuntime> find, IDictionary<string, int> 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<string, PlcStationRuntime> 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<string, PlcStationRuntime> 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<string, PlcStationRuntime> 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<string, PlcStationRuntime> 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;
}
}
}
}
@@ -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
{
/// <summary>对齐反编译 DefaultPlugin.PointControl:牵引棒升/降到位后再放行。</summary>
public static class PointControlHandler
{
public static void OnStopped(Car car, SignalStopSpec spec, Func<string, PlcStationRuntime> 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);
}
}
}
@@ -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
{
/// <summary>
/// 对齐反编译 DefaultPlugin.RateFlowControl。
/// 独立类型用分号:区域;车组;限额;点1,点2。JGControl 第三段用短横:区域-车组-限额-点1,点2。
/// </summary>
public static class RateFlowHandler
{
public static void OnStopped(Car car, SignalStopSpec spec, IDictionary<string, int> logByArea)
{
if (AllowPass(car, spec.SignalParams, logByArea, sep: ';'))
{
SignalCarAdapter.StopCharge(car);
SignalCarAdapter.Start(car);
}
}
public static bool AllowPass(Car car, string rateFlowString, IDictionary<string, int> logByArea) =>
AllowPass(car, rateFlowString, logByArea, sep: '-');
private static bool AllowPass(Car car, string raw, IDictionary<string, int> 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<int>();
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<string, int> 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);
}
}
}
@@ -0,0 +1,19 @@
using System.Collections.Generic;
using SimpleLite.RCS.CarTypes;
using StandardScene.Signal.Model;
using StandardScene.Signal.Plc;
namespace StandardScene.Signal.Logic
{
/// <summary>对齐反编译 StarSignManage:配置点停稳则停充并放行。</summary>
public static class ReleasePointHandler
{
public static void OnStopped(Car car, int sit, IReadOnlyDictionary<int, ReleasePointModel> points)
{
if (points == null || !points.TryGetValue(sit, out var p) || p == null || !p.IsUse)
return;
SignalCarAdapter.StopCharge(car);
SignalCarAdapter.Start(car);
}
}
}
@@ -0,0 +1,42 @@
using System;
using StandardScene.Signal.Model;
namespace StandardScene.Signal.Logic
{
/// <summary>握手点解析结果。参数格式与反编译 SignalStop.SignalParams 一致。</summary>
public sealed class SignalStopSpec
{
public string SignalType { get; init; } = "";
public string SignalParams { get; init; } = "";
public string[] Parts { get; init; } = Array.Empty<string>();
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);
}
}
@@ -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
{
/// <summary>对齐反编译 DefaultPlugin.TimedCharging:时长 / 上限电量 / 下限电量。</summary>
public static class TimedChargeHandler
{
public static void OnStopped(Car car, SignalStopSpec spec, IDictionary<int, DateTime> 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);
}
}
}
}
@@ -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
{
/// <summary>
/// 第三方信号交互进程:PLC Worker(200ms+ 扫车握手(1s)。
/// </summary>
[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<PlcStationWorker> _workers = new List<PlcStationWorker>();
[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<PlcStationSnapshot> Stations { get; set; } = new List<PlcStationSnapshot>();
}
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<PlcStationModel>(stationsFile);
var points = SignalConfigStore.Load<ReleasePointModel>(releaseFile);
var handshake = SignalConfigStore.Load<HandshakePointModel>(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<PlcStationModel>.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<HandshakePointModel>.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<ReleasePointModel>.Open("放行点配置", path, x => x == null ? "" : x.Sit.ToString());
}
catch (Exception ex)
{
CycleUiHelper.Alert("错误", $"打开放行点列表失败:{ex.Message}");
}
}
internal IReadOnlyList<PlcStationRuntime> Runtimes
{
get
{
lock (_workerLock)
return _workers.Select(w => w.Runtime).ToList();
}
}
private void RestartScan()
{
try { _scan?.Stop(); }
catch { }
var handshake = SignalConfigStore.Load<HandshakePointModel>(SignalConfigStore.Resolve(HandshakePointsPath));
var release = SignalConfigStore.Load<ReleasePointModel>(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<PlcStationModel>(stationsFile)
.Where(s => !string.IsNullOrWhiteSpace(s.JgName))
.ToList();
var created = new List<PlcStationWorker>();
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<PlcStationWorker> 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<PlcStationSnapshot> 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}";
}
}
}
@@ -0,0 +1,20 @@
using System.ComponentModel;
namespace StandardScene.Signal.Model
{
/// <summary>
/// 握手点配置。对应老地图点上的 <c>SignalStop</c>SignalType + SignalParams)。
/// 站点 fields 若已填写同名键,优先用站点字段。
/// </summary>
public class HandshakePointModel
{
[DisplayName("站点")]
public int Sit { get; set; }
[DisplayName("信号类型")]
public string SignalType { get; set; } = "JGControl";
[DisplayName("信号参数")]
public string SignalParams { get; set; } = "";
}
}
@@ -0,0 +1,43 @@
using System.ComponentModel;
namespace StandardScene.Signal.Model
{
/// <summary>
/// PLC 机构配置。对应反编译 <c>PLCModel</c> 的可存档字段;读写运行态不进本类。
/// </summary>
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.;
}
}
@@ -0,0 +1,19 @@
using System.ComponentModel;
namespace StandardScene.Signal.Model
{
/// <summary>单个机构运行态快照,供 MissionStatus / 监视面板展示。</summary>
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; } = "";
}
}
@@ -0,0 +1,14 @@
using System.ComponentModel;
namespace StandardScene.Signal.Model
{
/// <summary>放行点配置。对应反编译 <c>StartSignModel</c>。</summary>
public class ReleasePointModel
{
[DisplayName("放行站点")]
public int Sit { get; set; }
[DisplayName("启用")]
public bool IsUse { get; set; } = true;
}
}
+30
View File
@@ -0,0 +1,30 @@
namespace StandardScene.Signal.Model
{
/// <summary>PLC → 调度:读字节含义(与反编译 ENUM_READ_SIGNAL 对齐)。</summary>
public enum SignalReadValue : byte
{
= 0,
= 1,
= 2
}
/// <summary>调度 → PLC:写字节含义(与反编译 ENUM_WRITE_SIGNAL 对齐)。</summary>
public enum SignalWriteValue : byte
{
= 0,
= 1,
= 2,
= 3,
= 4
}
/// <summary>机构类型(与反编译 ENUM_JGTYPE 对齐)。</summary>
public enum SignalLineType : byte
{
= 0,
线 = 1,
线 = 2,
线 = 3,
= 4
}
}
+183
View File
@@ -0,0 +1,183 @@
using System;
using System.Linq;
using SimpleCore.Library;
using StandardScene.Signal.Model;
namespace StandardScene.Signal.Plc
{
/// <summary>读写字节转换与四套自动离站,对齐反编译 PLCManager。</summary>
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.;
}
}
}
}
@@ -0,0 +1,79 @@
using System;
using SimpleCore.Library;
using StandardScene.Signal.Model;
namespace StandardScene.Signal.Plc
{
/// <summary>机构运行态。写信号赋值会清 WriteSuccess,驱动 Worker 重写 PLC。</summary>
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; }
/// <summary>扫车线程与 PLC Worker 共用运行态,写入时加此锁。</summary>
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 ?? ""
};
}
}
@@ -0,0 +1,155 @@
using System;
using System.Threading;
using SimpleCore.Library;
using StandardScene.Signal.Model;
namespace StandardScene.Signal.Plc
{
/// <summary>
/// 单机构 200ms 循环:心跳、读允许进出、回读写值、按需写入、自动离站。
/// 对齐反编译 <c>PLCManager.ExcuteThreadMethod</c>。
/// </summary>
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);
}
}
}
@@ -0,0 +1,152 @@
using System;
using System.Text.RegularExpressions;
using IoTClient.Clients.PLC;
using IoTClient.Common.Enums;
namespace StandardScene.Signal.Plc
{
/// <summary>西门子 S7 长连接。地址兼容 Hsl 的 DB1.0 与 IoTClient 的 DB1.DBB0。</summary>
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;
}
}
}
@@ -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
{
/// <summary>
/// 不引用 Magnetic.dll:按方法名反射 Start/Stop/牵引棒。
/// MagCar / Mag2Car 有对应 MethodMember 时生效;没有则视为动作已完成。
/// </summary>
public static class SignalCarAdapter
{
public static IEnumerable<Car> All()
{
try
{
return SimpleLib.GetAllCars().OfType<Car>();
}
catch
{
return Array.Empty<Car>();
}
}
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);
}
/// <summary>对齐反编译 RunStatus==停止:非运行中即视为停稳,含充电中。</summary>
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<Car> CarsAt(IEnumerable<int> siteIds)
{
var set = new HashSet<int>(siteIds ?? Array.Empty<int>());
if (set.Count == 0)
return Array.Empty<Car>();
return All().Where(c => set.Contains(PositionId(c))).ToList();
}
public static IReadOnlyList<Car> CarsAt(int siteId)
{
if (siteId <= 0) return Array.Empty<Car>();
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<int, DateTime> 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;
}
}
}
+85
View File
@@ -0,0 +1,85 @@
using System;
using System.Collections.Generic;
using System.IO;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace StandardScene.Signal
{
/// <summary>信号插件 JSON 配置的路径解析、种子拷贝与读写。</summary>
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() }
};
/// <summary>
/// 相对路径依次尝试:工作目录、插件目录旁的 Config。
/// 工作目录文件不存在时,若插件目录有样例则拷过去。
/// </summary>
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<T> Load<T>(string path) where T : class
{
try
{
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
return new List<T>();
var json = File.ReadAllText(path);
if (string.IsNullOrWhiteSpace(json))
return new List<T>();
return JsonConvert.DeserializeObject<List<T>>(json, JsonSettings) ?? new List<T>();
}
catch
{
return new List<T>();
}
}
public static void Save<T>(string path, IReadOnlyList<T> items)
{
var json = JsonConvert.SerializeObject(items ?? Array.Empty<T>(), JsonSettings);
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
lock (FileLock)
File.WriteAllText(path, json);
}
}
}
@@ -0,0 +1,12 @@
namespace StandardScene.Signal.Site
{
/// <summary>
/// 站点 fields 键名,替代老地图 <c>SignalStop</c>。
/// 地图编辑器若未挂字段袋,仍可手工写入这两个键;未写则回退 handshake-points.json。
/// </summary>
public static class SignalSiteFields
{
public const string SignalType = "SignalType";
public const string SignalParams = "SignalParams";
}
}
@@ -0,0 +1,57 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<OutputType>Library</OutputType>
<RootNamespace>StandardScene.Signal</RootNamespace>
<AssemblyName>StandardScene.Signal</AssemblyName>
<LangVersion>latest</LangVersion>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Platforms>AnyCPU;x64</Platforms>
<PlatformTarget>x64</PlatformTarget>
<Deterministic>true</Deterministic>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>disable</Nullable>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<NoWarn>$(NoWarn);NU1701;CS0618;CS0612;MSB3277;CA1416</NoWarn>
<AssemblySearchPaths>{HintPathFromItem};{TargetFrameworkDirectory};{RawFileName};{GAC}</AssemblySearchPaths>
</PropertyGroup>
<ItemGroup>
<None Update="StandardScene.Signal.scene.json" CopyToOutputDirectory="PreserveNewest" />
<None Update="Config\Signal\*.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StandardScene.Core\StandardScene.Core.csproj" />
</ItemGroup>
<ItemGroup>
<Reference Include="CommonUsage">
<HintPath>$(StandRefDir)CommonUsage.dll</HintPath>
</Reference>
<Reference Include="CycleGUI">
<HintPath>$(StandRefDir)CycleGUI.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="LessokajiWeaverUtilities">
<HintPath>$(StandRefDir)LessokajiWeaverUtilities.dll</HintPath>
</Reference>
<Reference Include="SimpleCore">
<HintPath>$(StandRefDir)SimpleCore.dll</HintPath>
</Reference>
<Reference Include="SimpleLite">
<HintPath>$(StandSimpleLiteDir)SimpleLite.dll</HintPath>
</Reference>
<Reference Include="Topaz">
<HintPath>$(StandRefDir)Topaz.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<PackageReference Include="IoTClient" Version="1.0.40" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
</ItemGroup>
</Project>
@@ -0,0 +1,10 @@
{
"id": "scene.signal",
"displayName": "第三方信号交互",
"assembly": "StandardScene.Signal.dll",
"coreVersion": ">=1.0.0",
"requiresCore": "StandardScene.dll",
"provides": {
"missionTypes": [ "SignalInteractMission" ]
}
}
+349
View File
@@ -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
{
/// <summary>
/// 按 Model 的 <see cref="DisplayNameAttribute"/> 反射生成可增删改列表(CycleGUI)。
/// 跳过 <see cref="BrowsableAttribute.Browsable"/> = false 与 <see cref="JsonIgnoreAttribute"/>。
/// </summary>
public static class ModelListPanel<T> where T : class, new()
{
private static Panel _panel;
private static List<T> _items = new List<T>();
private static int _selected = -1;
private static string _status = "";
private static string _title = "";
private static string _path = "";
private static Func<T, string> _keySelector = _ => "";
private static readonly Dictionary<string, string> Text = new Dictionary<string, string>();
private static readonly Dictionary<string, bool> Bools = new Dictionary<string, bool>();
private static readonly Dictionary<string, int> EnumIdx = new Dictionary<string, int>();
private static PropertyInfo[] Props => _props ??= Discover();
private static PropertyInfo[] _props;
public static void Open(string title, string path, Func<T, string> keySelector)
{
if (_panel != null)
{
try { _panel.BringToFront(); return; }
catch { _panel = null; }
}
_title = title;
_path = path;
_keySelector = keySelector ?? (_ => "");
_items = SignalConfigStore.Load<T>(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<BrowsableAttribute>()?.Browsable != false)
.Where(p => p.GetCustomAttribute<JsonIgnoreAttribute>() == null)
.OrderBy(p => p.MetadataToken)
.ToArray();
}
private static string DisplayOf(PropertyInfo p) =>
p.GetCustomAttribute<DisplayNameAttribute>()?.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();
});
}
}
}
+14
View File
@@ -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