新增 scene.signal 信号交互插件,落地 PLC 握手与扫车放行。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+14
-1
@@ -1,7 +1,7 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<!--
|
<!--
|
||||||
StandardScene 插件工程编译后,自动将产物复制到 build\plugins\(SimpleComposer / SimpleLite 宿主加载目录)。
|
StandardScene 插件工程编译后,自动将产物复制到 build\plugins\(SimpleComposer / SimpleLite 宿主加载目录)。
|
||||||
适用:StandardScene.Core + Magnetic / QrLidar / Devices / Protocol.VDA5050 等卫星插件。
|
适用:StandardScene.Core + Magnetic / QrLidar / Devices / Protocol.VDA5050 / Signal 等卫星插件。
|
||||||
-->
|
-->
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<StandardSceneRepoRoot>$(MSBuildThisFileDirectory)</StandardSceneRepoRoot>
|
<StandardSceneRepoRoot>$(MSBuildThisFileDirectory)</StandardSceneRepoRoot>
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
<IsStandardScenePluginCopy Condition="'$(MSBuildProjectName)' == 'StandardScene.QrLidar'">true</IsStandardScenePluginCopy>
|
<IsStandardScenePluginCopy Condition="'$(MSBuildProjectName)' == 'StandardScene.QrLidar'">true</IsStandardScenePluginCopy>
|
||||||
<IsStandardScenePluginCopy Condition="'$(MSBuildProjectName)' == 'StandardScene.Devices'">true</IsStandardScenePluginCopy>
|
<IsStandardScenePluginCopy Condition="'$(MSBuildProjectName)' == 'StandardScene.Devices'">true</IsStandardScenePluginCopy>
|
||||||
<IsStandardScenePluginCopy Condition="'$(MSBuildProjectName)' == 'StandardScene.Protocol.VDA5050'">true</IsStandardScenePluginCopy>
|
<IsStandardScenePluginCopy Condition="'$(MSBuildProjectName)' == 'StandardScene.Protocol.VDA5050'">true</IsStandardScenePluginCopy>
|
||||||
|
<IsStandardScenePluginCopy Condition="'$(MSBuildProjectName)' == 'StandardScene.Signal'">true</IsStandardScenePluginCopy>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<Target Name="CopyStandardScenePluginOutputs"
|
<Target Name="CopyStandardScenePluginOutputs"
|
||||||
@@ -28,12 +29,24 @@
|
|||||||
<!-- Devices 插件运行时依赖 -->
|
<!-- Devices 插件运行时依赖 -->
|
||||||
<StandardScenePluginCopyFile Include="$(TargetDir)leegKeys-sdk.dll"
|
<StandardScenePluginCopyFile Include="$(TargetDir)leegKeys-sdk.dll"
|
||||||
Condition="Exists('$(TargetDir)leegKeys-sdk.dll')" />
|
Condition="Exists('$(TargetDir)leegKeys-sdk.dll')" />
|
||||||
|
<StandardScenePluginCopyFile Include="$(TargetDir)IoTClient.dll"
|
||||||
|
Condition="Exists('$(TargetDir)IoTClient.dll')" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<Copy SourceFiles="@(StandardScenePluginCopyFile)"
|
<Copy SourceFiles="@(StandardScenePluginCopyFile)"
|
||||||
DestinationFolder="$(StandardScenePluginsDir)"
|
DestinationFolder="$(StandardScenePluginsDir)"
|
||||||
SkipUnchangedFiles="true" />
|
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"
|
<Message Importance="high"
|
||||||
Text="[StandardScene] 已复制 $(MSBuildProjectName) 插件产物到 $(StandardScenePluginsDir)" />
|
Text="[StandardScene] 已复制 $(MSBuildProjectName) 插件产物到 $(StandardScenePluginsDir)" />
|
||||||
</Target>
|
</Target>
|
||||||
|
|||||||
@@ -6,5 +6,6 @@ using System.Runtime.CompilerServices;
|
|||||||
[assembly: InternalsVisibleTo("StandardScene.Devices")]
|
[assembly: InternalsVisibleTo("StandardScene.Devices")]
|
||||||
[assembly: InternalsVisibleTo("StandardScene.Magnetic")]
|
[assembly: InternalsVisibleTo("StandardScene.Magnetic")]
|
||||||
[assembly: InternalsVisibleTo("StandardScene.QrLidar")]
|
[assembly: InternalsVisibleTo("StandardScene.QrLidar")]
|
||||||
|
[assembly: InternalsVisibleTo("StandardScene.Signal")]
|
||||||
// SimpleLite 反射 API 需读取 internal 字段袋(BasicTrackFields / KivaSiteFields 等)的 public 字段定义。
|
// SimpleLite 反射 API 需读取 internal 字段袋(BasicTrackFields / KivaSiteFields 等)的 public 字段定义。
|
||||||
[assembly: InternalsVisibleTo("SimpleLite")]
|
[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": "单牵引下线机构"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" ]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandardScene.Fass2Simulato
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandardScene.Magnetic.Tests", "StandardScene.Magnetic.Tests\StandardScene.Magnetic.Tests.csproj", "{7C13685A-D666-4404-8CD7-19AA9BFC907B}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandardScene.Magnetic.Tests", "StandardScene.Magnetic.Tests\StandardScene.Magnetic.Tests.csproj", "{7C13685A-D666-4404-8CD7-19AA9BFC907B}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandardScene.Signal", "StandardScene.Signal\StandardScene.Signal.csproj", "{7E5050AA-0009-4A11-9C22-0A0B0C0D0E09}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
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|x64.Build.0 = Release|Any CPU
|
||||||
{7C13685A-D666-4404-8CD7-19AA9BFC907B}.Release|x86.ActiveCfg = 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
|
{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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
|||||||
Reference in New Issue
Block a user