新增1.0和2.0两种协议车型车型
This commit is contained in:
@@ -1,5 +1,11 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
<!-- FASS2.0 与 stand 为兄弟目录:$(RepoRoot)stand\ -->
|
||||||
|
<StandRoot Condition="'$(StandRoot)' == ''">$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\..\stand\'))</StandRoot>
|
||||||
|
<StandRefDir Condition="'$(StandRefDir)' == ''">$(StandRoot)ref\</StandRefDir>
|
||||||
|
<StandSimpleLiteDir Condition="'$(StandSimpleLiteDir)' == ''">$(StandRoot)SimpleLite\</StandSimpleLiteDir>
|
||||||
<CGUILibDir Condition="'$(CGUILibDir)' == ''">D:\MDCS\Dependencies\Commons</CGUILibDir>
|
<CGUILibDir Condition="'$(CGUILibDir)' == ''">D:\MDCS\Dependencies\Commons</CGUILibDir>
|
||||||
|
<LeegKeysSdkPath Condition="'$(LeegKeysSdkPath)' == '' and Exists('$(MSBuildThisFileDirectory)StandardScene.Core\Ref\leegKeys-sdk.dll')">$(MSBuildThisFileDirectory)StandardScene.Core\Ref\leegKeys-sdk.dll</LeegKeysSdkPath>
|
||||||
|
<LeegKeysSdkPath Condition="'$(LeegKeysSdkPath)' == ''">$(StandSimpleLiteDir)plugins\leegKeys-sdk.dll</LeegKeysSdkPath>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1058,6 +1058,22 @@ namespace StandardScene.Chained
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
car.tags.Remove("occupied");
|
||||||
|
if (car is Car trafficCar && car.GetLastSite() > 0)
|
||||||
|
{
|
||||||
|
var currentSite = SimpleLib.GetSite(car.GetLastSite());
|
||||||
|
if (currentSite != null)
|
||||||
|
{
|
||||||
|
trafficCar.TrafficReset(currentSite, true, strict: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
// 记录异常并等待后重试
|
// 记录异常并等待后重试
|
||||||
Diagnosis.Post(
|
Diagnosis.Post(
|
||||||
$"{car.name}循环任务报错{ExceptionFormatter.FormatEx(ex)}--去往循环点id:{targetSite.id},name:{targetSite.name}",
|
$"{car.name}循环任务报错{ExceptionFormatter.FormatEx(ex)}--去往循环点id:{targetSite.id},name:{targetSite.name}",
|
||||||
|
|||||||
@@ -25,32 +25,31 @@
|
|||||||
|
|
||||||
<!-- 程序集引用:SimpleComposer.exe -> SimpleLite.dll;SimpleCore 配套本地产物 -->
|
<!-- 程序集引用:SimpleComposer.exe -> SimpleLite.dll;SimpleCore 配套本地产物 -->
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Reference Include="SimpleLite">
|
<Reference Include="CommonUsage">
|
||||||
<HintPath>E:\Work\Core\Simple-FR\Simple\SimpleLite\bin\Debug\SimpleLite.dll</HintPath>
|
<HintPath>$(StandRefDir)CommonUsage.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="CycleGUI">
|
||||||
|
<HintPath>$(StandRefDir)CycleGUI.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="LessokajiWeaverUtilities">
|
||||||
|
<HintPath>$(StandRefDir)LessokajiWeaverUtilities.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="MDCSToolBox">
|
||||||
|
<HintPath>$(StandRefDir)MDCSToolBox.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="SimpleCore">
|
||||||
|
<HintPath>$(StandRefDir)SimpleCore.dll</HintPath>
|
||||||
</Reference>
|
</Reference>
|
||||||
<!-- CycleGUI:插件 UI 已从 WinForms 迁移到 CycleGUI(DeliveryViewer 等)。
|
<!-- CycleGUI:插件 UI 已从 WinForms 迁移到 CycleGUI(DeliveryViewer 等)。
|
||||||
Private=false:宿主 SimpleLite 已在默认 ALC 加载 CycleGUI,插件仅编译期引用、不随产物分发,避免重复 DLL。 -->
|
Private=false:宿主 SimpleLite 已在默认 ALC 加载 CycleGUI,插件仅编译期引用、不随产物分发,避免重复 DLL。 -->
|
||||||
<Reference Include="CycleGUI">
|
<Reference Include="leegKeys-sdk">
|
||||||
<HintPath>$(CGUILibDir)\CycleGUI.dll</HintPath>
|
<HintPath>$(LeegKeysSdkPath)</HintPath>
|
||||||
<Private>false</Private>
|
|
||||||
</Reference>
|
</Reference>
|
||||||
<Reference Include="SimpleCore">
|
<Reference Include="SimpleLite">
|
||||||
<HintPath>E:\Work\Core\Simple-FR\Simple\SimpleCore\bin\Debug\netstandard2.0\SimpleCore.dll</HintPath>
|
<HintPath>$(StandSimpleLiteDir)SimpleLite.dll</HintPath>
|
||||||
</Reference>
|
|
||||||
<Reference Include="CommonUsage">
|
|
||||||
<HintPath>D:\MDCS\Dependencies\Commons\CommonUsage.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="MDCSToolBox">
|
|
||||||
<HintPath>D:\MDCS\Dependencies\Commons\MDCSToolBox.dll</HintPath>
|
|
||||||
</Reference>
|
</Reference>
|
||||||
<Reference Include="Topaz">
|
<Reference Include="Topaz">
|
||||||
<HintPath>E:\Work\Core\Simple-FR\Simple\tools\Topaz.dll</HintPath>
|
<HintPath>$(StandRefDir)Topaz.dll</HintPath>
|
||||||
</Reference>
|
|
||||||
<Reference Include="LessokajiWeaverUtilities">
|
|
||||||
<HintPath>E:\Work\Core\Simple-FR\Simple\SimpleLite\bin\Debug\LessokajiWeaverUtilities.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="leegKeys-sdk">
|
|
||||||
<HintPath>Ref\leegKeys-sdk.dll</HintPath>
|
|
||||||
</Reference>
|
</Reference>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -30,23 +30,23 @@
|
|||||||
|
|
||||||
<!-- 与 Core 一致的本地契约/工具 dll + Leeg 按钮盒 SDK(leegiot);Modbus/TCP 经 Core 的 Utils/TCP 间接使用,无需 NuGet -->
|
<!-- 与 Core 一致的本地契约/工具 dll + Leeg 按钮盒 SDK(leegiot);Modbus/TCP 经 Core 的 Utils/TCP 间接使用,无需 NuGet -->
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Reference Include="SimpleLite">
|
|
||||||
<HintPath>E:\Work\Core\Simple-FR\Simple\SimpleLite\bin\Debug\SimpleLite.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="SimpleCore">
|
|
||||||
<HintPath>E:\Work\Core\Simple-FR\Simple\SimpleCore\bin\Debug\netstandard2.0\SimpleCore.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="CommonUsage">
|
<Reference Include="CommonUsage">
|
||||||
<HintPath>D:\MDCS\Dependencies\Commons\CommonUsage.dll</HintPath>
|
<HintPath>$(StandRefDir)CommonUsage.dll</HintPath>
|
||||||
</Reference>
|
|
||||||
<Reference Include="Topaz">
|
|
||||||
<HintPath>E:\Work\Core\Simple-FR\Simple\tools\Topaz.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="LessokajiWeaverUtilities">
|
|
||||||
<HintPath>E:\Work\Core\Simple-FR\Simple\SimpleLite\bin\Debug\LessokajiWeaverUtilities.dll</HintPath>
|
|
||||||
</Reference>
|
</Reference>
|
||||||
<Reference Include="leegKeys-sdk">
|
<Reference Include="leegKeys-sdk">
|
||||||
<HintPath>..\StandardScene.Core\Ref\leegKeys-sdk.dll</HintPath>
|
<HintPath>$(LeegKeysSdkPath)</HintPath>
|
||||||
|
</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>
|
</Reference>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<Application x:Class="StandardScene.Fass2Simulator.App"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
<Application.Resources>
|
||||||
|
<SolidColorBrush x:Key="PanelBrush" Color="#F4F6F8"/>
|
||||||
|
<SolidColorBrush x:Key="AccentBrush" Color="#2563EB"/>
|
||||||
|
<SolidColorBrush x:Key="BorderBrush" Color="#D0D7DE"/>
|
||||||
|
</Application.Resources>
|
||||||
|
</Application>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using System;
|
||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
namespace StandardScene.Fass2Simulator
|
||||||
|
{
|
||||||
|
public partial class App : Application
|
||||||
|
{
|
||||||
|
protected override void OnStartup(StartupEventArgs e)
|
||||||
|
{
|
||||||
|
if (HasTestArg(e.Args))
|
||||||
|
{
|
||||||
|
var code = Fass2SimSelfTests.Run(e.Args);
|
||||||
|
Shutdown(code);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
base.OnStartup(e);
|
||||||
|
Exit += (_, __) => Fass2SimFileLogger.Shutdown();
|
||||||
|
var window = new MainWindow();
|
||||||
|
MainWindow = window;
|
||||||
|
window.Show();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasTestArg(string[] args)
|
||||||
|
{
|
||||||
|
if (args == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var arg in args)
|
||||||
|
{
|
||||||
|
if (string.Equals(arg, "--motion-test", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(arg, "--action-test", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace StandardScene.Fass2Simulator
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 到站动作闭环:0xB1 登记期望 → 0xA1 或定时器补全字段 → 供调度 AtStation 判定完成。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Fass2SimActionEngine
|
||||||
|
{
|
||||||
|
private readonly Fass2SimVehicle _vehicle;
|
||||||
|
private readonly Fass2SimConfig _config;
|
||||||
|
private readonly Fass2SimNodeProfileStore _profiles;
|
||||||
|
private readonly object _syncRoot = new object();
|
||||||
|
private readonly List<PendingAction> _pendingActions = new List<PendingAction>();
|
||||||
|
|
||||||
|
private Fass2SimNodeMessage _expectedStation;
|
||||||
|
|
||||||
|
public Fass2SimActionEngine(Fass2SimVehicle vehicle, Fass2SimConfig config, Fass2SimNodeProfileStore profiles)
|
||||||
|
{
|
||||||
|
_vehicle = vehicle;
|
||||||
|
_config = config;
|
||||||
|
_profiles = profiles ?? new Fass2SimNodeProfileStore();
|
||||||
|
}
|
||||||
|
|
||||||
|
public event Action StationActionsCompleted;
|
||||||
|
|
||||||
|
public void OnStationArrival(Fass2SimNodeMessage station)
|
||||||
|
{
|
||||||
|
if (station == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
_pendingActions.Clear();
|
||||||
|
_expectedStation = Clone(station);
|
||||||
|
|
||||||
|
ApplyArrivalFields(station);
|
||||||
|
_vehicle.State = 2;
|
||||||
|
|
||||||
|
var pending = Fass2SimActionResolver.DescribePending(_expectedStation, _vehicle.CopySnapshotUnsafe());
|
||||||
|
if (string.IsNullOrEmpty(pending))
|
||||||
|
{
|
||||||
|
Fass2SimLog.WriteLine($"[{Now()}] -> 站点 node={station.Node} 无待执行动作");
|
||||||
|
NotifyStationCompleteIfReady();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Fass2SimLog.WriteLine($"[{Now()}] -> 站点 node={station.Node} 待动作: {pending}");
|
||||||
|
|
||||||
|
if (_config.AutoCompleteStationActions)
|
||||||
|
{
|
||||||
|
SchedulePatch(Clone(station), 0, 0, ResolveActionDelayMs(station.Node));
|
||||||
|
Fass2SimLog.WriteLine($"[{Now()}] -> AutoComplete 已排队,delay={ResolveActionDelayMs(station.Node)}ms");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnActionCommand(ulong actionId, Fass2SimNodeMessage patch)
|
||||||
|
{
|
||||||
|
if (patch == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
if (patch.Node != 0 && patch.Node != _vehicle.Node)
|
||||||
|
{
|
||||||
|
Fass2SimLog.WriteLine(
|
||||||
|
$"[{Now()}] -> 0xA1 节点不匹配: patch={patch.Node}, current={_vehicle.Node},仍尝试应用");
|
||||||
|
}
|
||||||
|
|
||||||
|
var delay = ResolveActionDelayMs(patch.Node != 0 ? patch.Node : _vehicle.Node);
|
||||||
|
SchedulePatch(patch, actionId, 0, delay);
|
||||||
|
Fass2SimLog.WriteLine(
|
||||||
|
$"[{Now()}] -> 0xA1 actionId={actionId} 已排队,delay={delay}ms, pending={DescribePatch(patch)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Tick(int deltaMs)
|
||||||
|
{
|
||||||
|
if (deltaMs <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<PendingAction> due = null;
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
for (var i = _pendingActions.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
_pendingActions[i].RemainingMs -= deltaMs;
|
||||||
|
if (_pendingActions[i].RemainingMs <= 0)
|
||||||
|
{
|
||||||
|
due ??= new List<PendingAction>();
|
||||||
|
due.Add(_pendingActions[i]);
|
||||||
|
_pendingActions.RemoveAt(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (due == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var action in due)
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
ApplyPatch(action.Patch);
|
||||||
|
if (action.ActionId > 0)
|
||||||
|
{
|
||||||
|
Fass2SimLog.WriteLine(
|
||||||
|
$"[{Now()}] -> 动作完成 actionId={action.ActionId}, node={_vehicle.Node}, fields={DescribePatch(action.Patch)}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Fass2SimLog.WriteLine(
|
||||||
|
$"[{Now()}] -> 自动动作完成 node={_vehicle.Node}, fields={DescribePatch(action.Patch)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var pending = Fass2SimActionResolver.DescribePending(_expectedStation, _vehicle.CopySnapshotUnsafe());
|
||||||
|
if (!string.IsNullOrEmpty(pending))
|
||||||
|
{
|
||||||
|
Fass2SimLog.WriteLine($"[{Now()}] -> 仍有待动作: {pending}");
|
||||||
|
}
|
||||||
|
|
||||||
|
NotifyStationCompleteIfReady();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsStationComplete()
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
return IsStationCompleteLocked();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool CanLeaveStation()
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
return IsStationCompleteLocked();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Fass2SimNodeMessage ExpectedStation
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
return _expectedStation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsStationCompleteLocked()
|
||||||
|
{
|
||||||
|
if (_pendingActions.Count > 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_expectedStation == null)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Fass2SimActionResolver.IsStationActionComplete(
|
||||||
|
_expectedStation,
|
||||||
|
_vehicle.CopySnapshotUnsafe(),
|
||||||
|
_vehicle.State);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void NotifyStationCompleteIfReady()
|
||||||
|
{
|
||||||
|
if (!IsStationCompleteLocked())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_expectedStation != null)
|
||||||
|
{
|
||||||
|
Fass2SimLog.WriteLine($"[{Now()}] -> 站点 node={_expectedStation.Node} 动作已全部完成");
|
||||||
|
}
|
||||||
|
|
||||||
|
StationActionsCompleted?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyArrivalFields(Fass2SimNodeMessage station)
|
||||||
|
{
|
||||||
|
if (station.StartStop > 0)
|
||||||
|
{
|
||||||
|
_vehicle.StartStop = station.StartStop;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (station.Orientation > 0)
|
||||||
|
{
|
||||||
|
_vehicle.Orientation = station.Orientation;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (station.Direction > 0)
|
||||||
|
{
|
||||||
|
_vehicle.Direction = station.Direction;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (station.Byroad > 0)
|
||||||
|
{
|
||||||
|
_vehicle.Byroad = station.Byroad;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SchedulePatch(Fass2SimNodeMessage patch, ulong actionId, int source, int delayMs)
|
||||||
|
{
|
||||||
|
_pendingActions.Add(new PendingAction
|
||||||
|
{
|
||||||
|
Patch = Clone(patch),
|
||||||
|
ActionId = actionId,
|
||||||
|
RemainingMs = Math.Max(0, delayMs)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyPatch(Fass2SimNodeMessage patch)
|
||||||
|
{
|
||||||
|
if (patch.Node != 0)
|
||||||
|
{
|
||||||
|
_vehicle.Node = patch.Node;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patch.StartStop > 0)
|
||||||
|
{
|
||||||
|
_vehicle.StartStop = patch.StartStop;
|
||||||
|
}
|
||||||
|
if (patch.Speed > 0)
|
||||||
|
{
|
||||||
|
_vehicle.Speed = patch.Speed;
|
||||||
|
}
|
||||||
|
if (patch.Direction > 0)
|
||||||
|
{
|
||||||
|
_vehicle.Direction = patch.Direction;
|
||||||
|
}
|
||||||
|
if (patch.Orientation > 0)
|
||||||
|
{
|
||||||
|
_vehicle.Orientation = patch.Orientation;
|
||||||
|
}
|
||||||
|
if (patch.Byroad > 0)
|
||||||
|
{
|
||||||
|
_vehicle.Byroad = patch.Byroad;
|
||||||
|
}
|
||||||
|
if (patch.Obstacle > 0)
|
||||||
|
{
|
||||||
|
_vehicle.Obstacle = patch.Obstacle;
|
||||||
|
}
|
||||||
|
if (patch.Audio > 0)
|
||||||
|
{
|
||||||
|
_vehicle.Audio = patch.Audio;
|
||||||
|
}
|
||||||
|
if (patch.Light > 0)
|
||||||
|
{
|
||||||
|
_vehicle.Light = patch.Light;
|
||||||
|
}
|
||||||
|
if (patch.Charge > 0)
|
||||||
|
{
|
||||||
|
_vehicle.Charge = patch.Charge;
|
||||||
|
}
|
||||||
|
if (patch.Rest > 0)
|
||||||
|
{
|
||||||
|
_vehicle.Rest = patch.Rest;
|
||||||
|
}
|
||||||
|
if (patch.Lift > 0)
|
||||||
|
{
|
||||||
|
_vehicle.Lift = patch.Lift;
|
||||||
|
}
|
||||||
|
if (patch.Clamp > 0)
|
||||||
|
{
|
||||||
|
_vehicle.Clamp = patch.Clamp;
|
||||||
|
}
|
||||||
|
if (patch.Tray > 0)
|
||||||
|
{
|
||||||
|
_vehicle.Tray = patch.Tray;
|
||||||
|
}
|
||||||
|
if (patch.Roll > 0)
|
||||||
|
{
|
||||||
|
_vehicle.Roll = patch.Roll;
|
||||||
|
}
|
||||||
|
if (patch.Shutdown > 0)
|
||||||
|
{
|
||||||
|
_vehicle.Shutdown = patch.Shutdown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int ResolveActionDelayMs(ushort nodeId)
|
||||||
|
{
|
||||||
|
return _profiles.ResolveActionDelayMs(nodeId, _config.ActionDelayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Fass2SimNodeMessage Clone(Fass2SimNodeMessage source)
|
||||||
|
{
|
||||||
|
return new Fass2SimNodeMessage
|
||||||
|
{
|
||||||
|
Node = source.Node,
|
||||||
|
Distance = source.Distance,
|
||||||
|
StartStop = source.StartStop,
|
||||||
|
Direction = source.Direction,
|
||||||
|
Orientation = source.Orientation,
|
||||||
|
Byroad = source.Byroad,
|
||||||
|
Speed = source.Speed,
|
||||||
|
Obstacle = source.Obstacle,
|
||||||
|
Audio = source.Audio,
|
||||||
|
Light = source.Light,
|
||||||
|
Charge = source.Charge,
|
||||||
|
Rest = source.Rest,
|
||||||
|
Lift = source.Lift,
|
||||||
|
Clamp = source.Clamp,
|
||||||
|
Tray = source.Tray,
|
||||||
|
Roll = source.Roll,
|
||||||
|
Shutdown = source.Shutdown
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DescribePatch(Fass2SimNodeMessage patch)
|
||||||
|
{
|
||||||
|
var parts = new List<string>();
|
||||||
|
if (patch.StartStop > 0) parts.Add($"StartStop={patch.StartStop}");
|
||||||
|
if (patch.Lift > 0) parts.Add($"Lift={patch.Lift}");
|
||||||
|
if (patch.Charge > 0) parts.Add($"Charge={patch.Charge}");
|
||||||
|
if (patch.Roll > 0) parts.Add($"Roll={patch.Roll}");
|
||||||
|
if (patch.Speed > 0) parts.Add($"Speed={patch.Speed}");
|
||||||
|
return parts.Count == 0 ? "(empty)" : string.Join(",", parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Now()
|
||||||
|
{
|
||||||
|
return DateTime.Now.ToString("HH:mm:ss.fff");
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class PendingAction
|
||||||
|
{
|
||||||
|
public Fass2SimNodeMessage Patch { get; set; }
|
||||||
|
public ulong ActionId { get; set; }
|
||||||
|
public int RemainingMs { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace StandardScene.Fass2Simulator
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 对齐 <c>Fass2ActionResolver</c> 的到站动作判定(独立副本,避免引用 Magnetic)。
|
||||||
|
/// </summary>
|
||||||
|
public static class Fass2SimActionResolver
|
||||||
|
{
|
||||||
|
public const byte StartStopPass = 1;
|
||||||
|
public const byte StartStopStop = 2;
|
||||||
|
public const byte StartStopPrecision = 22;
|
||||||
|
|
||||||
|
private static readonly (string Name, Func<Fass2SimNodeMessage, byte> Get)[] ActionFields =
|
||||||
|
{
|
||||||
|
("Direction", n => n.Direction),
|
||||||
|
("Orientation", n => n.Orientation),
|
||||||
|
("Byroad", n => n.Byroad),
|
||||||
|
("Obstacle", n => n.Obstacle),
|
||||||
|
("Audio", n => n.Audio),
|
||||||
|
("Light", n => n.Light),
|
||||||
|
("Charge", n => n.Charge),
|
||||||
|
("Rest", n => n.Rest),
|
||||||
|
("Lift", n => n.Lift),
|
||||||
|
("Clamp", n => n.Clamp),
|
||||||
|
("Tray", n => n.Tray),
|
||||||
|
("Roll", n => n.Roll),
|
||||||
|
("Shutdown", n => n.Shutdown)
|
||||||
|
};
|
||||||
|
|
||||||
|
public static bool RequiresActionWait(Fass2SimNodeMessage expected)
|
||||||
|
{
|
||||||
|
if (expected == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expected.StartStop is StartStopStop or StartStopPrecision)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var field in ActionFields)
|
||||||
|
{
|
||||||
|
if (field.Get(expected) != 0)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return expected.Speed != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsStationActionComplete(Fass2SimNodeMessage expected, Fass2SimVehicleSnapshot actual, byte vehicleState)
|
||||||
|
{
|
||||||
|
if (expected == null || actual == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actual.Node != expected.Node)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vehicleState == 1)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!RequiresActionWait(expected))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expected.StartStop == StartStopPass)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListPendingFields(expected, actual).Count == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<string> ListPendingFields(Fass2SimNodeMessage expected, Fass2SimVehicleSnapshot actual)
|
||||||
|
{
|
||||||
|
var pending = new List<string>();
|
||||||
|
if (expected == null || actual == null)
|
||||||
|
{
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expected.StartStop != 0 && actual.StartStop != expected.StartStop)
|
||||||
|
{
|
||||||
|
pending.Add("StartStop");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expected.Speed != 0 && actual.Speed != expected.Speed)
|
||||||
|
{
|
||||||
|
pending.Add("Speed");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var field in ActionFields)
|
||||||
|
{
|
||||||
|
var expectedValue = field.Get(expected);
|
||||||
|
if (expectedValue != 0 && GetActualField(actual, field.Name) != expectedValue)
|
||||||
|
{
|
||||||
|
pending.Add(field.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string DescribePending(Fass2SimNodeMessage expected, Fass2SimVehicleSnapshot actual)
|
||||||
|
{
|
||||||
|
var pending = ListPendingFields(expected, actual);
|
||||||
|
return pending.Count == 0 ? string.Empty : string.Join(",", pending);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte GetActualField(Fass2SimVehicleSnapshot actual, string name)
|
||||||
|
{
|
||||||
|
switch (name)
|
||||||
|
{
|
||||||
|
case "Direction": return actual.Direction;
|
||||||
|
case "Orientation": return actual.Orientation;
|
||||||
|
case "Byroad": return actual.Byroad;
|
||||||
|
case "Obstacle": return actual.Obstacle;
|
||||||
|
case "Audio": return actual.Audio;
|
||||||
|
case "Light": return actual.Light;
|
||||||
|
case "Charge": return actual.Charge;
|
||||||
|
case "Rest": return actual.Rest;
|
||||||
|
case "Lift": return actual.Lift;
|
||||||
|
case "Clamp": return actual.Clamp;
|
||||||
|
case "Tray": return actual.Tray;
|
||||||
|
case "Roll": return actual.Roll;
|
||||||
|
case "Shutdown": return actual.Shutdown;
|
||||||
|
default: return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace StandardScene.Fass2Simulator
|
||||||
|
{
|
||||||
|
public static class Fass2SimBootstrap
|
||||||
|
{
|
||||||
|
public static Fass2SimConfig LoadConfig()
|
||||||
|
{
|
||||||
|
var basePath = AppContext.BaseDirectory;
|
||||||
|
var builder = new ConfigurationBuilder()
|
||||||
|
.SetBasePath(basePath)
|
||||||
|
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: false);
|
||||||
|
|
||||||
|
var configuration = builder.Build();
|
||||||
|
var config = new Fass2SimConfig();
|
||||||
|
configuration.GetSection("Fass2Simulator").Bind(config);
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Fass2SimNodeProfileStore LoadNodeProfiles(Fass2SimConfig config)
|
||||||
|
{
|
||||||
|
var store = new Fass2SimNodeProfileStore();
|
||||||
|
var path = ResolveNodeProfilesPath(config.NodeProfilesPath);
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(path);
|
||||||
|
var profiles = JsonConvert.DeserializeObject<Dictionary<string, Fass2SimNodeProfile>>(json)
|
||||||
|
?? new Dictionary<string, Fass2SimNodeProfile>();
|
||||||
|
store.Load(profiles);
|
||||||
|
Fass2SimLog.WriteLine($"[{DateTime.Now:HH:mm:ss}] 已加载节点配置: {path} ({profiles.Count} 项)");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Fass2SimLog.WriteLine($"[{DateTime.Now:HH:mm:ss}] 加载节点配置失败: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Fass2SimRuntime CreateRuntime(Fass2SimConfig config)
|
||||||
|
{
|
||||||
|
var profiles = LoadNodeProfiles(config);
|
||||||
|
return new Fass2SimRuntime(config, profiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static (Fass2SimVehicle vehicle, Fass2SimMotionEngine motion, Fass2SimActionEngine actions) CreateTestStack(
|
||||||
|
Fass2SimConfig config)
|
||||||
|
{
|
||||||
|
var vehicle = new Fass2SimVehicle(config);
|
||||||
|
var actions = new Fass2SimActionEngine(vehicle, config, new Fass2SimNodeProfileStore());
|
||||||
|
var motion = new Fass2SimMotionEngine(vehicle, config, actions);
|
||||||
|
actions.StationActionsCompleted += motion.OnStationActionsCompleted;
|
||||||
|
return (vehicle, motion, actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveNodeProfilesPath(string configuredPath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(configuredPath))
|
||||||
|
{
|
||||||
|
return Path.Combine(AppContext.BaseDirectory, "sim-nodes.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.IsPathRooted(configuredPath)
|
||||||
|
? configuredPath
|
||||||
|
: Path.Combine(AppContext.BaseDirectory, configuredPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace StandardScene.Fass2Simulator
|
||||||
|
{
|
||||||
|
public sealed class Fass2SimCommandHandler
|
||||||
|
{
|
||||||
|
private readonly Fass2SimVehicle _vehicle;
|
||||||
|
private readonly Fass2SimMotionEngine _motion;
|
||||||
|
private readonly Fass2SimActionEngine _actions;
|
||||||
|
private readonly bool _logRawFrames;
|
||||||
|
private long _stateResponseCount;
|
||||||
|
|
||||||
|
public Fass2SimCommandHandler(
|
||||||
|
Fass2SimVehicle vehicle,
|
||||||
|
Fass2SimMotionEngine motion,
|
||||||
|
Fass2SimActionEngine actions,
|
||||||
|
bool logRawFrames)
|
||||||
|
{
|
||||||
|
_vehicle = vehicle;
|
||||||
|
_motion = motion;
|
||||||
|
_actions = actions;
|
||||||
|
_logRawFrames = logRawFrames;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Handle(byte[] packet, string remote)
|
||||||
|
{
|
||||||
|
if (!Fass2SimProtocol.TryGetCommand(packet, out var command, out var expectedLength))
|
||||||
|
{
|
||||||
|
Fass2SimLog.WriteLine($"[{Now()}] RX 非法报文 from {remote}, len={packet?.Length ?? 0}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (packet.Length != expectedLength)
|
||||||
|
{
|
||||||
|
Fass2SimLog.WriteLine(
|
||||||
|
$"[{Now()}] RX 长度不匹配 from {remote}: cmd={Fass2SimProtocol.CommandName(command)} actual={packet.Length} expected={expectedLength}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (packet[expectedLength - 1] != Fass2SimProtocol.End)
|
||||||
|
{
|
||||||
|
Fass2SimLog.WriteLine($"[{Now()}] RX 帧尾错误 from {remote}: cmd={Fass2SimProtocol.CommandName(command)}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_vehicle.RecordCommandReceived();
|
||||||
|
_vehicle.LastCommand = command;
|
||||||
|
|
||||||
|
if (command == Fass2SimProtocol.CmdStateResponse)
|
||||||
|
{
|
||||||
|
_vehicle.RecordAckReceived();
|
||||||
|
LogStateResponseAck(remote);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_logRawFrames)
|
||||||
|
{
|
||||||
|
Fass2SimLog.WriteLine($"[{Now()}] RX {Fass2SimProtocol.CommandName(command)} from {remote}: {Fass2SimProtocol.ToHex(packet)}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Fass2SimLog.WriteLine($"[{Now()}] RX {Fass2SimProtocol.CommandName(command)} from {remote}");
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (command)
|
||||||
|
{
|
||||||
|
case Fass2SimProtocol.CmdStart:
|
||||||
|
_vehicle.State = 1;
|
||||||
|
_motion.OnStartCommand();
|
||||||
|
Fass2SimLog.WriteLine($"[{Now()}] -> State={Fass2SimProtocol.StateText(_vehicle.State)}");
|
||||||
|
break;
|
||||||
|
case Fass2SimProtocol.CmdStop:
|
||||||
|
_vehicle.State = 2;
|
||||||
|
_motion.OnStopCommand();
|
||||||
|
Fass2SimLog.WriteLine($"[{Now()}] -> State={Fass2SimProtocol.StateText(_vehicle.State)}");
|
||||||
|
break;
|
||||||
|
case Fass2SimProtocol.CmdEmergencyStop:
|
||||||
|
_vehicle.State = 3;
|
||||||
|
_motion.OnStopCommand();
|
||||||
|
Fass2SimLog.WriteLine($"[{Now()}] -> State={Fass2SimProtocol.StateText(_vehicle.State)}");
|
||||||
|
break;
|
||||||
|
case Fass2SimProtocol.CmdQuery:
|
||||||
|
Fass2SimLog.WriteLine($"[{Now()}] -> TCP 查询(UDP 模式通常不发 0x00)");
|
||||||
|
break;
|
||||||
|
case Fass2SimProtocol.CmdNodes:
|
||||||
|
if (Fass2SimProtocol.TryParseNodes(packet, out var taskId, out var nodes))
|
||||||
|
{
|
||||||
|
_motion.OnNodesCommand(taskId, nodes);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Fass2SimLog.WriteLine($"[{Now()}] -> 0xB1 解析失败");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case Fass2SimProtocol.CmdAction:
|
||||||
|
if (Fass2SimProtocol.TryParseAction(packet, out var actionId, out var patch))
|
||||||
|
{
|
||||||
|
_actions.OnActionCommand(actionId, patch);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Fass2SimLog.WriteLine($"[{Now()}] -> 0xA1 解析失败");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
Fass2SimLog.WriteLine($"[{Now()}] -> 暂未处理");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LogStateResponseAck(string remote)
|
||||||
|
{
|
||||||
|
_stateResponseCount++;
|
||||||
|
if (_stateResponseCount == 1)
|
||||||
|
{
|
||||||
|
Fass2SimLog.WriteLine($"[{Now()}] RX StateResponse(0x10) from {remote},调度在线(后续 0x10 仅写文件日志)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_stateResponseCount % 200 == 0)
|
||||||
|
{
|
||||||
|
Fass2SimLog.WriteFileOnly($"[{Now()}] 0x10 累计应答 {_stateResponseCount} 次");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Now()
|
||||||
|
{
|
||||||
|
return DateTime.Now.ToString("HH:mm:ss.fff");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace StandardScene.Fass2Simulator
|
||||||
|
{
|
||||||
|
public sealed class Fass2SimConfig
|
||||||
|
{
|
||||||
|
public ushort VehicleCode { get; set; } = 1;
|
||||||
|
public string VehicleListenAddress { get; set; } = "0.0.0.0";
|
||||||
|
public int VehicleListenPort { get; set; } = 5000;
|
||||||
|
public string SchedulerHost { get; set; } = "127.0.0.1";
|
||||||
|
public int SchedulerListenPort { get; set; } = 20103;
|
||||||
|
public int ReportIntervalMs { get; set; } = 200;
|
||||||
|
public ushort InitialNode { get; set; } = 1;
|
||||||
|
public byte InitialState { get; set; } = 0;
|
||||||
|
public byte BatteryCharge { get; set; } = 100;
|
||||||
|
public ushort CarLength { get; set; } = 1200;
|
||||||
|
public ushort CarWidth { get; set; } = 800;
|
||||||
|
public bool LogRawFrames { get; set; }
|
||||||
|
public double DefaultSpeed { get; set; } = 500;
|
||||||
|
public double DefaultSegmentDistance { get; set; } = 1000;
|
||||||
|
public double SecondsPerSegment { get; set; } = 2;
|
||||||
|
public bool AutoContinueOnPass { get; set; } = true;
|
||||||
|
public int ActionDelayMs { get; set; } = 1000;
|
||||||
|
public bool AutoCompleteStationActions { get; set; }
|
||||||
|
public string NodeProfilesPath { get; set; } = "sim-nodes.json";
|
||||||
|
public bool EnableFileLog { get; set; } = true;
|
||||||
|
public string LogDirectory { get; set; } = "logs";
|
||||||
|
public Dictionary<string, Fass2SimNodeProfile> NodeProfiles { get; set; } = new Dictionary<string, Fass2SimNodeProfile>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace StandardScene.Fass2Simulator
|
||||||
|
{
|
||||||
|
public static class Fass2SimFileLogger
|
||||||
|
{
|
||||||
|
private static readonly object SyncRoot = new object();
|
||||||
|
private static StreamWriter _writer;
|
||||||
|
private static bool _enabled = true;
|
||||||
|
private static string _logDirectory;
|
||||||
|
|
||||||
|
public static string CurrentFilePath { get; private set; }
|
||||||
|
|
||||||
|
public static void Configure(Fass2SimConfig config)
|
||||||
|
{
|
||||||
|
_enabled = config == null || config.EnableFileLog;
|
||||||
|
_logDirectory = ResolveLogDirectory(config?.LogDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void BeginSession(Fass2SimConfig config)
|
||||||
|
{
|
||||||
|
if (config == null || !config.EnableFileLog)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (SyncRoot)
|
||||||
|
{
|
||||||
|
CloseWriter();
|
||||||
|
Directory.CreateDirectory(_logDirectory);
|
||||||
|
var fileName = $"car{config.VehicleCode}_{DateTime.Now:yyyyMMdd_HHmmss}.log";
|
||||||
|
CurrentFilePath = Path.Combine(_logDirectory, fileName);
|
||||||
|
_writer = new StreamWriter(CurrentFilePath, false, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false))
|
||||||
|
{
|
||||||
|
AutoFlush = true
|
||||||
|
};
|
||||||
|
|
||||||
|
WriteDirect("===== FASS2 车体模拟器会话开始 =====");
|
||||||
|
WriteDirect($"时间: {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
|
||||||
|
WriteDirect($"车号: {config.VehicleCode}");
|
||||||
|
WriteDirect($"车体监听: {config.VehicleListenAddress}:{config.VehicleListenPort}");
|
||||||
|
WriteDirect($"调度目标: {config.SchedulerHost}:{config.SchedulerListenPort}");
|
||||||
|
WriteDirect($"上报周期: {config.ReportIntervalMs} ms");
|
||||||
|
WriteDirect($"日志文件: {CurrentFilePath}");
|
||||||
|
WriteDirect("====================================");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Write(string message)
|
||||||
|
{
|
||||||
|
if (!_enabled || string.IsNullOrEmpty(message))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (SyncRoot)
|
||||||
|
{
|
||||||
|
if (_writer == null)
|
||||||
|
{
|
||||||
|
EnsureDefaultWriter();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_writer == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_writer.WriteLine(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Shutdown()
|
||||||
|
{
|
||||||
|
lock (SyncRoot)
|
||||||
|
{
|
||||||
|
if (_writer != null)
|
||||||
|
{
|
||||||
|
WriteDirect($"===== 会话结束 {DateTime.Now:yyyy-MM-dd HH:mm:ss} =====");
|
||||||
|
}
|
||||||
|
|
||||||
|
CloseWriter();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetLogDirectory()
|
||||||
|
{
|
||||||
|
return _logDirectory ?? ResolveLogDirectory("logs");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureDefaultWriter()
|
||||||
|
{
|
||||||
|
if (!_enabled)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory.CreateDirectory(_logDirectory);
|
||||||
|
var fileName = $"app_{DateTime.Now:yyyyMMdd}.log";
|
||||||
|
CurrentFilePath = Path.Combine(_logDirectory, fileName);
|
||||||
|
_writer = new StreamWriter(CurrentFilePath, true, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false))
|
||||||
|
{
|
||||||
|
AutoFlush = true
|
||||||
|
};
|
||||||
|
WriteDirect($"[{DateTime.Now:HH:mm:ss}] 文件日志已启用: {CurrentFilePath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteDirect(string message)
|
||||||
|
{
|
||||||
|
_writer?.WriteLine(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CloseWriter()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_writer?.Flush();
|
||||||
|
_writer?.Dispose();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
_writer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveLogDirectory(string configuredPath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(configuredPath))
|
||||||
|
{
|
||||||
|
return Path.Combine(AppContext.BaseDirectory, "logs");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.IsPathRooted(configuredPath)
|
||||||
|
? configuredPath
|
||||||
|
: Path.Combine(AppContext.BaseDirectory, configuredPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace StandardScene.Fass2Simulator
|
||||||
|
{
|
||||||
|
public static class Fass2SimLog
|
||||||
|
{
|
||||||
|
public static event Action<string> MessageWritten;
|
||||||
|
|
||||||
|
public static void WriteLine(string message)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(message))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Fass2SimFileLogger.Write(message);
|
||||||
|
MessageWritten?.Invoke(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 高频日志只写文件,避免 UI 线程被 BeginInvoke 淹没。
|
||||||
|
/// </summary>
|
||||||
|
public static void WriteFileOnly(string message)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(message))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Fass2SimFileLogger.Write(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace StandardScene.Fass2Simulator
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 按 0xB1 节点序列模拟行驶:距离推进 → 到站更新 Node/字段 → State 切为停止供调度判定到站。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Fass2SimMotionEngine
|
||||||
|
{
|
||||||
|
private readonly Fass2SimVehicle _vehicle;
|
||||||
|
private readonly Fass2SimConfig _config;
|
||||||
|
private readonly Fass2SimActionEngine _actions;
|
||||||
|
private readonly object _syncRoot = new object();
|
||||||
|
|
||||||
|
private ulong _activeTaskId;
|
||||||
|
private Fass2SimNodeMessage[] _windowNodes = Array.Empty<Fass2SimNodeMessage>();
|
||||||
|
private Fass2SimNodeMessage _segmentTarget;
|
||||||
|
private double _segmentProgress;
|
||||||
|
private double _segmentLength;
|
||||||
|
private bool _segmentActive;
|
||||||
|
|
||||||
|
public Fass2SimMotionEngine(Fass2SimVehicle vehicle, Fass2SimConfig config, Fass2SimActionEngine actions)
|
||||||
|
{
|
||||||
|
_vehicle = vehicle;
|
||||||
|
_config = config;
|
||||||
|
_actions = actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnNodesCommand(ulong taskId, Fass2SimNodeMessage[] nodes)
|
||||||
|
{
|
||||||
|
if (nodes == null || nodes.Length == 0)
|
||||||
|
{
|
||||||
|
Fass2SimLog.WriteLine($"[{Now()}] -> 0xB1 节点为空,忽略");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
var taskChanged = _activeTaskId != taskId;
|
||||||
|
_activeTaskId = taskId;
|
||||||
|
_windowNodes = nodes;
|
||||||
|
_vehicle.Task = taskId;
|
||||||
|
|
||||||
|
ApplyCurrentNodeFields(nodes);
|
||||||
|
LogNodes(nodes);
|
||||||
|
|
||||||
|
if (_vehicle.State == 3)
|
||||||
|
{
|
||||||
|
Fass2SimLog.WriteLine($"[{Now()}] -> 急停中,不启动运动");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_segmentActive)
|
||||||
|
{
|
||||||
|
if (taskChanged)
|
||||||
|
{
|
||||||
|
Fass2SimLog.WriteLine($"[{Now()}] -> 新 taskId,重置路段");
|
||||||
|
ResetSegment();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TryStartNextSegment();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnStartCommand()
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
if (_vehicle.State == 3)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
TryStartNextSegment();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnStopCommand()
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
ResetSegment();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnStationActionsCompleted()
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
TryResumeFromStation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Tick(int deltaMs)
|
||||||
|
{
|
||||||
|
if (deltaMs <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
if (!_segmentActive || _segmentTarget == null || _vehicle.State != 1)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var speed = ResolveSpeed(_segmentTarget);
|
||||||
|
var deltaDistance = speed * deltaMs / 1000.0;
|
||||||
|
_segmentProgress += deltaDistance;
|
||||||
|
_vehicle.Distance = (ushort)Math.Min(ushort.MaxValue, Math.Round(_segmentProgress));
|
||||||
|
|
||||||
|
if (_segmentProgress < _segmentLength)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ArriveAtTarget();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TryStartNextSegment()
|
||||||
|
{
|
||||||
|
if (_vehicle.State != 1 && _vehicle.State != 2)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_vehicle.State == 2 && !_actions.CanLeaveStation())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var target = ResolveNextTarget(_windowNodes, _vehicle.Node);
|
||||||
|
if (target == null)
|
||||||
|
{
|
||||||
|
if (_vehicle.State == 2)
|
||||||
|
{
|
||||||
|
Fass2SimLog.WriteLine($"[{Now()}] -> 窗口内无下一段,等待下一帧 0xB1");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_segmentTarget = target;
|
||||||
|
_segmentLength = ResolveSegmentLength(target);
|
||||||
|
_segmentProgress = 0;
|
||||||
|
_segmentActive = true;
|
||||||
|
_vehicle.State = 1;
|
||||||
|
_vehicle.Distance = 0;
|
||||||
|
|
||||||
|
if (target.Speed > 0)
|
||||||
|
{
|
||||||
|
_vehicle.Speed = target.Speed;
|
||||||
|
}
|
||||||
|
|
||||||
|
Fass2SimLog.WriteLine(
|
||||||
|
$"[{Now()}] -> 开始路段: {_vehicle.Node} -> {target.Node}, dist={_segmentLength:F0}mm, speed={ResolveSpeed(target):F0}mm/s");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ArriveAtTarget()
|
||||||
|
{
|
||||||
|
var target = _segmentTarget;
|
||||||
|
_vehicle.Node = target.Node;
|
||||||
|
_vehicle.Distance = 0;
|
||||||
|
ResetSegment();
|
||||||
|
_actions.OnStationArrival(target);
|
||||||
|
|
||||||
|
Fass2SimLog.WriteLine(
|
||||||
|
$"[{Now()}] -> 到站 node={target.Node}, StartStop={target.StartStop}, state={Fass2SimProtocol.StateText(_vehicle.State)}");
|
||||||
|
|
||||||
|
TryResumeFromStation();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TryResumeFromStation()
|
||||||
|
{
|
||||||
|
if (!_config.AutoContinueOnPass)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var expected = _actions.ExpectedStation;
|
||||||
|
if (expected == null || expected.StartStop != Fass2SimActionResolver.StartStopPass)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_vehicle.State != 2 || !_actions.CanLeaveStation() || !HasNextTarget(_vehicle.Node))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_vehicle.State = 1;
|
||||||
|
TryStartNextSegment();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResetSegment()
|
||||||
|
{
|
||||||
|
_segmentTarget = null;
|
||||||
|
_segmentProgress = 0;
|
||||||
|
_segmentLength = 0;
|
||||||
|
_segmentActive = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool HasNextTarget(ushort currentNode)
|
||||||
|
{
|
||||||
|
return ResolveNextTarget(_windowNodes, currentNode) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Fass2SimNodeMessage ResolveNextTarget(Fass2SimNodeMessage[] nodes, ushort currentNode)
|
||||||
|
{
|
||||||
|
if (nodes == null || nodes.Length == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var startIndex = 0;
|
||||||
|
for (var i = 0; i < nodes.Length; i++)
|
||||||
|
{
|
||||||
|
if (nodes[i].Node == currentNode)
|
||||||
|
{
|
||||||
|
startIndex = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = startIndex; i < nodes.Length; i++)
|
||||||
|
{
|
||||||
|
var node = nodes[i];
|
||||||
|
if (node.Node == 0 || node.Node == currentNode)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyCurrentNodeFields(Fass2SimNodeMessage[] nodes)
|
||||||
|
{
|
||||||
|
foreach (var node in nodes)
|
||||||
|
{
|
||||||
|
if (node.Node == _vehicle.Node && node.StartStop > 0)
|
||||||
|
{
|
||||||
|
_vehicle.StartStop = node.StartStop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private double ResolveSegmentLength(Fass2SimNodeMessage target)
|
||||||
|
{
|
||||||
|
if (target.Distance > 0)
|
||||||
|
{
|
||||||
|
return target.Distance;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_config.DefaultSegmentDistance > 0)
|
||||||
|
{
|
||||||
|
return _config.DefaultSegmentDistance;
|
||||||
|
}
|
||||||
|
|
||||||
|
var speed = ResolveSpeed(target);
|
||||||
|
return Math.Max(100, speed * _config.SecondsPerSegment);
|
||||||
|
}
|
||||||
|
|
||||||
|
private double ResolveSpeed(Fass2SimNodeMessage target)
|
||||||
|
{
|
||||||
|
if (target.Speed > 0)
|
||||||
|
{
|
||||||
|
return target.Speed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_vehicle.Speed > 0)
|
||||||
|
{
|
||||||
|
return _vehicle.Speed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _config.DefaultSpeed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LogNodes(Fass2SimNodeMessage[] nodes)
|
||||||
|
{
|
||||||
|
var summary = string.Join(" -> ", Array.ConvertAll(nodes, n => n.Node.ToString()));
|
||||||
|
Fass2SimLog.WriteLine($"[{Now()}] -> 0xB1 taskId={_activeTaskId}, nodes=[{summary}]");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Now()
|
||||||
|
{
|
||||||
|
return DateTime.Now.ToString("HH:mm:ss.fff");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 供界面展示路段进度:行驶中返回目标节点与路段长度,到站后 node 才会在协议里跳变。
|
||||||
|
/// </summary>
|
||||||
|
public bool TryGetSegmentStatus(out ushort targetNode, out int segmentLengthMm, out int progressMm)
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
if (!_segmentActive || _segmentTarget == null)
|
||||||
|
{
|
||||||
|
targetNode = 0;
|
||||||
|
segmentLengthMm = 0;
|
||||||
|
progressMm = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
targetNode = _segmentTarget.Node;
|
||||||
|
segmentLengthMm = (int)Math.Round(_segmentLength);
|
||||||
|
progressMm = (int)Math.Round(_segmentProgress);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
namespace StandardScene.Fass2Simulator
|
||||||
|
{
|
||||||
|
public sealed class Fass2SimNodeMessage
|
||||||
|
{
|
||||||
|
public ushort Node { get; set; }
|
||||||
|
public ushort Distance { get; set; }
|
||||||
|
public byte StartStop { get; set; }
|
||||||
|
public byte Direction { get; set; }
|
||||||
|
public byte Orientation { get; set; }
|
||||||
|
public byte Byroad { get; set; }
|
||||||
|
public ushort Speed { get; set; }
|
||||||
|
public byte Obstacle { get; set; }
|
||||||
|
public byte Audio { get; set; }
|
||||||
|
public byte Light { get; set; }
|
||||||
|
public byte Charge { get; set; }
|
||||||
|
public byte Rest { get; set; }
|
||||||
|
public byte Lift { get; set; }
|
||||||
|
public byte Clamp { get; set; }
|
||||||
|
public byte Tray { get; set; }
|
||||||
|
public byte Roll { get; set; }
|
||||||
|
public byte Shutdown { get; set; }
|
||||||
|
|
||||||
|
public static Fass2SimNodeMessage FromBytes(byte[] bytes, int offset)
|
||||||
|
{
|
||||||
|
return new Fass2SimNodeMessage
|
||||||
|
{
|
||||||
|
Node = ReadUInt16(bytes, offset),
|
||||||
|
Distance = ReadUInt16(bytes, offset + 2),
|
||||||
|
StartStop = bytes[offset + 4],
|
||||||
|
Direction = bytes[offset + 5],
|
||||||
|
Orientation = bytes[offset + 6],
|
||||||
|
Byroad = bytes[offset + 7],
|
||||||
|
Speed = ReadUInt16(bytes, offset + 8),
|
||||||
|
Obstacle = bytes[offset + 10],
|
||||||
|
Audio = bytes[offset + 11],
|
||||||
|
Light = bytes[offset + 12],
|
||||||
|
Charge = bytes[offset + 13],
|
||||||
|
Rest = bytes[offset + 14],
|
||||||
|
Lift = bytes[offset + 15],
|
||||||
|
Clamp = bytes[offset + 16],
|
||||||
|
Tray = bytes[offset + 17],
|
||||||
|
Roll = bytes[offset + 18],
|
||||||
|
Shutdown = bytes[offset + 19]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ApplyTo(Fass2SimVehicle vehicle)
|
||||||
|
{
|
||||||
|
if (vehicle.Node != Node)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (StartStop > 0)
|
||||||
|
{
|
||||||
|
vehicle.StartStop = StartStop;
|
||||||
|
}
|
||||||
|
if (Direction > 0)
|
||||||
|
{
|
||||||
|
vehicle.Direction = Direction;
|
||||||
|
}
|
||||||
|
if (Orientation > 0)
|
||||||
|
{
|
||||||
|
vehicle.Orientation = Orientation;
|
||||||
|
}
|
||||||
|
if (Byroad > 0)
|
||||||
|
{
|
||||||
|
vehicle.Byroad = Byroad;
|
||||||
|
}
|
||||||
|
if (Speed > 0)
|
||||||
|
{
|
||||||
|
vehicle.Speed = Speed;
|
||||||
|
}
|
||||||
|
if (Obstacle > 0)
|
||||||
|
{
|
||||||
|
vehicle.Obstacle = Obstacle;
|
||||||
|
}
|
||||||
|
if (Audio > 0)
|
||||||
|
{
|
||||||
|
vehicle.Audio = Audio;
|
||||||
|
}
|
||||||
|
if (Light > 0)
|
||||||
|
{
|
||||||
|
vehicle.Light = Light;
|
||||||
|
}
|
||||||
|
if (Charge > 0)
|
||||||
|
{
|
||||||
|
vehicle.Charge = Charge;
|
||||||
|
}
|
||||||
|
if (Rest > 0)
|
||||||
|
{
|
||||||
|
vehicle.Rest = Rest;
|
||||||
|
}
|
||||||
|
if (Lift > 0)
|
||||||
|
{
|
||||||
|
vehicle.Lift = Lift;
|
||||||
|
}
|
||||||
|
if (Clamp > 0)
|
||||||
|
{
|
||||||
|
vehicle.Clamp = Clamp;
|
||||||
|
}
|
||||||
|
if (Tray > 0)
|
||||||
|
{
|
||||||
|
vehicle.Tray = Tray;
|
||||||
|
}
|
||||||
|
if (Roll > 0)
|
||||||
|
{
|
||||||
|
vehicle.Roll = Roll;
|
||||||
|
}
|
||||||
|
if (Shutdown > 0)
|
||||||
|
{
|
||||||
|
vehicle.Shutdown = Shutdown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ushort ReadUInt16(byte[] bytes, int offset)
|
||||||
|
{
|
||||||
|
return System.BitConverter.ToUInt16(bytes, offset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace StandardScene.Fass2Simulator
|
||||||
|
{
|
||||||
|
public sealed class Fass2SimNodeProfile
|
||||||
|
{
|
||||||
|
public int ActionDelayMs { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class Fass2SimNodeProfileStore
|
||||||
|
{
|
||||||
|
private readonly Dictionary<ushort, Fass2SimNodeProfile> _profiles = new Dictionary<ushort, Fass2SimNodeProfile>();
|
||||||
|
|
||||||
|
public void Load(IDictionary<string, Fass2SimNodeProfile> nodes)
|
||||||
|
{
|
||||||
|
_profiles.Clear();
|
||||||
|
if (nodes == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var pair in nodes)
|
||||||
|
{
|
||||||
|
if (!ushort.TryParse(pair.Key, out var nodeId))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_profiles[nodeId] = pair.Value ?? new Fass2SimNodeProfile();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int ResolveActionDelayMs(ushort nodeId, int fallbackMs)
|
||||||
|
{
|
||||||
|
if (_profiles.TryGetValue(nodeId, out var profile) && profile.ActionDelayMs > 0)
|
||||||
|
{
|
||||||
|
return profile.ActionDelayMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallbackMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace StandardScene.Fass2Simulator
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// FASS 2.0 PCB/UDP 报文编解码,字段布局对齐
|
||||||
|
/// <c>StandardScene.Magnetic.Protocol.Fass2Protocol.ParseState</c> 与 backend PCB 仿真器。
|
||||||
|
/// </summary>
|
||||||
|
public static class Fass2SimProtocol
|
||||||
|
{
|
||||||
|
public const byte Begin = 0xBB;
|
||||||
|
public const byte End = 0xEE;
|
||||||
|
|
||||||
|
public const byte CmdQuery = 0x00;
|
||||||
|
public const byte CmdStart = 0x01;
|
||||||
|
public const byte CmdStop = 0x02;
|
||||||
|
public const byte CmdEmergencyStop = 0x03;
|
||||||
|
public const byte CmdReset = 0x04;
|
||||||
|
public const byte CmdRest = 0x05;
|
||||||
|
public const byte CmdShutdown = 0x06;
|
||||||
|
public const byte CmdAction = 0xA1;
|
||||||
|
public const byte CmdNodes = 0xB1;
|
||||||
|
public const byte CmdStateResponse = 0x10;
|
||||||
|
|
||||||
|
public const int ControlFrameLength = 50;
|
||||||
|
public const int StateFrameLength = 100;
|
||||||
|
public const int ActionFrameLength = 100;
|
||||||
|
public const int NodesFrameLength = 300;
|
||||||
|
|
||||||
|
public static byte[] BuildState(Fass2SimVehicleSnapshot snapshot)
|
||||||
|
{
|
||||||
|
var frame = new byte[StateFrameLength];
|
||||||
|
frame[0] = Begin;
|
||||||
|
frame[1] = snapshot.LastCommand;
|
||||||
|
WriteUInt16(frame, 2, snapshot.Car);
|
||||||
|
WriteUInt16(frame, 4, snapshot.Length);
|
||||||
|
WriteUInt16(frame, 6, snapshot.Width);
|
||||||
|
|
||||||
|
var carType = Encoding.ASCII.GetBytes("Fass2Sim".PadRight(16, '\0'));
|
||||||
|
Buffer.BlockCopy(carType, 0, frame, 12, Math.Min(16, carType.Length));
|
||||||
|
|
||||||
|
frame[28] = snapshot.BatteryCharge;
|
||||||
|
frame[29] = snapshot.BatteryHealth;
|
||||||
|
WriteUInt16(frame, 30, snapshot.BatteryCurrent);
|
||||||
|
WriteUInt16(frame, 32, snapshot.BatteryVoltage);
|
||||||
|
WriteUInt16(frame, 34, snapshot.HeadingAngle);
|
||||||
|
frame[36] = snapshot.State;
|
||||||
|
WriteUInt64(frame, 37, snapshot.Alarm);
|
||||||
|
WriteUInt64(frame, 45, snapshot.Task);
|
||||||
|
|
||||||
|
var nodeBytes = BuildNodeBytes(snapshot);
|
||||||
|
Buffer.BlockCopy(nodeBytes, 0, frame, 53, 25);
|
||||||
|
|
||||||
|
frame[98] = Xor(frame, 1, 97);
|
||||||
|
frame[99] = End;
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] BuildNodeBytes(Fass2SimVehicleSnapshot snapshot)
|
||||||
|
{
|
||||||
|
var bytes = new byte[25];
|
||||||
|
WriteUInt16(bytes, 0, snapshot.Node);
|
||||||
|
WriteUInt16(bytes, 2, snapshot.Distance);
|
||||||
|
bytes[4] = snapshot.StartStop;
|
||||||
|
bytes[5] = snapshot.Direction;
|
||||||
|
bytes[6] = snapshot.Orientation;
|
||||||
|
bytes[7] = snapshot.Byroad;
|
||||||
|
WriteUInt16(bytes, 8, snapshot.Speed);
|
||||||
|
bytes[10] = snapshot.Obstacle;
|
||||||
|
bytes[11] = snapshot.Audio;
|
||||||
|
bytes[12] = snapshot.Light;
|
||||||
|
bytes[13] = snapshot.Charge;
|
||||||
|
bytes[14] = snapshot.Rest;
|
||||||
|
bytes[15] = snapshot.Lift;
|
||||||
|
bytes[16] = snapshot.Clamp;
|
||||||
|
bytes[17] = snapshot.Tray;
|
||||||
|
bytes[18] = snapshot.Roll;
|
||||||
|
bytes[19] = snapshot.Shutdown;
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryGetCommand(byte[] packet, out byte command, out int expectedLength)
|
||||||
|
{
|
||||||
|
command = 0;
|
||||||
|
expectedLength = 0;
|
||||||
|
if (packet == null || packet.Length < 2 || packet[0] != Begin)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
command = packet[1];
|
||||||
|
expectedLength = ResolvePacketLength(command);
|
||||||
|
return expectedLength > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int ResolvePacketLength(byte command)
|
||||||
|
{
|
||||||
|
switch (command)
|
||||||
|
{
|
||||||
|
case CmdQuery:
|
||||||
|
case CmdStart:
|
||||||
|
case CmdStop:
|
||||||
|
case CmdEmergencyStop:
|
||||||
|
case CmdReset:
|
||||||
|
case CmdRest:
|
||||||
|
case CmdShutdown:
|
||||||
|
case CmdStateResponse:
|
||||||
|
return ControlFrameLength;
|
||||||
|
case CmdAction:
|
||||||
|
return ActionFrameLength;
|
||||||
|
case CmdNodes:
|
||||||
|
return NodesFrameLength;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string CommandName(byte command)
|
||||||
|
{
|
||||||
|
switch (command)
|
||||||
|
{
|
||||||
|
case CmdQuery: return "Query(0x00)";
|
||||||
|
case CmdStart: return "Start(0x01)";
|
||||||
|
case CmdStop: return "Stop(0x02)";
|
||||||
|
case CmdEmergencyStop: return "EmergencyStop(0x03)";
|
||||||
|
case CmdReset: return "Reset(0x04)";
|
||||||
|
case CmdRest: return "Rest(0x05)";
|
||||||
|
case CmdShutdown: return "Shutdown(0x06)";
|
||||||
|
case CmdAction: return "Action(0xA1)";
|
||||||
|
case CmdNodes: return "Nodes(0xB1)";
|
||||||
|
case CmdStateResponse: return "StateResponse(0x10)";
|
||||||
|
default: return $"Unknown(0x{command:X2})";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string StateText(byte state)
|
||||||
|
{
|
||||||
|
switch (state)
|
||||||
|
{
|
||||||
|
case 0: return "未准备";
|
||||||
|
case 1: return "运行中";
|
||||||
|
case 2: return "停止中";
|
||||||
|
case 3: return "急停中";
|
||||||
|
case 4: return "故障中";
|
||||||
|
case 5: return "任务中";
|
||||||
|
case 6: return "休眠中";
|
||||||
|
case 7: return "关机中";
|
||||||
|
case 8: return "充电中";
|
||||||
|
default: return $"未知({state})";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ToHex(byte[] bytes)
|
||||||
|
{
|
||||||
|
return bytes == null ? string.Empty : string.Join(" ", bytes.Select(b => b.ToString("X2")));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte Xor(byte[] data, int start, int length)
|
||||||
|
{
|
||||||
|
byte xor = 0;
|
||||||
|
for (var i = 0; i < length; i++)
|
||||||
|
{
|
||||||
|
xor ^= data[start + i];
|
||||||
|
}
|
||||||
|
return xor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryParseNodes(byte[] packet, out ulong taskId, out Fass2SimNodeMessage[] nodes)
|
||||||
|
{
|
||||||
|
taskId = 0;
|
||||||
|
nodes = Array.Empty<Fass2SimNodeMessage>();
|
||||||
|
if (packet == null || packet.Length < NodesFrameLength || packet[1] != CmdNodes)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
taskId = BitConverter.ToUInt64(packet, 4);
|
||||||
|
var count = BitConverter.ToUInt16(packet, 12);
|
||||||
|
if (count == 0 || count > 10)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var requiredLength = 14 + count * 25 + 2;
|
||||||
|
if (packet.Length < requiredLength)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes = new Fass2SimNodeMessage[count];
|
||||||
|
for (var i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
nodes[i] = Fass2SimNodeMessage.FromBytes(packet, 14 + i * 25);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryParseAction(byte[] packet, out ulong actionId, out Fass2SimNodeMessage node)
|
||||||
|
{
|
||||||
|
actionId = 0;
|
||||||
|
node = null;
|
||||||
|
if (packet == null || packet.Length < ActionFrameLength || packet[1] != CmdAction)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
actionId = BitConverter.ToUInt64(packet, 4);
|
||||||
|
node = Fass2SimNodeMessage.FromBytes(packet, 14);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteUInt16(byte[] buffer, int offset, ushort value)
|
||||||
|
{
|
||||||
|
var bytes = BitConverter.GetBytes(value);
|
||||||
|
buffer[offset] = bytes[0];
|
||||||
|
buffer[offset + 1] = bytes[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteUInt64(byte[] buffer, int offset, ulong value)
|
||||||
|
{
|
||||||
|
var bytes = BitConverter.GetBytes(value);
|
||||||
|
Buffer.BlockCopy(bytes, 0, buffer, offset, 8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace StandardScene.Fass2Simulator
|
||||||
|
{
|
||||||
|
public sealed class Fass2SimRuntime : IDisposable
|
||||||
|
{
|
||||||
|
private readonly Fass2SimUdpHost _host;
|
||||||
|
|
||||||
|
public Fass2SimRuntime(Fass2SimConfig config, Fass2SimNodeProfileStore profiles)
|
||||||
|
{
|
||||||
|
Config = config;
|
||||||
|
Vehicle = new Fass2SimVehicle(config);
|
||||||
|
Actions = new Fass2SimActionEngine(Vehicle, config, profiles);
|
||||||
|
Motion = new Fass2SimMotionEngine(Vehicle, config, Actions);
|
||||||
|
Actions.StationActionsCompleted += Motion.OnStationActionsCompleted;
|
||||||
|
var commandHandler = new Fass2SimCommandHandler(Vehicle, Motion, Actions, config.LogRawFrames);
|
||||||
|
_host = new Fass2SimUdpHost(config, Vehicle, commandHandler, Motion, Actions);
|
||||||
|
_host.StatusReported += (_, __) => StatusChanged?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Fass2SimConfig Config { get; }
|
||||||
|
|
||||||
|
public Fass2SimVehicle Vehicle { get; }
|
||||||
|
|
||||||
|
public Fass2SimMotionEngine Motion { get; }
|
||||||
|
|
||||||
|
public Fass2SimActionEngine Actions { get; }
|
||||||
|
|
||||||
|
public bool IsRunning => _host.IsRunning;
|
||||||
|
|
||||||
|
public event Action StatusChanged;
|
||||||
|
|
||||||
|
public void Start()
|
||||||
|
{
|
||||||
|
_host.Start();
|
||||||
|
StatusChanged?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Stop()
|
||||||
|
{
|
||||||
|
_host.Stop();
|
||||||
|
StatusChanged?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_host.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace StandardScene.Fass2Simulator
|
||||||
|
{
|
||||||
|
public static class Fass2SimSelfTests
|
||||||
|
{
|
||||||
|
public static int Run(string[] args)
|
||||||
|
{
|
||||||
|
if (HasArg(args, "--motion-test"))
|
||||||
|
{
|
||||||
|
return RunMotionSelfTest();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (HasArg(args, "--action-test"))
|
||||||
|
{
|
||||||
|
return RunActionSelfTest();
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int RunMotionSelfTest()
|
||||||
|
{
|
||||||
|
var config = new Fass2SimConfig
|
||||||
|
{
|
||||||
|
InitialNode = 1,
|
||||||
|
DefaultSegmentDistance = 500,
|
||||||
|
DefaultSpeed = 500,
|
||||||
|
AutoContinueOnPass = true
|
||||||
|
};
|
||||||
|
var (vehicle, motion, _) = Fass2SimBootstrap.CreateTestStack(config);
|
||||||
|
var nodes = new[]
|
||||||
|
{
|
||||||
|
new Fass2SimNodeMessage { Node = 1, StartStop = 1 },
|
||||||
|
new Fass2SimNodeMessage { Node = 2, StartStop = 1, Distance = 500 },
|
||||||
|
new Fass2SimNodeMessage { Node = 3, StartStop = 2, Distance = 500 }
|
||||||
|
};
|
||||||
|
|
||||||
|
vehicle.State = 1;
|
||||||
|
motion.OnNodesCommand(1001, nodes);
|
||||||
|
|
||||||
|
for (var i = 0; i < 30; i++)
|
||||||
|
{
|
||||||
|
motion.Tick(200);
|
||||||
|
var snap = vehicle.Snapshot();
|
||||||
|
if (snap.Node == 3 && snap.State == 2)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"motion self-test ok: node={snap.Node}, task={snap.Task}");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var last = vehicle.Snapshot();
|
||||||
|
Console.WriteLine($"motion self-test failed: node={last.Node}, state={last.State}, task={last.Task}");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int RunActionSelfTest()
|
||||||
|
{
|
||||||
|
var config = new Fass2SimConfig
|
||||||
|
{
|
||||||
|
InitialNode = 3,
|
||||||
|
ActionDelayMs = 500,
|
||||||
|
AutoCompleteStationActions = false
|
||||||
|
};
|
||||||
|
var (vehicle, _, actions) = Fass2SimBootstrap.CreateTestStack(config);
|
||||||
|
vehicle.State = 2;
|
||||||
|
vehicle.Node = 3;
|
||||||
|
vehicle.Lift = 0;
|
||||||
|
|
||||||
|
var station = new Fass2SimNodeMessage
|
||||||
|
{
|
||||||
|
Node = 3,
|
||||||
|
StartStop = Fass2SimActionResolver.StartStopStop,
|
||||||
|
Lift = 1
|
||||||
|
};
|
||||||
|
actions.OnStationArrival(station);
|
||||||
|
|
||||||
|
var pendingBefore = Fass2SimActionResolver.DescribePending(station, vehicle.Snapshot());
|
||||||
|
if (string.IsNullOrEmpty(pendingBefore) || vehicle.Lift != 0)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"action self-test failed at setup: pending={pendingBefore}, lift={vehicle.Lift}");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
actions.OnActionCommand(2001, new Fass2SimNodeMessage { Node = 3, Lift = 1 });
|
||||||
|
|
||||||
|
for (var i = 0; i < 10; i++)
|
||||||
|
{
|
||||||
|
actions.Tick(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
var snap = vehicle.Snapshot();
|
||||||
|
if (snap.Lift == 1 && actions.IsStationComplete())
|
||||||
|
{
|
||||||
|
Console.WriteLine($"action self-test ok: node={snap.Node}, lift={snap.Lift}");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine(
|
||||||
|
$"action self-test failed: lift={snap.Lift}, complete={actions.IsStationComplete()}, pending={Fass2SimActionResolver.DescribePending(station, snap)}");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasArg(string[] args, string name)
|
||||||
|
{
|
||||||
|
if (args == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var arg in args)
|
||||||
|
{
|
||||||
|
if (string.Equals(arg, name, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
using System;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Threading;
|
||||||
|
|
||||||
|
namespace StandardScene.Fass2Simulator
|
||||||
|
{
|
||||||
|
public sealed class Fass2SimUdpHost : IDisposable
|
||||||
|
{
|
||||||
|
private readonly Fass2SimConfig _config;
|
||||||
|
private readonly Fass2SimVehicle _vehicle;
|
||||||
|
private readonly Fass2SimCommandHandler _commandHandler;
|
||||||
|
private readonly Fass2SimMotionEngine _motionEngine;
|
||||||
|
private readonly Fass2SimActionEngine _actionEngine;
|
||||||
|
private readonly IPEndPoint _schedulerEndpoint;
|
||||||
|
|
||||||
|
private UdpClient _listener;
|
||||||
|
private UdpClient _sender;
|
||||||
|
private Thread _receiveThread;
|
||||||
|
private Timer _reportTimer;
|
||||||
|
private volatile bool _running;
|
||||||
|
|
||||||
|
public Fass2SimUdpHost(
|
||||||
|
Fass2SimConfig config,
|
||||||
|
Fass2SimVehicle vehicle,
|
||||||
|
Fass2SimCommandHandler commandHandler,
|
||||||
|
Fass2SimMotionEngine motionEngine,
|
||||||
|
Fass2SimActionEngine actionEngine)
|
||||||
|
{
|
||||||
|
_config = config;
|
||||||
|
_vehicle = vehicle;
|
||||||
|
_commandHandler = commandHandler;
|
||||||
|
_motionEngine = motionEngine;
|
||||||
|
_actionEngine = actionEngine;
|
||||||
|
_schedulerEndpoint = new IPEndPoint(IPAddress.Parse(config.SchedulerHost), config.SchedulerListenPort);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsRunning => _running;
|
||||||
|
|
||||||
|
public event EventHandler StatusReported;
|
||||||
|
|
||||||
|
public void Start()
|
||||||
|
{
|
||||||
|
if (_running)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var listenAddress = IPAddress.Parse(_config.VehicleListenAddress);
|
||||||
|
_listener = new UdpClient(new IPEndPoint(listenAddress, _config.VehicleListenPort));
|
||||||
|
_sender = new UdpClient();
|
||||||
|
_running = true;
|
||||||
|
|
||||||
|
_receiveThread = new Thread(ReceiveLoop)
|
||||||
|
{
|
||||||
|
IsBackground = true,
|
||||||
|
Name = $"Fass2SimRx:{_config.VehicleListenPort}"
|
||||||
|
};
|
||||||
|
_receiveThread.Start();
|
||||||
|
|
||||||
|
var interval = Math.Max(50, _config.ReportIntervalMs);
|
||||||
|
_reportTimer = new Timer(SendStateReport, null, 0, interval);
|
||||||
|
|
||||||
|
Fass2SimLog.WriteLine($"[{Now()}] 模拟车已启动");
|
||||||
|
Fass2SimLog.WriteLine($" 车体监听 : {_config.VehicleListenAddress}:{_config.VehicleListenPort}");
|
||||||
|
Fass2SimLog.WriteLine($" 上报目标 : {_config.SchedulerHost}:{_config.SchedulerListenPort}");
|
||||||
|
Fass2SimLog.WriteLine($" 车号 : {_config.VehicleCode}");
|
||||||
|
Fass2SimLog.WriteLine($" 上报周期 : {interval} ms");
|
||||||
|
Fass2SimLog.WriteLine($" 路段模拟 : dist={_config.DefaultSegmentDistance}mm 或 node.Distance, speed={_config.DefaultSpeed}mm/s");
|
||||||
|
Fass2SimLog.WriteLine($" 动作延时 : {_config.ActionDelayMs}ms(0xA1 到位),AutoComplete={_config.AutoCompleteStationActions}");
|
||||||
|
Fass2SimLog.WriteLine($" 初始节点 : {_config.InitialNode}, 状态={Fass2SimProtocol.StateText(_config.InitialState)}");
|
||||||
|
Fass2SimLog.WriteLine("等待 MagFass2Car 联调(address=127.0.0.1, Port=5000, ListenPort=20103, VehicleCode=1)");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Stop()
|
||||||
|
{
|
||||||
|
_running = false;
|
||||||
|
_reportTimer?.Dispose();
|
||||||
|
_reportTimer = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_listener?.Close();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_receiveThread?.Join(1000);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
_receiveThread = null;
|
||||||
|
_listener = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_sender?.Close();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
_sender = null;
|
||||||
|
Fass2SimLog.WriteLine($"[{Now()}] 模拟车已停止");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReceiveLoop()
|
||||||
|
{
|
||||||
|
while (_running)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var remote = new IPEndPoint(IPAddress.Any, 0);
|
||||||
|
var packet = _listener.Receive(ref remote);
|
||||||
|
_commandHandler.Handle(packet, remote.ToString());
|
||||||
|
}
|
||||||
|
catch (SocketException) when (!_running)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (ObjectDisposedException) when (!_running)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Fass2SimLog.WriteLine($"[{Now()}] 接收异常: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SendStateReport(object _)
|
||||||
|
{
|
||||||
|
if (!_running)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var interval = Math.Max(50, _config.ReportIntervalMs);
|
||||||
|
_actionEngine.Tick(interval);
|
||||||
|
_motionEngine.Tick(interval);
|
||||||
|
|
||||||
|
var snapshot = _vehicle.Snapshot();
|
||||||
|
var frame = Fass2SimProtocol.BuildState(snapshot);
|
||||||
|
_sender.Send(frame, frame.Length, _schedulerEndpoint);
|
||||||
|
_vehicle.RecordStateReportSent();
|
||||||
|
StatusReported?.Invoke(this, EventArgs.Empty);
|
||||||
|
|
||||||
|
if (snapshot.StateReportsSent == 0 || snapshot.StateReportsSent % 25 == 0)
|
||||||
|
{
|
||||||
|
var pending = _actionEngine.ExpectedStation == null
|
||||||
|
? "-"
|
||||||
|
: Fass2SimActionResolver.DescribePending(_actionEngine.ExpectedStation, snapshot);
|
||||||
|
if (string.IsNullOrEmpty(pending))
|
||||||
|
{
|
||||||
|
pending = "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
Fass2SimLog.WriteLine(
|
||||||
|
$"[{Now()}] TX 状态 #{snapshot.StateReportsSent + 1}: node={snapshot.Node}, dist={snapshot.Distance}, state={Fass2SimProtocol.StateText(snapshot.State)}, lift={snapshot.Lift}, pending={pending}, task={snapshot.Task}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Fass2SimLog.WriteLine($"[{Now()}] 上报异常: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Now()
|
||||||
|
{
|
||||||
|
return DateTime.Now.ToString("HH:mm:ss.fff");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace StandardScene.Fass2Simulator
|
||||||
|
{
|
||||||
|
public sealed class Fass2SimVehicle
|
||||||
|
{
|
||||||
|
private readonly object _syncRoot = new object();
|
||||||
|
|
||||||
|
public Fass2SimVehicle(Fass2SimConfig config)
|
||||||
|
{
|
||||||
|
Car = config.VehicleCode;
|
||||||
|
Length = config.CarLength;
|
||||||
|
Width = config.CarWidth;
|
||||||
|
BatteryCharge = config.BatteryCharge;
|
||||||
|
BatteryHealth = 100;
|
||||||
|
BatteryVoltage = 480;
|
||||||
|
State = config.InitialState;
|
||||||
|
Node = config.InitialNode;
|
||||||
|
Speed = 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ushort Car { get; }
|
||||||
|
public ushort Length { get; set; }
|
||||||
|
public ushort Width { get; set; }
|
||||||
|
public byte BatteryCharge { get; set; }
|
||||||
|
public byte BatteryHealth { get; set; }
|
||||||
|
public ushort BatteryCurrent { get; set; }
|
||||||
|
public ushort BatteryVoltage { get; set; }
|
||||||
|
public ushort HeadingAngle { get; set; }
|
||||||
|
public byte State { get; set; }
|
||||||
|
public ulong Alarm { get; set; }
|
||||||
|
public ulong Task { get; set; }
|
||||||
|
public byte LastCommand { get; set; }
|
||||||
|
|
||||||
|
public ushort Node { get; set; }
|
||||||
|
public ushort Distance { get; set; }
|
||||||
|
public byte StartStop { get; set; }
|
||||||
|
public byte Direction { get; set; }
|
||||||
|
public byte Orientation { get; set; }
|
||||||
|
public byte Byroad { get; set; }
|
||||||
|
public ushort Speed { get; set; }
|
||||||
|
public byte Obstacle { get; set; }
|
||||||
|
public byte Audio { get; set; }
|
||||||
|
public byte Light { get; set; }
|
||||||
|
public byte Charge { get; set; }
|
||||||
|
public byte Rest { get; set; }
|
||||||
|
public byte Lift { get; set; }
|
||||||
|
public byte Clamp { get; set; }
|
||||||
|
public byte Tray { get; set; }
|
||||||
|
public byte Roll { get; set; }
|
||||||
|
public byte Shutdown { get; set; }
|
||||||
|
|
||||||
|
public long StateReportsSent { get; private set; }
|
||||||
|
public long CommandsReceived { get; private set; }
|
||||||
|
public long AckReceived { get; private set; }
|
||||||
|
public DateTime LastAckUtc { get; private set; } = DateTime.MinValue;
|
||||||
|
|
||||||
|
public void RecordStateReportSent()
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
StateReportsSent++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RecordCommandReceived()
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
CommandsReceived++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RecordAckReceived()
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
AckReceived++;
|
||||||
|
LastAckUtc = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Fass2SimVehicleSnapshot Snapshot()
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
return CopySnapshotUnsafe();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 供已持有其它引擎锁的代码路径读取快照,避免与 UI 线程 Snapshot() 争用 _syncRoot 导致死锁。
|
||||||
|
/// </summary>
|
||||||
|
internal Fass2SimVehicleSnapshot CopySnapshotUnsafe()
|
||||||
|
{
|
||||||
|
return new Fass2SimVehicleSnapshot
|
||||||
|
{
|
||||||
|
Car = Car,
|
||||||
|
Length = Length,
|
||||||
|
Width = Width,
|
||||||
|
BatteryCharge = BatteryCharge,
|
||||||
|
BatteryHealth = BatteryHealth,
|
||||||
|
BatteryCurrent = BatteryCurrent,
|
||||||
|
BatteryVoltage = BatteryVoltage,
|
||||||
|
HeadingAngle = HeadingAngle,
|
||||||
|
State = State,
|
||||||
|
Alarm = Alarm,
|
||||||
|
Task = Task,
|
||||||
|
LastCommand = LastCommand,
|
||||||
|
Node = Node,
|
||||||
|
Distance = Distance,
|
||||||
|
StartStop = StartStop,
|
||||||
|
Direction = Direction,
|
||||||
|
Orientation = Orientation,
|
||||||
|
Byroad = Byroad,
|
||||||
|
Speed = Speed,
|
||||||
|
Obstacle = Obstacle,
|
||||||
|
Audio = Audio,
|
||||||
|
Light = Light,
|
||||||
|
Charge = Charge,
|
||||||
|
Rest = Rest,
|
||||||
|
Lift = Lift,
|
||||||
|
Clamp = Clamp,
|
||||||
|
Tray = Tray,
|
||||||
|
Roll = Roll,
|
||||||
|
Shutdown = Shutdown,
|
||||||
|
StateReportsSent = StateReportsSent,
|
||||||
|
CommandsReceived = CommandsReceived,
|
||||||
|
AckReceived = AckReceived,
|
||||||
|
LastAckUtc = LastAckUtc
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class Fass2SimVehicleSnapshot
|
||||||
|
{
|
||||||
|
public ushort Car { get; set; }
|
||||||
|
public ushort Length { get; set; }
|
||||||
|
public ushort Width { get; set; }
|
||||||
|
public byte BatteryCharge { get; set; }
|
||||||
|
public byte BatteryHealth { get; set; }
|
||||||
|
public ushort BatteryCurrent { get; set; }
|
||||||
|
public ushort BatteryVoltage { get; set; }
|
||||||
|
public ushort HeadingAngle { get; set; }
|
||||||
|
public byte State { get; set; }
|
||||||
|
public ulong Alarm { get; set; }
|
||||||
|
public ulong Task { get; set; }
|
||||||
|
public byte LastCommand { get; set; }
|
||||||
|
public ushort Node { get; set; }
|
||||||
|
public ushort Distance { get; set; }
|
||||||
|
public byte StartStop { get; set; }
|
||||||
|
public byte Direction { get; set; }
|
||||||
|
public byte Orientation { get; set; }
|
||||||
|
public byte Byroad { get; set; }
|
||||||
|
public ushort Speed { get; set; }
|
||||||
|
public byte Obstacle { get; set; }
|
||||||
|
public byte Audio { get; set; }
|
||||||
|
public byte Light { get; set; }
|
||||||
|
public byte Charge { get; set; }
|
||||||
|
public byte Rest { get; set; }
|
||||||
|
public byte Lift { get; set; }
|
||||||
|
public byte Clamp { get; set; }
|
||||||
|
public byte Tray { get; set; }
|
||||||
|
public byte Roll { get; set; }
|
||||||
|
public byte Shutdown { get; set; }
|
||||||
|
public long StateReportsSent { get; set; }
|
||||||
|
public long CommandsReceived { get; set; }
|
||||||
|
public long AckReceived { get; set; }
|
||||||
|
public DateTime LastAckUtc { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<Window x:Class="StandardScene.Fass2Simulator.MainWindow"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
Title="FASS2 车体模拟器"
|
||||||
|
Height="720"
|
||||||
|
Width="1080"
|
||||||
|
MinHeight="560"
|
||||||
|
MinWidth="900"
|
||||||
|
Background="White"
|
||||||
|
WindowStartupLocation="CenterScreen">
|
||||||
|
<Grid Margin="16">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<DockPanel Grid.Row="0" Margin="0,0,0,12">
|
||||||
|
<StackPanel DockPanel.Dock="Left" Orientation="Horizontal" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="FASS2 车体模拟器" FontSize="22" FontWeight="SemiBold" Foreground="#111827"/>
|
||||||
|
<Border Margin="16,0,0,0" Padding="10,4" CornerRadius="12" Background="#E5E7EB">
|
||||||
|
<TextBlock x:Name="RunStateText" Text="已停止" FontWeight="SemiBold" Foreground="#6B7280"/>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" HorizontalAlignment="Right">
|
||||||
|
<Button x:Name="StartButton" Content="启动模拟" Width="110" Height="36" Margin="0,0,8,0"
|
||||||
|
Background="#2563EB" Foreground="White" BorderThickness="0" FontWeight="SemiBold" Click="StartButton_Click"/>
|
||||||
|
<Button x:Name="StopButton" Content="停止" Width="90" Height="36" Margin="0,0,8,0"
|
||||||
|
IsEnabled="False" Click="StopButton_Click"/>
|
||||||
|
<Button x:Name="ClearLogButton" Content="清空界面" Width="90" Height="36" Margin="0,0,8,0" Click="ClearLogButton_Click"/>
|
||||||
|
<Button x:Name="OpenLogFolderButton" Content="打开日志目录" Width="110" Height="36" Click="OpenLogFolderButton_Click"/>
|
||||||
|
</StackPanel>
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
|
<Grid Grid.Row="1" Margin="0,0,0,12">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="320"/>
|
||||||
|
<ColumnDefinition Width="16"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<Border Grid.Column="0" Background="{StaticResource PanelBrush}" BorderBrush="{StaticResource BorderBrush}"
|
||||||
|
BorderThickness="1" CornerRadius="8" Padding="14">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="联调参数" FontSize="16" FontWeight="SemiBold" Margin="0,0,0,12"/>
|
||||||
|
<TextBlock Text="车号" Margin="0,0,0,4"/>
|
||||||
|
<TextBox x:Name="VehicleCodeBox" Height="28" Margin="0,0,0,10"/>
|
||||||
|
<TextBlock Text="车体监听地址" Margin="0,0,0,4"/>
|
||||||
|
<TextBox x:Name="ListenAddressBox" Height="28" Margin="0,0,0,10"/>
|
||||||
|
<TextBlock Text="车体监听端口" Margin="0,0,0,4"/>
|
||||||
|
<TextBox x:Name="ListenPortBox" Height="28" Margin="0,0,0,10"/>
|
||||||
|
<TextBlock Text="调度地址" Margin="0,0,0,4"/>
|
||||||
|
<TextBox x:Name="SchedulerHostBox" Height="28" Margin="0,0,0,10"/>
|
||||||
|
<TextBlock Text="调度监听端口" Margin="0,0,0,4"/>
|
||||||
|
<TextBox x:Name="SchedulerPortBox" Height="28" Margin="0,0,0,10"/>
|
||||||
|
<TextBlock Text="上报周期 (ms)" Margin="0,0,0,4"/>
|
||||||
|
<TextBox x:Name="ReportIntervalBox" Height="28" Margin="0,0,0,10"/>
|
||||||
|
<TextBlock Text="初始节点" Margin="0,0,0,4"/>
|
||||||
|
<TextBox x:Name="InitialNodeBox" Height="28" Margin="0,0,0,10"/>
|
||||||
|
<CheckBox x:Name="AutoContinueBox" Content="途经站自动续跑" Margin="0,0,0,6"/>
|
||||||
|
<CheckBox x:Name="AutoCompleteBox" Content="到站自动补全动作" Margin="0,0,0,6"/>
|
||||||
|
<CheckBox x:Name="LogRawFramesBox" Content="记录原始报文"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Grid.Column="2" Background="{StaticResource PanelBrush}" BorderBrush="{StaticResource BorderBrush}"
|
||||||
|
BorderThickness="1" CornerRadius="8" Padding="14">
|
||||||
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<TextBlock Text="车体状态" FontSize="16" FontWeight="SemiBold" Margin="0,0,0,12"/>
|
||||||
|
<UniformGrid Grid.Row="1" Columns="4" Rows="4">
|
||||||
|
<StackPanel Margin="0,0,12,10"><TextBlock Text="上报节点" Foreground="#6B7280"/><TextBlock x:Name="NodeText" Text="-" FontSize="18" FontWeight="SemiBold"/></StackPanel>
|
||||||
|
<StackPanel Margin="0,0,12,10"><TextBlock Text="目标节点" Foreground="#6B7280"/><TextBlock x:Name="TargetNodeText" Text="-" FontSize="18" FontWeight="SemiBold"/></StackPanel>
|
||||||
|
<StackPanel Margin="0,0,12,10"><TextBlock Text="行驶距离 (mm)" Foreground="#6B7280"/><TextBlock x:Name="DistanceText" Text="-" FontSize="18" FontWeight="SemiBold"/></StackPanel>
|
||||||
|
<StackPanel Margin="0,0,12,10"><TextBlock Text="车体状态" Foreground="#6B7280"/><TextBlock x:Name="StateText" Text="-" FontSize="18" FontWeight="SemiBold"/></StackPanel>
|
||||||
|
<StackPanel Margin="0,0,12,10"><TextBlock Text="任务号" Foreground="#6B7280"/><TextBlock x:Name="TaskText" Text="-" FontSize="18" FontWeight="SemiBold"/></StackPanel>
|
||||||
|
<StackPanel Margin="0,0,12,10"><TextBlock Text="StartStop" Foreground="#6B7280"/><TextBlock x:Name="StartStopText" Text="-" FontSize="18" FontWeight="SemiBold"/></StackPanel>
|
||||||
|
<StackPanel Margin="0,0,12,10"><TextBlock Text="Lift" Foreground="#6B7280"/><TextBlock x:Name="LiftText" Text="-" FontSize="18" FontWeight="SemiBold"/></StackPanel>
|
||||||
|
<StackPanel Margin="0,0,12,10"><TextBlock Text="电量" Foreground="#6B7280"/><TextBlock x:Name="BatteryText" Text="-" FontSize="18" FontWeight="SemiBold"/></StackPanel>
|
||||||
|
<StackPanel Margin="0,0,12,10"><TextBlock Text="待动作" Foreground="#6B7280"/><TextBlock x:Name="PendingText" Text="-" FontSize="18" FontWeight="SemiBold"/></StackPanel>
|
||||||
|
<StackPanel Margin="0,0,12,10"><TextBlock Text="状态上报" Foreground="#6B7280"/><TextBlock x:Name="ReportsText" Text="0" FontSize="18" FontWeight="SemiBold"/></StackPanel>
|
||||||
|
<StackPanel Margin="0,0,12,10"><TextBlock Text="收令次数" Foreground="#6B7280"/><TextBlock x:Name="CommandsText" Text="0" FontSize="18" FontWeight="SemiBold"/></StackPanel>
|
||||||
|
<StackPanel Margin="0,0,12,10"><TextBlock Text="0x10 应答" Foreground="#6B7280"/><TextBlock x:Name="AckText" Text="0" FontSize="18" FontWeight="SemiBold"/></StackPanel>
|
||||||
|
<StackPanel Margin="0,0,12,10"><TextBlock Text="最近应答" Foreground="#6B7280"/><TextBlock x:Name="LastAckText" Text="-" FontSize="18" FontWeight="SemiBold"/></StackPanel>
|
||||||
|
</UniformGrid>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Border Grid.Row="2" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" CornerRadius="8" Padding="10">
|
||||||
|
<DockPanel>
|
||||||
|
<StackPanel DockPanel.Dock="Top" Margin="0,0,0,8">
|
||||||
|
<TextBlock Text="运行日志(界面仅保留最近 800 行,完整日志写入本地文件)" FontSize="16" FontWeight="SemiBold"/>
|
||||||
|
<TextBlock x:Name="LogFilePathText" Text="日志文件: -" FontSize="12" Foreground="#6B7280" Margin="0,4,0,0" TextWrapping="Wrap"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBox x:Name="LogTextBox"
|
||||||
|
IsReadOnly="True"
|
||||||
|
TextWrapping="NoWrap"
|
||||||
|
VerticalScrollBarVisibility="Auto"
|
||||||
|
HorizontalScrollBarVisibility="Auto"
|
||||||
|
FontFamily="Consolas"
|
||||||
|
FontSize="12"
|
||||||
|
Background="#FAFAFA"
|
||||||
|
BorderThickness="0"/>
|
||||||
|
</DockPanel>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Threading;
|
||||||
|
|
||||||
|
namespace StandardScene.Fass2Simulator
|
||||||
|
{
|
||||||
|
public partial class MainWindow : Window
|
||||||
|
{
|
||||||
|
private const int MaxLogLines = 800;
|
||||||
|
private readonly StringBuilder _logBuffer = new StringBuilder();
|
||||||
|
private int _logLineCount;
|
||||||
|
private Fass2SimConfig _config;
|
||||||
|
private Fass2SimRuntime _runtime;
|
||||||
|
private readonly DispatcherTimer _uiTimer;
|
||||||
|
private readonly Queue<string> _pendingLogLines = new Queue<string>();
|
||||||
|
private readonly object _logQueueLock = new object();
|
||||||
|
private bool _logFlushScheduled;
|
||||||
|
|
||||||
|
public MainWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
_config = Fass2SimBootstrap.LoadConfig();
|
||||||
|
Fass2SimFileLogger.Configure(_config);
|
||||||
|
LoadConfigToUi(_config);
|
||||||
|
SetRunningUi(false);
|
||||||
|
UpdateLogFilePathText();
|
||||||
|
|
||||||
|
Fass2SimLog.MessageWritten += OnLogMessage;
|
||||||
|
_uiTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(250) };
|
||||||
|
_uiTimer.Tick += (_, __) => RefreshStatus();
|
||||||
|
_uiTimer.Start();
|
||||||
|
RefreshStatus();
|
||||||
|
|
||||||
|
Closed += (_, __) =>
|
||||||
|
{
|
||||||
|
Fass2SimLog.MessageWritten -= OnLogMessage;
|
||||||
|
_runtime?.Dispose();
|
||||||
|
Fass2SimFileLogger.Shutdown();
|
||||||
|
};
|
||||||
|
|
||||||
|
AppendLog("就绪。配置参数后点击「启动模拟」开始联调。完整日志将写入 logs 目录。");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartButton_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_runtime != null && _runtime.IsRunning)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_config = ReadConfigFromUi();
|
||||||
|
Fass2SimFileLogger.Configure(_config);
|
||||||
|
Fass2SimFileLogger.BeginSession(_config);
|
||||||
|
UpdateLogFilePathText();
|
||||||
|
_runtime?.Dispose();
|
||||||
|
_runtime = Fass2SimBootstrap.CreateRuntime(_config);
|
||||||
|
_runtime.Start();
|
||||||
|
SetRunningUi(true);
|
||||||
|
RefreshStatus();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MessageBox.Show(this, ex.Message, "启动失败", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StopButton_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_runtime == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_runtime.Stop();
|
||||||
|
SetRunningUi(false);
|
||||||
|
RefreshStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearLogButton_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_logBuffer.Clear();
|
||||||
|
_logLineCount = 0;
|
||||||
|
LogTextBox.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenLogFolderButton_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var directory = Fass2SimFileLogger.GetLogDirectory();
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = directory,
|
||||||
|
UseShellExecute = true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateLogFilePathText()
|
||||||
|
{
|
||||||
|
if (!_config.EnableFileLog)
|
||||||
|
{
|
||||||
|
LogFilePathText.Text = "文件日志已关闭(appsettings.json: EnableFileLog=false)";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var path = Fass2SimFileLogger.CurrentFilePath;
|
||||||
|
if (string.IsNullOrEmpty(path))
|
||||||
|
{
|
||||||
|
LogFilePathText.Text = $"日志目录: {Fass2SimFileLogger.GetLogDirectory()}(启动模拟后生成会话日志)";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LogFilePathText.Text = $"日志文件: {path}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnLogMessage(string message)
|
||||||
|
{
|
||||||
|
lock (_logQueueLock)
|
||||||
|
{
|
||||||
|
_pendingLogLines.Enqueue(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_logFlushScheduled)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logFlushScheduled = true;
|
||||||
|
Dispatcher.BeginInvoke(new Action(FlushPendingLogs), DispatcherPriority.Background);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void FlushPendingLogs()
|
||||||
|
{
|
||||||
|
_logFlushScheduled = false;
|
||||||
|
List<string> batch;
|
||||||
|
lock (_logQueueLock)
|
||||||
|
{
|
||||||
|
if (_pendingLogLines.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
batch = new List<string>(_pendingLogLines.Count);
|
||||||
|
while (_pendingLogLines.Count > 0)
|
||||||
|
{
|
||||||
|
batch.Add(_pendingLogLines.Dequeue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var line in batch)
|
||||||
|
{
|
||||||
|
AppendLog(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AppendLog(string message)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(message))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logBuffer.AppendLine(message);
|
||||||
|
_logLineCount++;
|
||||||
|
while (_logLineCount > MaxLogLines)
|
||||||
|
{
|
||||||
|
var bufferText = _logBuffer.ToString();
|
||||||
|
var firstBreak = bufferText.IndexOf('\n');
|
||||||
|
if (firstBreak < 0)
|
||||||
|
{
|
||||||
|
_logBuffer.Clear();
|
||||||
|
_logLineCount = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logBuffer.Remove(0, firstBreak + 1);
|
||||||
|
_logLineCount--;
|
||||||
|
}
|
||||||
|
|
||||||
|
LogTextBox.Text = _logBuffer.ToString();
|
||||||
|
LogTextBox.CaretIndex = LogTextBox.Text.Length;
|
||||||
|
LogTextBox.ScrollToEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshStatus()
|
||||||
|
{
|
||||||
|
if (_runtime == null || !_runtime.IsRunning)
|
||||||
|
{
|
||||||
|
if (_runtime == null)
|
||||||
|
{
|
||||||
|
ApplyIdleStatus(
|
||||||
|
_config.InitialNode.ToString(),
|
||||||
|
"-",
|
||||||
|
"0",
|
||||||
|
Fass2SimProtocol.StateText(_config.InitialState),
|
||||||
|
"0",
|
||||||
|
"-",
|
||||||
|
"0",
|
||||||
|
$"{_config.BatteryCharge}%",
|
||||||
|
"-",
|
||||||
|
"0",
|
||||||
|
"0",
|
||||||
|
"0",
|
||||||
|
"无");
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var snap = _runtime.Vehicle.Snapshot();
|
||||||
|
var expected = _runtime.Actions.ExpectedStation;
|
||||||
|
var pending = expected == null
|
||||||
|
? "-"
|
||||||
|
: Fass2SimActionResolver.DescribePending(expected, snap);
|
||||||
|
if (string.IsNullOrEmpty(pending))
|
||||||
|
{
|
||||||
|
pending = "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
var distanceText = snap.Distance.ToString();
|
||||||
|
var targetNodeText = "-";
|
||||||
|
if (_runtime.Motion.TryGetSegmentStatus(out var targetNode, out var segmentLengthMm, out var progressMm))
|
||||||
|
{
|
||||||
|
targetNodeText = targetNode.ToString();
|
||||||
|
distanceText = $"{progressMm} / {segmentLengthMm}";
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplyIdleStatus(
|
||||||
|
snap.Node.ToString(),
|
||||||
|
targetNodeText,
|
||||||
|
distanceText,
|
||||||
|
Fass2SimProtocol.StateText(snap.State),
|
||||||
|
snap.Task.ToString(),
|
||||||
|
snap.StartStop.ToString(),
|
||||||
|
snap.Lift.ToString(),
|
||||||
|
$"{snap.BatteryCharge}%",
|
||||||
|
pending,
|
||||||
|
snap.StateReportsSent.ToString(),
|
||||||
|
snap.CommandsReceived.ToString(),
|
||||||
|
snap.AckReceived.ToString(),
|
||||||
|
snap.LastAckUtc == DateTime.MinValue
|
||||||
|
? "无"
|
||||||
|
: $"{(DateTime.UtcNow - snap.LastAckUtc).TotalSeconds:F1}s 前");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyIdleStatus(
|
||||||
|
string node,
|
||||||
|
string targetNode,
|
||||||
|
string distance,
|
||||||
|
string state,
|
||||||
|
string task,
|
||||||
|
string startStop,
|
||||||
|
string lift,
|
||||||
|
string battery,
|
||||||
|
string pending,
|
||||||
|
string reports,
|
||||||
|
string commands,
|
||||||
|
string ack,
|
||||||
|
string lastAck)
|
||||||
|
{
|
||||||
|
NodeText.Text = node;
|
||||||
|
TargetNodeText.Text = targetNode;
|
||||||
|
DistanceText.Text = distance;
|
||||||
|
StateText.Text = state;
|
||||||
|
TaskText.Text = task;
|
||||||
|
StartStopText.Text = startStop;
|
||||||
|
LiftText.Text = lift;
|
||||||
|
BatteryText.Text = battery;
|
||||||
|
PendingText.Text = pending;
|
||||||
|
ReportsText.Text = reports;
|
||||||
|
CommandsText.Text = commands;
|
||||||
|
AckText.Text = ack;
|
||||||
|
LastAckText.Text = lastAck;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LoadConfigToUi(Fass2SimConfig config)
|
||||||
|
{
|
||||||
|
VehicleCodeBox.Text = config.VehicleCode.ToString();
|
||||||
|
ListenAddressBox.Text = config.VehicleListenAddress;
|
||||||
|
ListenPortBox.Text = config.VehicleListenPort.ToString();
|
||||||
|
SchedulerHostBox.Text = config.SchedulerHost;
|
||||||
|
SchedulerPortBox.Text = config.SchedulerListenPort.ToString();
|
||||||
|
ReportIntervalBox.Text = config.ReportIntervalMs.ToString();
|
||||||
|
InitialNodeBox.Text = config.InitialNode.ToString();
|
||||||
|
AutoContinueBox.IsChecked = config.AutoContinueOnPass;
|
||||||
|
AutoCompleteBox.IsChecked = config.AutoCompleteStationActions;
|
||||||
|
LogRawFramesBox.IsChecked = config.LogRawFrames;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Fass2SimConfig ReadConfigFromUi()
|
||||||
|
{
|
||||||
|
return new Fass2SimConfig
|
||||||
|
{
|
||||||
|
VehicleCode = ParseUShort(VehicleCodeBox.Text, "车号"),
|
||||||
|
VehicleListenAddress = ListenAddressBox.Text?.Trim() ?? "0.0.0.0",
|
||||||
|
VehicleListenPort = ParseInt(ListenPortBox.Text, "车体监听端口"),
|
||||||
|
SchedulerHost = SchedulerHostBox.Text?.Trim() ?? "127.0.0.1",
|
||||||
|
SchedulerListenPort = ParseInt(SchedulerPortBox.Text, "调度监听端口"),
|
||||||
|
ReportIntervalMs = ParseInt(ReportIntervalBox.Text, "上报周期"),
|
||||||
|
InitialNode = ParseUShort(InitialNodeBox.Text, "初始节点"),
|
||||||
|
InitialState = _config?.InitialState ?? 0,
|
||||||
|
BatteryCharge = _config?.BatteryCharge ?? 100,
|
||||||
|
CarLength = _config?.CarLength ?? 1200,
|
||||||
|
CarWidth = _config?.CarWidth ?? 800,
|
||||||
|
DefaultSpeed = _config?.DefaultSpeed ?? 500,
|
||||||
|
DefaultSegmentDistance = _config?.DefaultSegmentDistance ?? 1000,
|
||||||
|
SecondsPerSegment = _config?.SecondsPerSegment ?? 2,
|
||||||
|
ActionDelayMs = _config?.ActionDelayMs ?? 1000,
|
||||||
|
NodeProfilesPath = _config?.NodeProfilesPath ?? "sim-nodes.json",
|
||||||
|
AutoContinueOnPass = AutoContinueBox.IsChecked == true,
|
||||||
|
AutoCompleteStationActions = AutoCompleteBox.IsChecked == true,
|
||||||
|
LogRawFrames = LogRawFramesBox.IsChecked == true,
|
||||||
|
EnableFileLog = _config?.EnableFileLog ?? true,
|
||||||
|
LogDirectory = _config?.LogDirectory ?? "logs"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetRunningUi(bool running)
|
||||||
|
{
|
||||||
|
StartButton.IsEnabled = !running;
|
||||||
|
StopButton.IsEnabled = running;
|
||||||
|
VehicleCodeBox.IsEnabled = !running;
|
||||||
|
ListenAddressBox.IsEnabled = !running;
|
||||||
|
ListenPortBox.IsEnabled = !running;
|
||||||
|
SchedulerHostBox.IsEnabled = !running;
|
||||||
|
SchedulerPortBox.IsEnabled = !running;
|
||||||
|
ReportIntervalBox.IsEnabled = !running;
|
||||||
|
InitialNodeBox.IsEnabled = !running;
|
||||||
|
AutoContinueBox.IsEnabled = !running;
|
||||||
|
AutoCompleteBox.IsEnabled = !running;
|
||||||
|
LogRawFramesBox.IsEnabled = !running;
|
||||||
|
|
||||||
|
RunStateText.Text = running ? "运行中" : "已停止";
|
||||||
|
RunStateText.Foreground = running
|
||||||
|
? new SolidColorBrush(Color.FromRgb(22, 163, 74))
|
||||||
|
: new SolidColorBrush(Color.FromRgb(107, 114, 128));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ushort ParseUShort(string text, string fieldName)
|
||||||
|
{
|
||||||
|
if (!ushort.TryParse(text?.Trim(), out var value))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"{fieldName} 无效:{text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ParseInt(string text, string fieldName)
|
||||||
|
{
|
||||||
|
if (!int.TryParse(text?.Trim(), out var value))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"{fieldName} 无效:{text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net8.0-windows</TargetFramework>
|
||||||
|
<RootNamespace>StandardScene.Fass2Simulator</RootNamespace>
|
||||||
|
<AssemblyName>StandardScene.Fass2Simulator</AssemblyName>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<ImplicitUsings>disable</ImplicitUsings>
|
||||||
|
<Nullable>disable</Nullable>
|
||||||
|
<Deterministic>true</Deterministic>
|
||||||
|
<UseWPF>true</UseWPF>
|
||||||
|
<ApplicationIcon />
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.0" />
|
||||||
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
<None Update="sim-nodes.json" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"Fass2Simulator": {
|
||||||
|
"VehicleCode": 1,
|
||||||
|
"VehicleListenAddress": "0.0.0.0",
|
||||||
|
"VehicleListenPort": 5000,
|
||||||
|
"SchedulerHost": "127.0.0.1",
|
||||||
|
"SchedulerListenPort": 20103,
|
||||||
|
"ReportIntervalMs": 200,
|
||||||
|
"InitialNode": 1,
|
||||||
|
"InitialState": 0,
|
||||||
|
"BatteryCharge": 100,
|
||||||
|
"CarLength": 1200,
|
||||||
|
"CarWidth": 800,
|
||||||
|
"LogRawFrames": false,
|
||||||
|
"DefaultSpeed": 500,
|
||||||
|
"DefaultSegmentDistance": 1000,
|
||||||
|
"SecondsPerSegment": 2,
|
||||||
|
"AutoContinueOnPass": true,
|
||||||
|
"ActionDelayMs": 1000,
|
||||||
|
"AutoCompleteStationActions": false,
|
||||||
|
"NodeProfilesPath": "sim-nodes.json",
|
||||||
|
"EnableFileLog": true,
|
||||||
|
"LogDirectory": "logs"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"3": { "ActionDelayMs": 1200 },
|
||||||
|
"5": { "ActionDelayMs": 800 }
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
|||||||
|
using Newtonsoft.Json;
|
||||||
|
using SimpleLite.Props;
|
||||||
|
using SimpleLite.RCS;
|
||||||
|
using StandardScene.Chained;
|
||||||
|
|
||||||
|
namespace StandardScene
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 磁导航 FASS 2.0 环线任务进程。
|
||||||
|
/// <para>任务分配/触发/流量逻辑完全继承 Core <see cref="LoopMission"/>,不修改 Core。</para>
|
||||||
|
/// <para>导航执行由 <see cref="MagFass2Car"/> 拦截 Loop 编译脚本(含 <c>MagFass2Go</c> + <c>goalSite</c>),
|
||||||
|
/// 改为单次全程 <c>0xB1</c> 状态机任务。</para>
|
||||||
|
/// <para>场景内可继续使用原 <see cref="LoopMission"/>;本类型仅作磁导航 FASS2 场景标识。</para>
|
||||||
|
/// </summary>
|
||||||
|
[MissionType(Name = "磁导航FASS2环线", editor = typeof(MagFass2LoopMission))]
|
||||||
|
public class MagFass2LoopMission : LoopMission
|
||||||
|
{
|
||||||
|
[JsonIgnore] public override MissionStatus status { get; set; } = new LoopMissionStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"TriggerEnable": true,
|
||||||
|
"TriggerSource": "Car",
|
||||||
|
"TriggerEvent": "TimerTick",
|
||||||
|
"TriggerCarCode": null,
|
||||||
|
"TriggerCarPrevNodeCode": null,
|
||||||
|
"TriggerCarCurrentNodeCode": "3",
|
||||||
|
"TriggerCarNextNodeCode": null,
|
||||||
|
"TriggerCarState": "Stopping",
|
||||||
|
"TriggerCarAngle": null,
|
||||||
|
"ConditionEvents": [
|
||||||
|
{
|
||||||
|
"ConditionEnable": true,
|
||||||
|
"ConditionType": "Method",
|
||||||
|
"ConditionAssemblyFile": null,
|
||||||
|
"ConditionTypeName": null,
|
||||||
|
"ConditionTypeArgs": null,
|
||||||
|
"ConditionMethodName": "NodeAllUnlockedNotCar",
|
||||||
|
"ConditionMethodArgs": "3,53,21"
|
||||||
|
}
|
||||||
|
|
||||||
|
],
|
||||||
|
"TaskEnable": true,
|
||||||
|
"TaskType": "Start"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TriggerEnable": true,
|
||||||
|
"TriggerSource": "Car",
|
||||||
|
"TriggerEvent": "TimerTick",
|
||||||
|
"TriggerCarCode": null,
|
||||||
|
"TriggerCarPrevNodeCode": null,
|
||||||
|
"TriggerCarCurrentNodeCode": "16",
|
||||||
|
"TriggerCarNextNodeCode": null,
|
||||||
|
"TriggerCarState": "WaitPass",
|
||||||
|
"TriggerCarAngle": null,
|
||||||
|
"ConditionEvents": [
|
||||||
|
{
|
||||||
|
"ConditionEnable": true,
|
||||||
|
"ConditionType": "Method",
|
||||||
|
"ConditionAssemblyFile": null,
|
||||||
|
"ConditionTypeName": null,
|
||||||
|
"ConditionTypeArgs": null,
|
||||||
|
"ConditionMethodName": "NodeAllUnlockedNotCar",
|
||||||
|
"ConditionMethodArgs": "16,3"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"TaskEnable": true,
|
||||||
|
"TaskType": "Start"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TriggerEnable": true,
|
||||||
|
"TriggerSource": "Car",
|
||||||
|
"TriggerEvent": "TimerTick",
|
||||||
|
"TriggerCarCode": null,
|
||||||
|
"TriggerCarPrevNodeCode": null,
|
||||||
|
"TriggerCarCurrentNodeCode": "21",
|
||||||
|
"TriggerCarNextNodeCode": null,
|
||||||
|
"TriggerCarState": "Stopping",
|
||||||
|
"TriggerCarAngle": null,
|
||||||
|
"ConditionEvents": [
|
||||||
|
{
|
||||||
|
"ConditionEnable": true,
|
||||||
|
"ConditionType": "Method",
|
||||||
|
"ConditionAssemblyFile": null,
|
||||||
|
"ConditionTypeName": null,
|
||||||
|
"ConditionTypeArgs": null,
|
||||||
|
"ConditionMethodName": "NodeAllUnlockedNotCar",
|
||||||
|
"ConditionMethodArgs": "21,1,4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ConditionEnable": true,
|
||||||
|
"ConditionType": "Method",
|
||||||
|
"ConditionAssemblyFile": null,
|
||||||
|
"ConditionTypeName": null,
|
||||||
|
"ConditionTypeArgs": null,
|
||||||
|
"ConditionMethodName": "requestStaion",
|
||||||
|
"ConditionMethodArgs": "DB5524.0.0"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"TaskEnable": true,
|
||||||
|
"TaskType": "Start"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TriggerEnable": true,
|
||||||
|
"TriggerSource": "Car",
|
||||||
|
"TriggerEvent": "TimerTick",
|
||||||
|
"TriggerCarCode": null,
|
||||||
|
"TriggerCarPrevNodeCode": null,
|
||||||
|
"TriggerCarCurrentNodeCode": "1",
|
||||||
|
"TriggerCarNextNodeCode": null,
|
||||||
|
"TriggerCarState": "Stopping",
|
||||||
|
"TriggerCarAngle": null,
|
||||||
|
"ConditionEvents": [
|
||||||
|
{
|
||||||
|
"ConditionEnable": true,
|
||||||
|
"ConditionType": "Method",
|
||||||
|
"ConditionAssemblyFile": null,
|
||||||
|
"ConditionTypeName": null,
|
||||||
|
"ConditionTypeArgs": null,
|
||||||
|
"ConditionMethodName": "InplaceStaion",
|
||||||
|
"ConditionMethodArgs": "DB5524.2.0,DB5524.0.1"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"TaskEnable": true,
|
||||||
|
"TaskType": "Start"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TriggerEnable": true,
|
||||||
|
"TriggerSource": "Car",
|
||||||
|
"TriggerEvent": "TimerTick",
|
||||||
|
"TriggerCarCode": null,
|
||||||
|
"TriggerCarPrevNodeCode": null,
|
||||||
|
"TriggerCarCurrentNodeCode": "2",
|
||||||
|
"TriggerCarNextNodeCode": null,
|
||||||
|
"TriggerCarState": "Stopping",
|
||||||
|
"TriggerCarAngle": null,
|
||||||
|
"ConditionEvents": [
|
||||||
|
{
|
||||||
|
"ConditionEnable": true,
|
||||||
|
"ConditionType": "Method",
|
||||||
|
"ConditionAssemblyFile": null,
|
||||||
|
"ConditionTypeName": null,
|
||||||
|
"ConditionTypeArgs": null,
|
||||||
|
"ConditionMethodName": "clearStaion",
|
||||||
|
"ConditionMethodArgs": "DB5524.2.0"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"TaskEnable": true,
|
||||||
|
"TaskType": "Start"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TriggerEnable": true,
|
||||||
|
"TriggerSource": "Car",
|
||||||
|
"TriggerEvent": "TimerTick",
|
||||||
|
"TriggerCarCode": null,
|
||||||
|
"TriggerCarPrevNodeCode": null,
|
||||||
|
"TriggerCarCurrentNodeCode": "53",
|
||||||
|
"TriggerCarNextNodeCode": null,
|
||||||
|
"TriggerCarState": "Running",
|
||||||
|
"TriggerCarAngle": null,
|
||||||
|
"ConditionEvents": [
|
||||||
|
{
|
||||||
|
"ConditionEnable": true,
|
||||||
|
"ConditionType": "Method",
|
||||||
|
"ConditionAssemblyFile": null,
|
||||||
|
"ConditionTypeName": null,
|
||||||
|
"ConditionTypeArgs": null,
|
||||||
|
"ConditionMethodName": "EmegerStop",
|
||||||
|
"ConditionMethodArgs": "DB5524.0.2"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"TaskEnable": true,
|
||||||
|
"TaskType": "Stop"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TriggerEnable": true,
|
||||||
|
"TriggerSource": "Car",
|
||||||
|
"TriggerEvent": "TimerTick",
|
||||||
|
"TriggerCarCode": null,
|
||||||
|
"TriggerCarPrevNodeCode": null,
|
||||||
|
"TriggerCarCurrentNodeCode": "53",
|
||||||
|
"TriggerCarNextNodeCode": null,
|
||||||
|
"TriggerCarState": "Stopping",
|
||||||
|
"TriggerCarAngle": null,
|
||||||
|
"ConditionEvents": [
|
||||||
|
{
|
||||||
|
"ConditionEnable": true,
|
||||||
|
"ConditionType": "Method",
|
||||||
|
"ConditionAssemblyFile": null,
|
||||||
|
"ConditionTypeName": null,
|
||||||
|
"ConditionTypeArgs": null,
|
||||||
|
"ConditionMethodName": "EmegerStart",
|
||||||
|
"ConditionMethodArgs": "DB5524.0.2"
|
||||||
|
}
|
||||||
|
|
||||||
|
],
|
||||||
|
"TaskEnable": true,
|
||||||
|
"TaskType": "Start"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TriggerEnable": true,
|
||||||
|
"TriggerSource": "Car",
|
||||||
|
"TriggerEvent": "TimerTick",
|
||||||
|
"TriggerCarCode": null,
|
||||||
|
"TriggerCarPrevNodeCode": null,
|
||||||
|
"TriggerCarCurrentNodeCode": "5",
|
||||||
|
"TriggerCarNextNodeCode": null,
|
||||||
|
"TriggerCarState": "Stopping,Charging",
|
||||||
|
"TriggerCarAngle": null,
|
||||||
|
"ConditionEvents": [
|
||||||
|
{
|
||||||
|
"ConditionEnable": true,
|
||||||
|
"ConditionType": "Method",
|
||||||
|
"ConditionAssemblyFile": null,
|
||||||
|
"ConditionTypeName": null,
|
||||||
|
"ConditionTypeArgs": null,
|
||||||
|
"ConditionMethodName": "NodeAllUnlockedNotCar",
|
||||||
|
"ConditionMethodArgs": "5,54"
|
||||||
|
}
|
||||||
|
|
||||||
|
],
|
||||||
|
"TaskEnable": true,
|
||||||
|
"TaskType": "Start"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TriggerEnable": true,
|
||||||
|
"TriggerSource": "Car",
|
||||||
|
"TriggerEvent": "TimerTick",
|
||||||
|
"TriggerCarCode": null,
|
||||||
|
"TriggerCarPrevNodeCode": null,
|
||||||
|
"TriggerCarCurrentNodeCode": "7",
|
||||||
|
"TriggerCarNextNodeCode": null,
|
||||||
|
"TriggerCarState": "Running",
|
||||||
|
"TriggerCarAngle": null,
|
||||||
|
"ConditionEvents": [
|
||||||
|
{
|
||||||
|
"ConditionEnable": true,
|
||||||
|
"ConditionType": "Method",
|
||||||
|
"ConditionAssemblyFile": null,
|
||||||
|
"ConditionTypeName": null,
|
||||||
|
"ConditionTypeArgs": null,
|
||||||
|
"ConditionMethodName": "NodeAllUnlockedNotCar",
|
||||||
|
"ConditionMethodArgs": "7,8"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"TaskEnable": false,
|
||||||
|
"TaskType": "Start"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TriggerEnable": true,
|
||||||
|
"TriggerSource": "Car",
|
||||||
|
"TriggerEvent": "TimerTick",
|
||||||
|
"TriggerCarCode": null,
|
||||||
|
"TriggerCarPrevNodeCode": null,
|
||||||
|
"TriggerCarCurrentNodeCode": "9",
|
||||||
|
"TriggerCarNextNodeCode": null,
|
||||||
|
"TriggerCarState": "Running",
|
||||||
|
"TriggerCarAngle": null,
|
||||||
|
"ConditionEvents": [
|
||||||
|
{
|
||||||
|
"ConditionEnable": true,
|
||||||
|
"ConditionType": "Method",
|
||||||
|
"ConditionAssemblyFile": null,
|
||||||
|
"ConditionTypeName": null,
|
||||||
|
"ConditionTypeArgs": null,
|
||||||
|
"ConditionMethodName": "NodeAllUnlockedNotCar",
|
||||||
|
"ConditionMethodArgs": "9"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"TaskEnable": false,
|
||||||
|
"TaskType": "Start"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TriggerEnable": true,
|
||||||
|
"TriggerSource": "Car",
|
||||||
|
"TriggerEvent": "TimerTick",
|
||||||
|
"TriggerCarCode": null,
|
||||||
|
"TriggerCarPrevNodeCode": null,
|
||||||
|
"TriggerCarCurrentNodeCode": "11",
|
||||||
|
"TriggerCarNextNodeCode": null,
|
||||||
|
"TriggerCarState": "Running",
|
||||||
|
"TriggerCarAngle": null,
|
||||||
|
"ConditionEvents": [
|
||||||
|
{
|
||||||
|
"ConditionEnable": true,
|
||||||
|
"ConditionType": "Method",
|
||||||
|
"ConditionAssemblyFile": null,
|
||||||
|
"ConditionTypeName": null,
|
||||||
|
"ConditionTypeArgs": null,
|
||||||
|
"ConditionMethodName": "NodeAllUnlockedNotCar",
|
||||||
|
"ConditionMethodArgs": "11"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"TaskEnable": false,
|
||||||
|
"TaskType": "Start"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TriggerEnable": true,
|
||||||
|
"TriggerSource": "Car",
|
||||||
|
"TriggerEvent": "TimerTick",
|
||||||
|
"TriggerCarCode": null,
|
||||||
|
"TriggerCarPrevNodeCode": null,
|
||||||
|
"TriggerCarCurrentNodeCode": "14",
|
||||||
|
"TriggerCarNextNodeCode": null,
|
||||||
|
"TriggerCarState": "Running",
|
||||||
|
"TriggerCarAngle": null,
|
||||||
|
"ConditionEvents": [
|
||||||
|
{
|
||||||
|
"ConditionEnable": true,
|
||||||
|
"ConditionType": "Method",
|
||||||
|
"ConditionAssemblyFile": null,
|
||||||
|
"ConditionTypeName": null,
|
||||||
|
"ConditionTypeArgs": null,
|
||||||
|
"ConditionMethodName": "NodeAllUnlockedNotCar",
|
||||||
|
"ConditionMethodArgs": "14"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"TaskEnable": false,
|
||||||
|
"TaskType": "Start"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TriggerEnable": true,
|
||||||
|
"TriggerSource": "Car",
|
||||||
|
"TriggerEvent": "TimerTick",
|
||||||
|
"TriggerCarCode": null,
|
||||||
|
"TriggerCarPrevNodeCode": null,
|
||||||
|
"TriggerCarCurrentNodeCode": "15",
|
||||||
|
"TriggerCarNextNodeCode": null,
|
||||||
|
"TriggerCarState": "Running",
|
||||||
|
"TriggerCarAngle": null,
|
||||||
|
"ConditionEvents": [
|
||||||
|
{
|
||||||
|
"ConditionEnable": true,
|
||||||
|
"ConditionType": "Method",
|
||||||
|
"ConditionAssemblyFile": null,
|
||||||
|
"ConditionTypeName": null,
|
||||||
|
"ConditionTypeArgs": null,
|
||||||
|
"ConditionMethodName": "NodeAllUnlockedNotCar",
|
||||||
|
"ConditionMethodArgs": "15"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"TaskEnable": false,
|
||||||
|
"TaskType": "Start"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TriggerEnable": true,
|
||||||
|
"TriggerSource": "Car",
|
||||||
|
"TriggerEvent": "TimerTick",
|
||||||
|
"TriggerCarCode": null,
|
||||||
|
"TriggerCarPrevNodeCode": null,
|
||||||
|
"TriggerCarCurrentNodeCode": "27",
|
||||||
|
"TriggerCarNextNodeCode": null,
|
||||||
|
"TriggerCarState": "Stopping,Charging",
|
||||||
|
"TriggerCarAngle": null,
|
||||||
|
"ConditionEvents": [
|
||||||
|
{
|
||||||
|
"ConditionEnable": true,
|
||||||
|
"ConditionType": "Method",
|
||||||
|
"ConditionAssemblyFile": null,
|
||||||
|
"ConditionTypeName": null,
|
||||||
|
"ConditionTypeArgs": null,
|
||||||
|
"ConditionMethodName": "ChargeStart",
|
||||||
|
"ConditionMethodArgs": "95"
|
||||||
|
}
|
||||||
|
|
||||||
|
],
|
||||||
|
"TaskEnable": true,
|
||||||
|
"TaskType": "Start"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -7,8 +7,9 @@ namespace StandardScene.Magnetic
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// scene.mag 平台画像:磁导航场景插件。
|
/// scene.mag 平台画像:磁导航场景插件。
|
||||||
/// <para>车型为磁导航专用 <see cref="MagCar"/>(与下位机通过 UDP 协议交互);路径由
|
/// <para>车型:<see cref="MagCar"/>(FASS 1.0 TCP)、<see cref="MagFass2Car"/>(FASS 2.0 UDP/任务状态机)。
|
||||||
/// MagneticTrackCoder 生成 agv.MagGo / agv.NaiveMagGo 脚本(按 track.Magnet 触发)。</para>
|
/// Loop 导航:继续使用 Core <c>LoopMission</c> 或本插件 <c>MagFass2LoopMission</c> 分配 <c>goalSite</c>,
|
||||||
|
/// <see cref="MagFass2Car"/> 拦截编译脚本后一次下发全程 <c>0xB1</c>。</para>
|
||||||
/// 宿主(SimpleLite)加载本 dll 后反射实例化并 OnActivate / 注册。
|
/// 宿主(SimpleLite)加载本 dll 后反射实例化并 OnActivate / 注册。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class MagneticSceneProfile : NavigationProfileBase
|
public sealed class MagneticSceneProfile : NavigationProfileBase
|
||||||
@@ -22,11 +23,12 @@ namespace StandardScene.Magnetic
|
|||||||
public override IReadOnlyList<Type> CarTypes => new[]
|
public override IReadOnlyList<Type> CarTypes => new[]
|
||||||
{
|
{
|
||||||
typeof(MagCar),
|
typeof(MagCar),
|
||||||
|
typeof(MagFass2Car),
|
||||||
};
|
};
|
||||||
|
|
||||||
public override void OnActivate(ISceneContext context)
|
public override void OnActivate(ISceneContext context)
|
||||||
{
|
{
|
||||||
context.Log($"{DisplayName} 已激活(车型:磁导航车 MagCar)");
|
context.Log($"{DisplayName} 已激活(车型:MagCar / MagFass2Car)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace StandardScene.Magnetic.Protocol
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// FASS 2.0 通讯模式:PLC/TCP 主动查询,或 PCB/UDP 被动接收状态上报。
|
||||||
|
/// </summary>
|
||||||
|
public enum Fass2CommMode
|
||||||
|
{
|
||||||
|
/// <summary>PLC:TCP 同步收发,调度主动发 0x00 查询。</summary>
|
||||||
|
Tcp = 0,
|
||||||
|
|
||||||
|
/// <summary>PCB:UDP 监听 AGV 上报,回 0x10,命令单向下发。</summary>
|
||||||
|
Udp = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace StandardScene.Magnetic.Protocol
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// FASS 2.0 通用控制接口(PLC)报文编解码,对齐
|
||||||
|
/// <c>FASS.Extend.Car.Fairyland.Plc</c> 与调度器 <c>CarRequestService</c> 逻辑。
|
||||||
|
/// </summary>
|
||||||
|
internal static class Fass2Protocol
|
||||||
|
{
|
||||||
|
public const byte Begin = 0xBB;
|
||||||
|
public const byte End = 0xEE;
|
||||||
|
|
||||||
|
public const byte CmdQuery = 0x00;
|
||||||
|
public const byte CmdStart = 0x01;
|
||||||
|
public const byte CmdStop = 0x02;
|
||||||
|
public const byte CmdEmergencyStop = 0x03;
|
||||||
|
public const byte CmdReset = 0x04;
|
||||||
|
public const byte CmdRest = 0x05;
|
||||||
|
public const byte CmdShutdown = 0x06;
|
||||||
|
public const byte CmdAction = 0xA1;
|
||||||
|
public const byte CmdNodes = 0xB1;
|
||||||
|
public const byte CmdStateResponse = 0x10;
|
||||||
|
|
||||||
|
public const int ControlFrameLength = 50;
|
||||||
|
public const int StateFrameLength = 100;
|
||||||
|
public const int ActionFrameLength = 100;
|
||||||
|
public const int NodesFrameLength = 300;
|
||||||
|
|
||||||
|
public static byte Xor(byte[] data, int start, int length)
|
||||||
|
{
|
||||||
|
byte xor = 0;
|
||||||
|
for (var i = 0; i < length; i++)
|
||||||
|
{
|
||||||
|
xor ^= data[start + i];
|
||||||
|
}
|
||||||
|
return xor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] BuildControl(byte command, ushort car, ushort param)
|
||||||
|
{
|
||||||
|
var frame = new byte[ControlFrameLength];
|
||||||
|
frame[0] = Begin;
|
||||||
|
frame[1] = command;
|
||||||
|
WriteUInt16(frame, 2, car);
|
||||||
|
WriteUInt16(frame, 4, param);
|
||||||
|
frame[48] = Xor(frame, 1, 47);
|
||||||
|
frame[49] = End;
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] BuildNodes(ushort car, ulong taskId, Fass2NodeMessage[] nodes)
|
||||||
|
{
|
||||||
|
if (nodes == null || nodes.Length == 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("nodes required");
|
||||||
|
}
|
||||||
|
if (nodes.Length > 10)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("FASS2 nodes count must be <= 10");
|
||||||
|
}
|
||||||
|
|
||||||
|
var frame = new byte[NodesFrameLength];
|
||||||
|
frame[0] = Begin;
|
||||||
|
frame[1] = CmdNodes;
|
||||||
|
WriteUInt16(frame, 2, car);
|
||||||
|
WriteUInt64(frame, 4, taskId);
|
||||||
|
WriteUInt16(frame, 12, (ushort)nodes.Length);
|
||||||
|
for (var i = 0; i < nodes.Length; i++)
|
||||||
|
{
|
||||||
|
Buffer.BlockCopy(nodes[i].ToBytes(), 0, frame, 14 + i * 25, 25);
|
||||||
|
}
|
||||||
|
frame[298] = Xor(frame, 1, 297);
|
||||||
|
frame[299] = End;
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] BuildAction(ushort car, ulong actionId, Fass2NodeMessage node)
|
||||||
|
{
|
||||||
|
if (node == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("node required");
|
||||||
|
}
|
||||||
|
|
||||||
|
var frame = new byte[ActionFrameLength];
|
||||||
|
frame[0] = Begin;
|
||||||
|
frame[1] = CmdAction;
|
||||||
|
WriteUInt16(frame, 2, car);
|
||||||
|
WriteUInt64(frame, 4, actionId);
|
||||||
|
Buffer.BlockCopy(node.ToBytes(), 0, frame, 14, 25);
|
||||||
|
frame[98] = Xor(frame, 1, 97);
|
||||||
|
frame[99] = End;
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PCB/UDP 模式:收到 AGV 主动上报的 100B 状态后,回 0x10(50B)时间戳应答。
|
||||||
|
/// </summary>
|
||||||
|
public static byte[] BuildStateResponse(ushort car, ulong timestampMs)
|
||||||
|
{
|
||||||
|
var frame = new byte[ControlFrameLength];
|
||||||
|
frame[0] = Begin;
|
||||||
|
frame[1] = CmdStateResponse;
|
||||||
|
WriteUInt16(frame, 2, car);
|
||||||
|
WriteUInt64(frame, 4, timestampMs);
|
||||||
|
|
||||||
|
var epoch = new DateTime(1970, 1, 1, 8, 0, 0, DateTimeKind.Unspecified);
|
||||||
|
var date = epoch.AddMilliseconds(timestampMs > long.MaxValue ? long.MaxValue : (long)timestampMs);
|
||||||
|
frame[12] = (byte)(date.Year - 1970);
|
||||||
|
frame[13] = (byte)date.Month;
|
||||||
|
frame[14] = (byte)date.Day;
|
||||||
|
frame[15] = (byte)date.Hour;
|
||||||
|
frame[16] = (byte)date.Minute;
|
||||||
|
frame[17] = (byte)date.Second;
|
||||||
|
WriteUInt16(frame, 18, (ushort)date.Millisecond);
|
||||||
|
|
||||||
|
frame[48] = Xor(frame, 1, 47);
|
||||||
|
frame[49] = End;
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ulong BeijingUnixTimeMs()
|
||||||
|
{
|
||||||
|
var epoch = new DateTime(1970, 1, 1, 8, 0, 0, DateTimeKind.Unspecified);
|
||||||
|
return (ulong)(DateTime.Now - epoch).TotalMilliseconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryExtractStateFrame(byte[] buffer, int length, out byte[] frame)
|
||||||
|
{
|
||||||
|
frame = null;
|
||||||
|
if (buffer == null || length < StateFrameLength)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i <= length - StateFrameLength; i++)
|
||||||
|
{
|
||||||
|
if (buffer[i] != Begin || buffer[i + StateFrameLength - 1] != End)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
frame = new byte[StateFrameLength];
|
||||||
|
Buffer.BlockCopy(buffer, i, frame, 0, StateFrameLength);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (length == StateFrameLength && buffer[0] == Begin && buffer[StateFrameLength - 1] == End)
|
||||||
|
{
|
||||||
|
frame = new byte[StateFrameLength];
|
||||||
|
Buffer.BlockCopy(buffer, 0, frame, 0, StateFrameLength);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Fass2StateReport ParseState(byte[] bytes)
|
||||||
|
{
|
||||||
|
if (bytes == null || bytes.Length < StateFrameLength)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("FASS2 state frame must be 100 bytes");
|
||||||
|
}
|
||||||
|
if (bytes[0] != Begin || bytes[99] != End)
|
||||||
|
{
|
||||||
|
throw new ArgumentException($"invalid FASS2 state frame: begin=0x{bytes[0]:X2}, end=0x{bytes[99]:X2}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var nodeBytes = new byte[25];
|
||||||
|
Buffer.BlockCopy(bytes, 53, nodeBytes, 0, 25);
|
||||||
|
return new Fass2StateReport
|
||||||
|
{
|
||||||
|
Command = bytes[1],
|
||||||
|
Car = ReadUInt16(bytes, 2),
|
||||||
|
Length = ReadUInt16(bytes, 4),
|
||||||
|
Width = ReadUInt16(bytes, 6),
|
||||||
|
BatteryCharge = bytes[28],
|
||||||
|
BatteryHealth = bytes[29],
|
||||||
|
BatteryCurrent = ReadUInt16(bytes, 30),
|
||||||
|
BatteryVoltage = ReadUInt16(bytes, 32),
|
||||||
|
HeadingAngle = ReadUInt16(bytes, 34),
|
||||||
|
State = bytes[36],
|
||||||
|
Alarm = ReadUInt64(bytes, 37),
|
||||||
|
Task = ReadUInt64(bytes, 45),
|
||||||
|
Node = Fass2NodeMessage.FromBytes(nodeBytes)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ToHex(byte[] bytes)
|
||||||
|
{
|
||||||
|
return bytes == null ? string.Empty : string.Join(" ", bytes.Select(b => b.ToString("X2")));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string StateText(byte state)
|
||||||
|
{
|
||||||
|
switch (state)
|
||||||
|
{
|
||||||
|
case 0: return "未准备";
|
||||||
|
case 1: return "运行中";
|
||||||
|
case 2: return "停止中";
|
||||||
|
case 3: return "急停中";
|
||||||
|
case 4: return "故障中";
|
||||||
|
case 5: return "任务中";
|
||||||
|
case 6: return "休眠中";
|
||||||
|
case 7: return "关机中";
|
||||||
|
case 8: return "充电中";
|
||||||
|
default: return $"未知({state})";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteUInt16(byte[] buffer, int offset, ushort value)
|
||||||
|
{
|
||||||
|
var bytes = BitConverter.GetBytes(value);
|
||||||
|
buffer[offset] = bytes[0];
|
||||||
|
buffer[offset + 1] = bytes[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteUInt64(byte[] buffer, int offset, ulong value)
|
||||||
|
{
|
||||||
|
var bytes = BitConverter.GetBytes(value);
|
||||||
|
Buffer.BlockCopy(bytes, 0, buffer, offset, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ushort ReadUInt16(byte[] bytes, int offset)
|
||||||
|
{
|
||||||
|
return BitConverter.ToUInt16(bytes, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ulong ReadUInt64(byte[] bytes, int offset)
|
||||||
|
{
|
||||||
|
return BitConverter.ToUInt64(bytes, offset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class Fass2NodeMessage
|
||||||
|
{
|
||||||
|
public ushort Node { get; set; }
|
||||||
|
public ushort Distance { get; set; }
|
||||||
|
public byte StartStop { get; set; }
|
||||||
|
public byte Direction { get; set; }
|
||||||
|
public byte Orientation { get; set; }
|
||||||
|
public byte Byroad { get; set; }
|
||||||
|
public ushort Speed { get; set; }
|
||||||
|
public byte Obstacle { get; set; }
|
||||||
|
public byte Audio { get; set; }
|
||||||
|
public byte Light { get; set; }
|
||||||
|
public byte Charge { get; set; }
|
||||||
|
public byte Rest { get; set; }
|
||||||
|
public byte Lift { get; set; }
|
||||||
|
public byte Clamp { get; set; }
|
||||||
|
public byte Tray { get; set; }
|
||||||
|
public byte Roll { get; set; }
|
||||||
|
public byte Shutdown { get; set; }
|
||||||
|
|
||||||
|
public byte[] ToBytes()
|
||||||
|
{
|
||||||
|
var bytes = new byte[25];
|
||||||
|
WriteUInt16(bytes, 0, Node);
|
||||||
|
WriteUInt16(bytes, 2, Distance);
|
||||||
|
bytes[4] = StartStop;
|
||||||
|
bytes[5] = Direction;
|
||||||
|
bytes[6] = Orientation;
|
||||||
|
bytes[7] = Byroad;
|
||||||
|
WriteUInt16(bytes, 8, Speed);
|
||||||
|
bytes[10] = Obstacle;
|
||||||
|
bytes[11] = Audio;
|
||||||
|
bytes[12] = Light;
|
||||||
|
bytes[13] = Charge;
|
||||||
|
bytes[14] = Rest;
|
||||||
|
bytes[15] = Lift;
|
||||||
|
bytes[16] = Clamp;
|
||||||
|
bytes[17] = Tray;
|
||||||
|
bytes[18] = Roll;
|
||||||
|
bytes[19] = Shutdown;
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Fass2NodeMessage FromBytes(byte[] bytes)
|
||||||
|
{
|
||||||
|
if (bytes == null || bytes.Length < 25)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("node frame must be 25 bytes");
|
||||||
|
}
|
||||||
|
return new Fass2NodeMessage
|
||||||
|
{
|
||||||
|
Node = ReadUInt16(bytes, 0),
|
||||||
|
Distance = ReadUInt16(bytes, 2),
|
||||||
|
StartStop = bytes[4],
|
||||||
|
Direction = bytes[5],
|
||||||
|
Orientation = bytes[6],
|
||||||
|
Byroad = bytes[7],
|
||||||
|
Speed = ReadUInt16(bytes, 8),
|
||||||
|
Obstacle = bytes[10],
|
||||||
|
Audio = bytes[11],
|
||||||
|
Light = bytes[12],
|
||||||
|
Charge = bytes[13],
|
||||||
|
Rest = bytes[14],
|
||||||
|
Lift = bytes[15],
|
||||||
|
Clamp = bytes[16],
|
||||||
|
Tray = bytes[17],
|
||||||
|
Roll = bytes[18],
|
||||||
|
Shutdown = bytes[19]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteUInt16(byte[] buffer, int offset, ushort value)
|
||||||
|
{
|
||||||
|
var bytes = BitConverter.GetBytes(value);
|
||||||
|
buffer[offset] = bytes[0];
|
||||||
|
buffer[offset + 1] = bytes[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ushort ReadUInt16(byte[] bytes, int offset)
|
||||||
|
{
|
||||||
|
return BitConverter.ToUInt16(bytes, offset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class Fass2StateReport
|
||||||
|
{
|
||||||
|
public byte Command { get; set; }
|
||||||
|
public ushort Car { get; set; }
|
||||||
|
public ushort Length { get; set; }
|
||||||
|
public ushort Width { get; set; }
|
||||||
|
public byte BatteryCharge { get; set; }
|
||||||
|
public byte BatteryHealth { get; set; }
|
||||||
|
public ushort BatteryCurrent { get; set; }
|
||||||
|
public ushort BatteryVoltage { get; set; }
|
||||||
|
public ushort HeadingAngle { get; set; }
|
||||||
|
public byte State { get; set; }
|
||||||
|
public ulong Alarm { get; set; }
|
||||||
|
public ulong Task { get; set; }
|
||||||
|
public Fass2NodeMessage Node { get; set; } = new Fass2NodeMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using System;
|
||||||
|
using System.Text;
|
||||||
|
using StandardScene.Magnetic.Protocol;
|
||||||
|
|
||||||
|
namespace StandardScene.Magnetic.Protocol
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 100B 状态帧编码,供模拟器或测试工具复用,字段布局与 <see cref="Fass2Protocol.ParseState"/> 一致。
|
||||||
|
/// </summary>
|
||||||
|
public static class Fass2StateCodec
|
||||||
|
{
|
||||||
|
public static byte[] BuildState(Fass2StateReport report, string carType = "MagFass2")
|
||||||
|
{
|
||||||
|
if (report == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(report));
|
||||||
|
}
|
||||||
|
|
||||||
|
var frame = new byte[Fass2Protocol.StateFrameLength];
|
||||||
|
frame[0] = Fass2Protocol.Begin;
|
||||||
|
frame[1] = report.Command;
|
||||||
|
WriteUInt16(frame, 2, report.Car);
|
||||||
|
WriteUInt16(frame, 4, report.Length);
|
||||||
|
WriteUInt16(frame, 6, report.Width);
|
||||||
|
|
||||||
|
var typeBytes = Encoding.ASCII.GetBytes((carType ?? string.Empty).PadRight(16, '\0'));
|
||||||
|
Buffer.BlockCopy(typeBytes, 0, frame, 12, Math.Min(16, typeBytes.Length));
|
||||||
|
|
||||||
|
frame[28] = report.BatteryCharge;
|
||||||
|
frame[29] = report.BatteryHealth;
|
||||||
|
WriteUInt16(frame, 30, report.BatteryCurrent);
|
||||||
|
WriteUInt16(frame, 32, report.BatteryVoltage);
|
||||||
|
WriteUInt16(frame, 34, report.HeadingAngle);
|
||||||
|
frame[36] = report.State;
|
||||||
|
WriteUInt64(frame, 37, report.Alarm);
|
||||||
|
WriteUInt64(frame, 45, report.Task);
|
||||||
|
|
||||||
|
var node = report.Node ?? new Fass2NodeMessage();
|
||||||
|
Buffer.BlockCopy(node.ToBytes(), 0, frame, 53, 25);
|
||||||
|
|
||||||
|
frame[98] = Fass2Protocol.Xor(frame, 1, 97);
|
||||||
|
frame[99] = Fass2Protocol.End;
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteUInt16(byte[] buffer, int offset, ushort value)
|
||||||
|
{
|
||||||
|
var bytes = BitConverter.GetBytes(value);
|
||||||
|
buffer[offset] = bytes[0];
|
||||||
|
buffer[offset + 1] = bytes[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteUInt64(byte[] buffer, int offset, ulong value)
|
||||||
|
{
|
||||||
|
var bytes = BitConverter.GetBytes(value);
|
||||||
|
Buffer.BlockCopy(bytes, 0, buffer, offset, 8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
using SimpleCore.Library;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Threading;
|
||||||
|
|
||||||
|
namespace StandardScene.Magnetic.Protocol
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// FASS 2.0 PCB/UDP 共享监听枢纽。
|
||||||
|
/// AGV 主动上报 100B 状态 → 解析并分发给对应车辆 → 回 0x10 时间戳应答到车辆 address:Port。
|
||||||
|
/// 下发 0xB1/0xA1/0x01 等命令亦通过本枢纽单播到车辆 address:Port。
|
||||||
|
/// </summary>
|
||||||
|
internal static class Fass2UdpHub
|
||||||
|
{
|
||||||
|
private static readonly object SyncRoot = new object();
|
||||||
|
private static readonly Dictionary<ushort, IFass2UdpCar> CarsByCode = new Dictionary<ushort, IFass2UdpCar>();
|
||||||
|
|
||||||
|
private static UdpClient _listener;
|
||||||
|
private static Thread _receiveThread;
|
||||||
|
private static volatile bool _running;
|
||||||
|
private static int _listenPort = 20103;
|
||||||
|
private static readonly Dictionary<ushort, long> _lastUnknownCarLogUtcTicks = new Dictionary<ushort, long>();
|
||||||
|
|
||||||
|
public static void Register(IFass2UdpCar car)
|
||||||
|
{
|
||||||
|
if (car == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (SyncRoot)
|
||||||
|
{
|
||||||
|
CarsByCode[car.VehicleCode] = car;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Unregister(IFass2UdpCar car)
|
||||||
|
{
|
||||||
|
if (car == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Unregister(car.VehicleCode, car);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Unregister(ushort vehicleCode, IFass2UdpCar car)
|
||||||
|
{
|
||||||
|
if (car == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (SyncRoot)
|
||||||
|
{
|
||||||
|
if (CarsByCode.TryGetValue(vehicleCode, out var existing) && ReferenceEquals(existing, car))
|
||||||
|
{
|
||||||
|
CarsByCode.Remove(vehicleCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void EnsureStarted(int listenPort)
|
||||||
|
{
|
||||||
|
if (listenPort > 0)
|
||||||
|
{
|
||||||
|
_listenPort = listenPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (SyncRoot)
|
||||||
|
{
|
||||||
|
if (_running)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_listener = new UdpClient(_listenPort);
|
||||||
|
_running = true;
|
||||||
|
_receiveThread = new Thread(ReceiveLoop)
|
||||||
|
{
|
||||||
|
IsBackground = true,
|
||||||
|
Name = $"Fass2UdpHub:{_listenPort}"
|
||||||
|
};
|
||||||
|
_receiveThread.Start();
|
||||||
|
Diagnosis.Post($"Fass2UdpHub 已启动,监听 UDP {_listenPort}", "Fass2UdpHub", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Stop()
|
||||||
|
{
|
||||||
|
lock (SyncRoot)
|
||||||
|
{
|
||||||
|
_running = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_listener?.Close();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
_listener = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_receiveThread?.Join(1000);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
_receiveThread = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void SendToCar(IFass2UdpCar car, byte[] payload)
|
||||||
|
{
|
||||||
|
if (car == null || payload == null || payload.Length == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(car.RemoteAddress) || car.RemotePort <= 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Fass2UdpHub send failed: car VehicleCode={car.VehicleCode} remote endpoint not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
using var sender = new UdpClient();
|
||||||
|
var remote = new IPEndPoint(IPAddress.Parse(car.RemoteAddress), car.RemotePort);
|
||||||
|
sender.Send(payload, payload.Length, remote);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ReceiveLoop()
|
||||||
|
{
|
||||||
|
while (_running)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var remote = new IPEndPoint(IPAddress.Any, 0);
|
||||||
|
var packet = _listener.Receive(ref remote);
|
||||||
|
ProcessPacket(packet, remote);
|
||||||
|
}
|
||||||
|
catch (SocketException) when (!_running)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (ObjectDisposedException) when (!_running)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Diagnosis.Post($"Fass2UdpHub receive error: {ex.Message}", "Fass2UdpHub", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ProcessPacket(byte[] packet, IPEndPoint remote)
|
||||||
|
{
|
||||||
|
if (!Fass2Protocol.TryExtractStateFrame(packet, packet.Length, out var frame))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Fass2StateReport report;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
report = Fass2Protocol.ParseState(frame);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Diagnosis.Post($"Fass2UdpHub parse error from {remote}: {ex.Message}", "Fass2UdpHub", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IFass2UdpCar car;
|
||||||
|
lock (SyncRoot)
|
||||||
|
{
|
||||||
|
if (!CarsByCode.TryGetValue(report.Car, out car))
|
||||||
|
{
|
||||||
|
LogUnknownCar(report.Car);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
car.OnUdpStateReceived(report);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Diagnosis.Post($"Fass2UdpHub dispatch error car={report.Car}: {ex.Message}", "Fass2UdpHub", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ack = Fass2Protocol.BuildStateResponse(report.Car, Fass2Protocol.BeijingUnixTimeMs());
|
||||||
|
SendToCar(car, ack);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Diagnosis.Post($"Fass2UdpHub 0x10 reply failed car={report.Car}: {ex.Message}", "Fass2UdpHub", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void LogUnknownCar(ushort carCode)
|
||||||
|
{
|
||||||
|
var nowTicks = DateTime.UtcNow.Ticks;
|
||||||
|
lock (SyncRoot)
|
||||||
|
{
|
||||||
|
if (_lastUnknownCarLogUtcTicks.TryGetValue(carCode, out var lastTicks)
|
||||||
|
&& nowTicks - lastTicks < TimeSpan.FromSeconds(5).Ticks)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastUnknownCarLogUtcTicks[carCode] = nowTicks;
|
||||||
|
}
|
||||||
|
|
||||||
|
var registered = string.Join(",", CarsByCode.Keys);
|
||||||
|
if (string.IsNullOrEmpty(registered))
|
||||||
|
{
|
||||||
|
registered = "(无)";
|
||||||
|
}
|
||||||
|
|
||||||
|
Diagnosis.Post(
|
||||||
|
$"Fass2UdpHub 收到 Car={carCode} 的状态,但未注册该编号(已注册: {registered})。请检查 MagFass2Car.VehicleCode 与模拟器车号是否一致,并重新启动场景。",
|
||||||
|
"Fass2UdpHub",
|
||||||
|
true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace StandardScene.Magnetic.Protocol
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 由 <see cref="Fass2UdpHub"/> 回调的 UDP/PCB 车型处理接口。
|
||||||
|
/// </summary>
|
||||||
|
internal interface IFass2UdpCar
|
||||||
|
{
|
||||||
|
ushort VehicleCode { get; }
|
||||||
|
|
||||||
|
string RemoteAddress { get; }
|
||||||
|
|
||||||
|
int RemotePort { get; }
|
||||||
|
|
||||||
|
void OnUdpStateReceived(Fass2StateReport report);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
using LessokajiWeaverUtilities.Utilities;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using SimpleLite.RCS;
|
||||||
|
using SimpleLite.Props;
|
||||||
|
using SimpleCore;
|
||||||
|
using SimpleCore.Library;
|
||||||
|
using SimpleCore.PropType;
|
||||||
|
using StandardScene;
|
||||||
|
using StandardScene.Scheduler.FassEvent;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using SimpleLite.RCS.CarTypes;
|
||||||
|
|
||||||
|
|
||||||
|
namespace StandardScene.Scheduler
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// FASS 车辆事件调度进程。
|
||||||
|
/// <para>
|
||||||
|
/// 移植自 <c>fass/FASS.Scheduler/Services/Events/EventCarService.cs</c>:
|
||||||
|
/// 按 JSON 配置定时轮询全场车辆,匹配触发条件后执行交管/PLC/电量等条件判断,
|
||||||
|
/// 最终对 MagCar 下发 Start(放行)或 Stop(停车)。
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// 使用方式:在场景中添加本 Mission →「启动进程」→ 默认加载 <c>Configs\EventCar\FG2512073.json</c>。
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
[MissionType(Name = "FASS车辆事件进程", editor = typeof(EventCarMission))]
|
||||||
|
[I18N.DocumentTranslation(Name = "FASS Event Car Mission", locale = "en")]
|
||||||
|
public class EventCarMission : Mission
|
||||||
|
{
|
||||||
|
/// <summary>EventCar JSON 配置文件路径(相对程序目录或绝对路径)。</summary>
|
||||||
|
[FieldMember] public string EventConfigPath = "Config\\EventCar\\traffic.json";
|
||||||
|
|
||||||
|
/// <summary>主循环轮询间隔(毫秒),对应 FASS TimerTick 频率,默认 500ms。</summary>
|
||||||
|
[FieldMember] public int TickIntervalMs = 500;
|
||||||
|
|
||||||
|
/// <summary>与下料站/安全信号交互用的西门子 PLC IP。</summary>
|
||||||
|
[FieldMember] public string PlcIpAddress = "127.0.0.1";
|
||||||
|
|
||||||
|
/// <summary>PLC 端口,S7 默认 102。</summary>
|
||||||
|
[FieldMember] public int PlcPort = 102;
|
||||||
|
|
||||||
|
/// <summary>PLC 型号字符串,如 S7_1500、S7_300,传给 IoTClient SiemensClient。</summary>
|
||||||
|
[FieldMember] public string PlcVersion = "S7_1500";
|
||||||
|
|
||||||
|
/// <summary>是否启用 ME11 下料站 PLC 信号交互(false 时 requestStaion/InplaceStaion 等直接返回 true)。</summary>
|
||||||
|
[FieldMember] public bool IsEnableMEll = true;
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>充电完成放行默认电量阈值(%),可被事件内 ChargeStart 参数覆盖。</summary>
|
||||||
|
[FieldMember] public double ChargeStartThreshold = 93;
|
||||||
|
|
||||||
|
|
||||||
|
[JsonIgnore] private bool _started;
|
||||||
|
|
||||||
|
[JsonIgnore] private Thread _workerThread;
|
||||||
|
|
||||||
|
[JsonIgnore] private FassEventCarHandler _handler;
|
||||||
|
|
||||||
|
/// <summary>当前生效的车辆事件配置列表(TriggerSource=Car 且 TriggerEnable=true)。</summary>
|
||||||
|
[JsonIgnore] private List<FassEventCarConfig> _eventCars = new List<FassEventCarConfig>();
|
||||||
|
|
||||||
|
/// <summary>记录每辆车上一周期的 CurrentNode,用于 PrevNode 触发与地标变化时清除任务防重复标记。</summary>
|
||||||
|
[JsonIgnore] private readonly Dictionary<int, string> _prevNodeByCarId = new Dictionary<int, string>();
|
||||||
|
|
||||||
|
/// <summary>进程运行状态扩展字段。</summary>
|
||||||
|
public class EventCarMissionStatus : MissionStatus
|
||||||
|
{
|
||||||
|
/// <summary>主循环已执行次数。</summary>
|
||||||
|
public int TickCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>已加载的事件规则条数。</summary>
|
||||||
|
public int LoadedEventCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>实际加载的配置文件绝对/解析路径。</summary>
|
||||||
|
public string ConfigPath { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public override MissionStatus status { get; set; } = new EventCarMissionStatus();
|
||||||
|
|
||||||
|
public static Mission Create()
|
||||||
|
{
|
||||||
|
return new EventCarMission();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 启动后台轮询线程:加载配置 → 创建 Handler → 周期性 ProcessAllCars。
|
||||||
|
/// </summary>
|
||||||
|
[MethodMember(Name = "启动进程", Description = "加载 FASS EventCar 配置并开始轮询车辆事件")]
|
||||||
|
public override void Execute()
|
||||||
|
{
|
||||||
|
if (_started)
|
||||||
|
{
|
||||||
|
status.status = "已在运行";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ReloadConfig();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
status.status = "配置加载失败";
|
||||||
|
Diagnosis.Post($"FASS车辆事件进程配置加载失败:{ExceptionFormatter.FormatEx(ex)}", "FassEventCar", true);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
_handler = new FassEventCarHandler(this);
|
||||||
|
_started = true;
|
||||||
|
status.status = "运行中";
|
||||||
|
|
||||||
|
_workerThread = new Thread(WorkerLoop)
|
||||||
|
{
|
||||||
|
IsBackground = true,
|
||||||
|
Name = "EventCarMission"
|
||||||
|
};
|
||||||
|
_workerThread.Start();
|
||||||
|
Diagnosis.Post($"FASS车辆事件进程已启动,配置数={_eventCars.Count}", "FassEventCar", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>热加载 JSON 配置,运行中也可调用。</summary>
|
||||||
|
[MethodMember(Name = "重新加载配置", Description = "重新读取 EventCar JSON 配置")]
|
||||||
|
public void ReloadConfig()
|
||||||
|
{
|
||||||
|
var path = ResolveConfigPath();
|
||||||
|
_eventCars = FassEventConfigLoader.Load(path)
|
||||||
|
.Where(item => item.TriggerEnable && string.Equals(item.TriggerSource, "Car", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var missionStatus = (EventCarMissionStatus)status;
|
||||||
|
missionStatus.LoadedEventCount = _eventCars.Count;
|
||||||
|
missionStatus.ConfigPath = path;
|
||||||
|
status.status = _started ? "运行中" : "配置已加载";
|
||||||
|
Diagnosis.Post($"FASS EventCar 配置已加载:{path},事件数={_eventCars.Count}", "FassEventCar", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>停止轮询并等待工作线程结束(最多 2 秒)。</summary>
|
||||||
|
[MethodMember(Name = "停止进程", Description = "停止 FASS 车辆事件轮询")]
|
||||||
|
public void Stop()
|
||||||
|
{
|
||||||
|
_started = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_workerThread?.Join(2000);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
status.status = "已停止";
|
||||||
|
Diagnosis.Post("FASS车辆事件进程已停止", "FassEventCar", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 后台主循环:每 TickIntervalMs 扫描一次全场车辆。
|
||||||
|
/// 单轮异常不会终止进程,仅写诊断日志。
|
||||||
|
/// </summary>
|
||||||
|
private void WorkerLoop()
|
||||||
|
{
|
||||||
|
while (_started)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ProcessAllCars();
|
||||||
|
var missionStatus = (EventCarMissionStatus)status;
|
||||||
|
missionStatus.TickCount++;
|
||||||
|
status.status = $"运行中:{missionStatus.TickCount}";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Diagnosis.Post($"FASS车辆事件进程循环异常:{ExceptionFormatter.FormatEx(ex)}", "FassEventCar", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Thread.Sleep(Math.Max(100, TickIntervalMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 遍历所有 Car,构建快照并异步调用 Handler(与 FASS Task.Run 行为一致)。
|
||||||
|
/// <para>
|
||||||
|
/// 跳过 status 为空或 usage.refreshing=false 的车辆(未参与调度刷新的车不处理)。
|
||||||
|
/// 当地标变化时清除 <see cref="FassEventCarHandler.ActiveTaskTag"/>,允许在新地标重新触发事件。
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
private void ProcessAllCars()
|
||||||
|
{
|
||||||
|
foreach (var car in SimpleLib.GetAllCars().OfType<Car>())
|
||||||
|
{
|
||||||
|
if (car.status == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var usage = car.status.usage?.Get();
|
||||||
|
if (usage != null && usage.refreshing == false)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentNode = FassEventCarSnapshotFactory.GetCurrentNodeCode(car);
|
||||||
|
_prevNodeByCarId.TryGetValue(car.id, out var prevNode);
|
||||||
|
var snapshot = FassEventCarSnapshotFactory.Create(car, prevNode, FassEventCarSnapshotFactory.GetNextNodeCode(car));
|
||||||
|
|
||||||
|
Task.Run(() => _handler.HandleCarTimerTick(car, snapshot, _eventCars));
|
||||||
|
|
||||||
|
if (!string.Equals(prevNode, currentNode, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_prevNodeByCarId[car.id] = currentNode;
|
||||||
|
ClearTaskTags(car);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>车辆换地标后删除防重复执行标签,使同一规则可在新站点再次触发。</summary>
|
||||||
|
private static void ClearTaskTags(Car car)
|
||||||
|
{
|
||||||
|
if (car.tags == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Commons.DeleteTag(car.tags, FassEventCarHandler.ActiveTaskTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 解析配置文件路径:绝对路径直接用;相对路径依次尝试 BaseDirectory、CurrentDirectory、上级目录。
|
||||||
|
/// </summary>
|
||||||
|
private string ResolveConfigPath()
|
||||||
|
{
|
||||||
|
if (Path.IsPathRooted(EventConfigPath))
|
||||||
|
{
|
||||||
|
return EventConfigPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidates = new[]
|
||||||
|
{
|
||||||
|
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, EventConfigPath),
|
||||||
|
Path.Combine(Directory.GetCurrentDirectory(), EventConfigPath),
|
||||||
|
Path.GetFullPath(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", EventConfigPath))
|
||||||
|
};
|
||||||
|
|
||||||
|
return candidates.FirstOrDefault(File.Exists) ?? candidates[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,577 @@
|
|||||||
|
using IoTClient.Clients.PLC;
|
||||||
|
using IoTClient.Common.Enums;
|
||||||
|
using LessokajiWeaverUtilities.Utilities;
|
||||||
|
using SimpleLite.RCS;
|
||||||
|
using SimpleCore.Library;
|
||||||
|
using SimpleCore.PropType;
|
||||||
|
using StandardScene;
|
||||||
|
using StandardScene.CarTypes;
|
||||||
|
using StandardScene.Scheduler;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using SimpleLite.RCS.CarTypes;
|
||||||
|
|
||||||
|
namespace StandardScene.Scheduler.FassEvent
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// FASS 车辆事件核心处理器,逻辑对齐 <c>EventCarService</c>。
|
||||||
|
/// <para>处理流程:TriggerHandler(触发匹配)→ ConditionHandler(条件 AND)→ ExecuteTask(Start/Stop)。</para>
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class FassEventCarHandler
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 防重复执行任务的车辆标签名。同一地标周期内只执行一次,换地标后由 EventCarMission 清除。
|
||||||
|
/// 标签值为 <see cref="BuildTaskKey"/> 生成的任务指纹。
|
||||||
|
/// </summary>
|
||||||
|
internal const string ActiveTaskTag = "FassEvent:Active";
|
||||||
|
|
||||||
|
private readonly EventCarMission _mission;
|
||||||
|
|
||||||
|
/// <summary>PLC 读写串行锁,避免多车并发条件中同时 Open/Close 同一连接。</summary>
|
||||||
|
private readonly object _plcLock = new object();
|
||||||
|
|
||||||
|
/// <summary>JSON ConditionMethodName → 内置条件方法 映射表。</summary>
|
||||||
|
private readonly Dictionary<string, Func<FassEventCarSnapshot, FassConditionEventConfig, bool>> _eventMethods;
|
||||||
|
|
||||||
|
public FassEventCarHandler(EventCarMission mission)
|
||||||
|
{
|
||||||
|
_mission = mission;
|
||||||
|
_eventMethods = BuildEventMethods();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 单次 TimerTick 对一辆车的处理入口(通常在 Task.Run 中调用)。
|
||||||
|
/// 仅处理 TriggerEvent=TimerTick 的配置项。
|
||||||
|
/// </summary>
|
||||||
|
public void HandleCarTimerTick(Car car, FassEventCarSnapshot snapshot, IReadOnlyList<FassEventCarConfig> eventCars)
|
||||||
|
{
|
||||||
|
foreach (var eventConfig in eventCars.Where(item => string.Equals(item.TriggerEvent, "TimerTick", StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
if (!TriggerHandler(snapshot, eventConfig))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ConditionHandler(car, snapshot, eventConfig))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!eventConfig.TaskEnable)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ExecuteTask(car, snapshot, eventConfig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>注册与 FASS EventCarService.InitEventMethod 一致的条件方法名(保留历史拼写)。</summary>
|
||||||
|
private Dictionary<string, Func<FassEventCarSnapshot, FassConditionEventConfig, bool>> BuildEventMethods()
|
||||||
|
{
|
||||||
|
return new Dictionary<string, Func<FassEventCarSnapshot, FassConditionEventConfig, bool>>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["NodeAllUnlockedNotCar"] = NodeAllUnlockedNotCar,
|
||||||
|
["NodeAllUnlocked"] = NodeAllUnlocked,
|
||||||
|
["NodeAnyLockedNotCar"] = NodeAnyLockedNotCar,
|
||||||
|
["NodeAnyLocked"] = NodeAnyLocked,
|
||||||
|
["NodeAllUnlockedNotCarNotEndCarState"] = NodeAllUnlockedNotCarNotEndCarState,
|
||||||
|
["requestStaion"] = RequestStation,
|
||||||
|
["clearStaion"] = ClearStation,
|
||||||
|
["EmegerStop"] = EmergencyStop,
|
||||||
|
["EmegerStart"] = EmergencyStart,
|
||||||
|
["IsExistRunningStateCar"] = IsExistRunningStateCar,
|
||||||
|
["ChargeStart"] = ChargeStart
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 触发器匹配:车辆编号、前后地标、状态、角度等 CSV 过滤。
|
||||||
|
/// 配置项为空表示“不限制”;车辆对应字段为空则无法匹配非空配置。
|
||||||
|
/// </summary>
|
||||||
|
private bool TriggerHandler(FassEventCarSnapshot snapshot, FassEventCarConfig config)
|
||||||
|
{
|
||||||
|
if (!MatchesCsvFilter(config.TriggerCarCode, snapshot.Code))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!MatchesCsvFilter(config.TriggerCarPrevNodeCode, snapshot.PrevNodeCode))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!MatchesCsvFilter(config.TriggerCarCurrentNodeCode, snapshot.CurrentNodeCode))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!MatchesCsvFilter(config.TriggerCarNextNodeCode, snapshot.NextNodeCode))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!MatchesCsvFilter(config.TriggerCarState, snapshot.State))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!MatchesCsvFilter(config.TriggerCarAngle, snapshot.Angle.ToString(CultureInfo.InvariantCulture)))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>所有 ConditionEvents 必须全部通过(逻辑与)。</summary>
|
||||||
|
private bool ConditionHandler(Car car, FassEventCarSnapshot snapshot, FassEventCarConfig config)
|
||||||
|
{
|
||||||
|
if (config.ConditionEvents == null || config.ConditionEvents.Count == 0)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var condition in config.ConditionEvents)
|
||||||
|
{
|
||||||
|
if (!SingleConditionHandler(car, snapshot, condition))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>单条条件分发:Method / Assembly / Plugin。</summary>
|
||||||
|
private bool SingleConditionHandler(Car car, FassEventCarSnapshot snapshot, FassConditionEventConfig condition)
|
||||||
|
{
|
||||||
|
if (!condition.ConditionEnable)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var conditionType = condition.ConditionType ?? "Method";
|
||||||
|
if (string.Equals(conditionType, "Method", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return MethodInvoke(snapshot, condition);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(conditionType, "Assembly", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(conditionType, "Plugin", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
LogSignal($"暂不支持的 ConditionType={conditionType}, Method={condition.ConditionMethodName}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>按方法名调用内置条件实现。</summary>
|
||||||
|
private bool MethodInvoke(FassEventCarSnapshot snapshot, FassConditionEventConfig condition)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(condition.ConditionMethodName))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_eventMethods.TryGetValue(condition.ConditionMethodName, out var handler))
|
||||||
|
{
|
||||||
|
return handler(snapshot, condition);
|
||||||
|
}
|
||||||
|
|
||||||
|
LogSignal($"未注册的条件方法:{condition.ConditionMethodName}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 执行任务:MagCar 调用 SendStart/SendStop;其他车型仅打日志。
|
||||||
|
/// 通过 ActiveTaskTag 防止同一地标周期内重复下发。
|
||||||
|
/// </summary>
|
||||||
|
private void ExecuteTask(Car car, FassEventCarSnapshot snapshot, FassEventCarConfig config)
|
||||||
|
{
|
||||||
|
if (car.tags != null && car.tags.Contains(ActiveTaskTag))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var taskType = config.TaskType ?? "Start";
|
||||||
|
if (car is MagCar magCar)
|
||||||
|
{
|
||||||
|
switch (taskType)
|
||||||
|
{
|
||||||
|
case "Start":
|
||||||
|
magCar.SendStart((ushort)Math.Round(magCar.th));
|
||||||
|
break;
|
||||||
|
case "Stop":
|
||||||
|
magCar.SendStop(0);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
LogTraffic($"车辆[{snapshot.Code}] 收到任务类型[{taskType}],当前仅显式处理 Start/Stop");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
LogTraffic($"车辆[{snapshot.Code}] 非 MagCar,任务类型[{taskType}] 仅记录标签");
|
||||||
|
}
|
||||||
|
|
||||||
|
var taskKey = BuildTaskKey(snapshot, config);
|
||||||
|
Commons.AddOrUpdateTag(car.tags, ActiveTaskTag, taskKey);
|
||||||
|
LogTraffic($"车辆[{snapshot.Code}] 触发事件任务:{taskType} @ node={snapshot.CurrentNodeCode}, state={snapshot.State}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>生成任务指纹,写入 ActiveTaskTag 的值,便于日志追溯。</summary>
|
||||||
|
private static string BuildTaskKey(FassEventCarSnapshot snapshot, FassEventCarConfig config)
|
||||||
|
{
|
||||||
|
return $"FassEvent:{snapshot.CurrentNodeCode}:{config.TriggerCarState}:{config.TaskType}:{string.Join("|", config.ConditionEvents?.Select(item => item.ConditionMethodName) ?? Array.Empty<string>())}";
|
||||||
|
}
|
||||||
|
|
||||||
|
#region 交管条件(节点锁)
|
||||||
|
|
||||||
|
/// <summary>指定节点全部未被其他车辆占用(排除自身)。参数:逗号分隔节点号。</summary>
|
||||||
|
private bool NodeAllUnlockedNotCar(FassEventCarSnapshot snapshot, FassConditionEventConfig condition)
|
||||||
|
{
|
||||||
|
return FassEventNodeHelper.NodeAllUnlockedNotCar(snapshot.Code, SplitArgs(condition.ConditionMethodArgs));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>指定节点全部空闲(含自身锁也计入)。</summary>
|
||||||
|
private bool NodeAllUnlocked(FassEventCarSnapshot snapshot, FassConditionEventConfig condition)
|
||||||
|
{
|
||||||
|
return FassEventNodeHelper.NodeAllUnlocked(SplitArgs(condition.ConditionMethodArgs));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>指定节点中存在被其他车占用的点。</summary>
|
||||||
|
private bool NodeAnyLockedNotCar(FassEventCarSnapshot snapshot, FassConditionEventConfig condition)
|
||||||
|
{
|
||||||
|
return FassEventNodeHelper.NodeAnyLockedNotCar(snapshot.Code, SplitArgs(condition.ConditionMethodArgs));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 指定节点存在占用时先 SendStart 再判断(与 FASS NodeAnyLocked 行为一致)。
|
||||||
|
/// </summary>
|
||||||
|
private bool NodeAnyLocked(FassEventCarSnapshot snapshot, FassConditionEventConfig condition)
|
||||||
|
{
|
||||||
|
var car = SimpleCore.SimpleLib.GetCar(snapshot.CarId) as MagCar;
|
||||||
|
car?.SendStart((ushort)Math.Round(car.th));
|
||||||
|
return FassEventNodeHelper.NodeAnyLocked(SplitArgs(condition.ConditionMethodArgs));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>节点全解锁且路径上无其他运行中车辆。</summary>
|
||||||
|
private bool NodeAllUnlockedNotCarNotEndCarState(FassEventCarSnapshot snapshot, FassConditionEventConfig condition)
|
||||||
|
{
|
||||||
|
return FassEventNodeHelper.NodeAllUnlockedNotCarNotEndCarState(snapshot.Code, "Runing", SplitArgs(condition.ConditionMethodArgs));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>判断指定节点范围是否不存在其他“运行中”车辆(返回 true 表示可以放行)。</summary>
|
||||||
|
private bool IsExistRunningStateCar(FassEventCarSnapshot snapshot, FassConditionEventConfig condition)
|
||||||
|
{
|
||||||
|
var param = SplitArgs(condition.ConditionMethodArgs);
|
||||||
|
LogTraffic($"IsExistRunningStateCar 参数:{condition.ConditionMethodArgs}");
|
||||||
|
return !FassEventNodeHelper.ExistsRunningCarOnNodes(snapshot.Code, "Runing", param);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region 电量与安全
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 电量达到阈值方可放行。阈值优先取 ConditionMethodArgs 首参,否则用 Mission.ChargeStartThreshold。
|
||||||
|
/// </summary>
|
||||||
|
private bool ChargeStart(FassEventCarSnapshot snapshot, FassConditionEventConfig condition)
|
||||||
|
{
|
||||||
|
var threshold = _mission.ChargeStartThreshold;
|
||||||
|
var configured = SplitArgs(condition.ConditionMethodArgs).FirstOrDefault();
|
||||||
|
if (!string.IsNullOrWhiteSpace(configured) &&
|
||||||
|
double.TryParse(configured, NumberStyles.Float, CultureInfo.InvariantCulture, out var eventThreshold))
|
||||||
|
{
|
||||||
|
threshold = eventThreshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = snapshot.Charge >= threshold;
|
||||||
|
if (result)
|
||||||
|
{
|
||||||
|
LogSignal($"ChargeStart 车辆[{snapshot.Code}] 电量[{snapshot.Charge}] >= 阈值[{threshold}]");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 设备安全急停:读 PLC 布尔位,false 表示故障需停车(条件返回 true 以触发 Stop 任务)。
|
||||||
|
/// 参数 param[0]:PLC 地址。
|
||||||
|
/// </summary>
|
||||||
|
private bool EmergencyStop(FassEventCarSnapshot snapshot, FassConditionEventConfig condition)
|
||||||
|
{
|
||||||
|
return ExecutePlcCondition(snapshot, condition, 1, (client, param) =>
|
||||||
|
{
|
||||||
|
var allowIn = client.ReadBoolean(param[0]).Value;
|
||||||
|
if (!allowIn)
|
||||||
|
{
|
||||||
|
LogSignal($"设备安全故障急停:{allowIn}");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 设备安全恢复:PLC 布尔位为 true 时允许 Start。
|
||||||
|
/// </summary>
|
||||||
|
private bool EmergencyStart(FassEventCarSnapshot snapshot, FassConditionEventConfig condition)
|
||||||
|
{
|
||||||
|
return ExecutePlcCondition(snapshot, condition, 1, (client, param) =>
|
||||||
|
{
|
||||||
|
var allowIn = client.ReadBoolean(param[0]).Value;
|
||||||
|
if (allowIn)
|
||||||
|
{
|
||||||
|
LogSignal($"设备安全恢复放行:{allowIn}");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region ME11 下料站 PLC 交互
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 清除站点到位与离开中信号。
|
||||||
|
/// 参数:param[0] 到位信号地址,param[1] 离开中信号地址。
|
||||||
|
/// </summary>
|
||||||
|
private bool ClearStation(FassEventCarSnapshot snapshot, FassConditionEventConfig condition)
|
||||||
|
{
|
||||||
|
var context = BuildStationLogContext(nameof(ClearStation), snapshot, condition);
|
||||||
|
LogSignal($"{context} 开始清除站点到位/离开中信号");
|
||||||
|
if (!_mission.IsEnableMEll)
|
||||||
|
{
|
||||||
|
LogSignal($"车辆[{snapshot.Code}] ME11 未启用,直接返回 true");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ExecutePlcCondition(snapshot, condition, 2, (client, param) =>
|
||||||
|
{
|
||||||
|
var afterClear = WriteAndRead(client, param[0], false, "清除到位信号", context);
|
||||||
|
WriteStationSignal(client, param[1], false, "清除离开中信号", context);
|
||||||
|
var leavingClear = client.ReadBoolean(param[1]).Value;
|
||||||
|
var result = !afterClear && !leavingClear;
|
||||||
|
LogSignal($"{context} 清除站点信号结束,返回[{result}]");
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 请求进入工位:读“请求进入”,为 true 时写“正在进入中”,并以进入中信号作为放行条件结果。
|
||||||
|
/// 参数:param[0] 请求进入,param[1] 正在进入中。
|
||||||
|
/// </summary>
|
||||||
|
private bool RequestStation(FassEventCarSnapshot snapshot, FassConditionEventConfig condition)
|
||||||
|
{
|
||||||
|
var context = BuildStationLogContext(nameof(RequestStation), snapshot, condition);
|
||||||
|
LogSignal($"{context} 开始请求进入信号判断");
|
||||||
|
if (!_mission.IsEnableMEll)
|
||||||
|
{
|
||||||
|
LogSignal($"车辆[{snapshot.Code}] ME11 未启用,直接返回 true");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ExecutePlcCondition(snapshot, condition, 2, (client, param) =>
|
||||||
|
{
|
||||||
|
var requestIn = ReadStationSignal(client, param[0], "请求进入", context);
|
||||||
|
if (requestIn)
|
||||||
|
{
|
||||||
|
WriteStationSignal(client, param[1], true, "正在进入中", context);
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = ReadStationSignal(client, param[1], "正在进入中", context);
|
||||||
|
LogSignal($"{context} 请求进入判断结束,返回[{result}]");
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 到位与允许离开交互(ME11 下料核心逻辑)。
|
||||||
|
/// <para>
|
||||||
|
/// 参数:param[0] 到位,param[1] 允许离开,param[2] 正在进入中,param[3] 正在离开中。
|
||||||
|
/// ME11 车型:气缸伸出(Read3&Read5)后写到位;读到允许离开后切换为放行车型。
|
||||||
|
/// 放行车型:气缸收回(Read4&Read6)后写离开中、清除到位。
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>将车辆 fields["CarModel"] 切换为放行车型,供后续触发器匹配。</summary>
|
||||||
|
private void SaveCarModel(int carId, string carModel, string reason)
|
||||||
|
{
|
||||||
|
var car = SimpleCore.SimpleLib.GetCar(carId) as Car;
|
||||||
|
if (car == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
car.fields ??= new Dictionary<string, string>();
|
||||||
|
if (string.Equals(FassEventCarSnapshotFactory.GetCarModel(car), carModel, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
car.fields["CarModel"] = carModel;
|
||||||
|
LogSignal($"{reason}:车辆[{FassEventCarSnapshotFactory.GetCarCode(car)}] 车型切换为 [{carModel}]");
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region PLC 与工具方法
|
||||||
|
|
||||||
|
/// <summary>在 PLC 锁内打开连接、执行读写、关闭连接。</summary>
|
||||||
|
private bool ExecutePlcCondition(
|
||||||
|
FassEventCarSnapshot snapshot,
|
||||||
|
FassConditionEventConfig condition,
|
||||||
|
int minArgs,
|
||||||
|
Func<SiemensClient, string[], bool> action)
|
||||||
|
{
|
||||||
|
if (!TryGetConditionArgs(condition, minArgs, out var param))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_plcLock)
|
||||||
|
{
|
||||||
|
SiemensClient client = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
client = CreatePlcClient();
|
||||||
|
client.Open();
|
||||||
|
return action(client, param);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LogSignalError(ex, "交互站点报错");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
client?.Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>根据 Mission 配置创建西门子 PLC 客户端。</summary>
|
||||||
|
private SiemensClient CreatePlcClient()
|
||||||
|
{
|
||||||
|
var version = ParsePlcVersion(_mission.PlcVersion);
|
||||||
|
return new SiemensClient(version, _mission.PlcIpAddress, _mission.PlcPort, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SiemensVersion ParsePlcVersion(string version)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(version))
|
||||||
|
{
|
||||||
|
return SiemensVersion.S7_1500;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Enum.TryParse(version, true, out SiemensVersion parsed) ? parsed : SiemensVersion.S7_1500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>拼接站点交互日志上下文,便于现场对照 PLC 地址排查。</summary>
|
||||||
|
private string BuildStationLogContext(string methodName, FassEventCarSnapshot snapshot, FassConditionEventConfig condition)
|
||||||
|
{
|
||||||
|
return $"{methodName} 车辆[{snapshot.Code}] 状态[{snapshot.State}] 当前地标[{snapshot.CurrentNodeCode}] 下一地标[{snapshot.NextNodeCode}] 车型[{snapshot.CarModel}] 参数[{condition.ConditionMethodArgs}]";
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool ReadStationSignal(SiemensClient client, string address, string signalName, string context)
|
||||||
|
{
|
||||||
|
var value = client.ReadBoolean(address).Value;
|
||||||
|
LogSignal($"{context} 读取信号[{signalName}] 地址[{address}] 值[{value}]");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WriteStationSignal(SiemensClient client, string address, bool value, string signalName, string context)
|
||||||
|
{
|
||||||
|
LogSignal($"{context} 写入信号[{signalName}] 地址[{address}] 值[{value}]");
|
||||||
|
client.Write(address, value);
|
||||||
|
var readBackValue = client.ReadBoolean(address).Value;
|
||||||
|
LogSignal($"{context} 回读信号[{signalName}] 地址[{address}] 值[{readBackValue}]");
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool WriteAndRead(SiemensClient client, string address, bool value, string signalName, string context)
|
||||||
|
{
|
||||||
|
WriteStationSignal(client, address, value, signalName, context);
|
||||||
|
return client.ReadBoolean(address).Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>校验条件参数个数是否满足 PLC 方法要求。</summary>
|
||||||
|
private static bool TryGetConditionArgs(FassConditionEventConfig condition, int minLength, out string[] args)
|
||||||
|
{
|
||||||
|
args = SplitArgs(condition.ConditionMethodArgs);
|
||||||
|
return args.Length >= minLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>将逗号分隔的配置参数字符串拆分为数组。</summary>
|
||||||
|
private static string[] SplitArgs(string args)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(args))
|
||||||
|
{
|
||||||
|
return Array.Empty<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
return args.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Select(item => item.Trim())
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// CSV 过滤器:filter 为空则通过;否则 value 必须等于 filter 中某项(忽略大小写)。
|
||||||
|
/// 用于 TriggerCarState 等多值匹配,如 "Stopping,Charging"。
|
||||||
|
/// </summary>
|
||||||
|
private static bool MatchesCsvFilter(string filter, string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(filter))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return filter.Split(',').Select(item => item.Trim()).Any(item => string.Equals(item, value, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LogTraffic(string message) => Diagnosis.Post(message, "Fass交管", true);
|
||||||
|
|
||||||
|
private void LogSignal(string message) => Diagnosis.Post(message, "Fass信号交互", true);
|
||||||
|
|
||||||
|
private void LogSignalError(Exception ex, string message) =>
|
||||||
|
Diagnosis.Post($"{message}: {ExceptionFormatter.FormatEx(ex)}", "Fass信号交互", true);
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>从磁盘加载 EventCar JSON 配置数组。</summary>
|
||||||
|
internal static class FassEventConfigLoader
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 读取并反序列化配置文件。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="configPath">JSON 文件路径。</param>
|
||||||
|
/// <returns>事件配置列表;文件内容为空数组时返回空列表。</returns>
|
||||||
|
/// <exception cref="FileNotFoundException">文件不存在时抛出。</exception>
|
||||||
|
public static List<FassEventCarConfig> Load(string configPath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(configPath) || !File.Exists(configPath))
|
||||||
|
{
|
||||||
|
throw new FileNotFoundException($"未找到 FASS 车辆事件配置文件:{configPath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = File.ReadAllText(configPath);
|
||||||
|
return Newtonsoft.Json.JsonConvert.DeserializeObject<List<FassEventCarConfig>>(json)
|
||||||
|
?? new List<FassEventCarConfig>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
using SimpleLite.RCS;
|
||||||
|
using SimpleCore.PropType;
|
||||||
|
using StandardScene.CarTypes;
|
||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using SimpleLite.RCS.CarTypes;
|
||||||
|
|
||||||
|
namespace StandardScene.Scheduler.FassEvent
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 将 StandardScene 车辆对象转换为 FASS 事件系统可识别的快照字段。
|
||||||
|
/// 负责编号、地标、状态、电量等与 <c>EventCarService.TriggerHandler</c> 对齐的映射。
|
||||||
|
/// </summary>
|
||||||
|
internal static class FassEventCarSnapshotFactory
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取车辆“下一地标”编码。
|
||||||
|
/// 优先级:status.enums["NextNode"] → tags["NextNode"] → pendingLocks 首站(TagValue 或站点 ID)。
|
||||||
|
/// </summary>
|
||||||
|
public static string GetNextNodeCode(Car car)
|
||||||
|
{
|
||||||
|
if (car.status?.enums != null && car.status.enums.TryGetValue("NextNode", out var node) && !string.IsNullOrWhiteSpace(node))
|
||||||
|
{
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (car.tags != null && car.tags.TryGetValue("NextNode", out var tagNode) && !string.IsNullOrWhiteSpace(tagNode))
|
||||||
|
{
|
||||||
|
return tagNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调度 pendingLocks 通常表示车辆即将前往的站点,可近似为 FASS NextNodeCode。
|
||||||
|
if (car.status?.pendingLocks != null && car.status.pendingLocks.Length > 0)
|
||||||
|
{
|
||||||
|
var pendingSiteId = car.status.pendingLocks[0];
|
||||||
|
var site = SimpleCore.SimpleLib.GetSite(pendingSiteId);
|
||||||
|
if (site?.fields != null && site.fields.TryGetValue("TagValue", out var tagValue) && !string.IsNullOrWhiteSpace(tagValue))
|
||||||
|
{
|
||||||
|
return tagValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return pendingSiteId.ToString(CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 根据车辆与上一周期地标构建完整快照。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="car">场景车辆。</param>
|
||||||
|
/// <param name="prevNodeCode">上一周期 CurrentNode,由 EventCarMission 维护。</param>
|
||||||
|
/// <param name="nextNodeCode">下一地标;为空时自动调用 <see cref="GetNextNodeCode"/>。</param>
|
||||||
|
public static FassEventCarSnapshot Create(Car car, string prevNodeCode, string nextNodeCode)
|
||||||
|
{
|
||||||
|
return new FassEventCarSnapshot
|
||||||
|
{
|
||||||
|
CarId = car.id,
|
||||||
|
Code = GetCarCode(car),
|
||||||
|
CarModel = GetCarModel(car),
|
||||||
|
PrevNodeCode = prevNodeCode,
|
||||||
|
CurrentNodeCode = GetCurrentNodeCode(car),
|
||||||
|
NextNodeCode = string.IsNullOrWhiteSpace(nextNodeCode) ? GetNextNodeCode(car) : nextNodeCode,
|
||||||
|
State = GetFassState(car),
|
||||||
|
Angle = (int)Math.Round(car.th),
|
||||||
|
Charge = GetCharge(car)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取 FASS 车辆编号。MagCar 优先使用 VehicleCode,否则 fields["Code"],最后回退 name。
|
||||||
|
/// </summary>
|
||||||
|
public static string GetCarCode(Car car)
|
||||||
|
{
|
||||||
|
if (car is MagCar magCar && magCar.VehicleCode > 0)
|
||||||
|
{
|
||||||
|
return magCar.VehicleCode.ToString(CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (car.fields != null && car.fields.TryGetValue("Code", out var code) && !string.IsNullOrWhiteSpace(code))
|
||||||
|
{
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
return car.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>获取车型。下料站 InplaceStaion 根据 CarModel 在 ME11 / 放行车型间切换。</summary>
|
||||||
|
public static string GetCarModel(Car car)
|
||||||
|
{
|
||||||
|
if (car.fields != null && car.fields.TryGetValue("CarModel", out var model) && !string.IsNullOrWhiteSpace(model))
|
||||||
|
{
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
return car.GetType().Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取当前地标号。MagCar 上报后写入 status.enums["CurrentNode"];
|
||||||
|
/// 其他车型可回退到 siteID。
|
||||||
|
/// </summary>
|
||||||
|
public static string GetCurrentNodeCode(Car car)
|
||||||
|
{
|
||||||
|
if (car.status?.enums != null && car.status.enums.TryGetValue("CurrentNode", out var node) && !string.IsNullOrWhiteSpace(node))
|
||||||
|
{
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
return car.siteID > 0 ? car.siteID.ToString(CultureInfo.InvariantCulture) : string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 将 StandardScene 车辆状态映射为 FASS 英文字符串。
|
||||||
|
/// <para>
|
||||||
|
/// 可手动覆盖:tags["FassState"] 或 enums["FassState"]。
|
||||||
|
/// WaitPass 需在停止中且 tags 含 WaitPass(FASS 中“等待放行”态)。
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public static string GetFassState(Car car)
|
||||||
|
{
|
||||||
|
if (car.tags != null && car.tags.TryGetValue("FassState", out var tagState) && !string.IsNullOrWhiteSpace(tagState))
|
||||||
|
{
|
||||||
|
return tagState;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (car.status?.enums != null && car.status.enums.TryGetValue("FassState", out var enumState) && !string.IsNullOrWhiteSpace(enumState))
|
||||||
|
{
|
||||||
|
return enumState;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (car.status?.enums != null && car.status.enums.TryGetValue("State", out var state))
|
||||||
|
{
|
||||||
|
switch (state)
|
||||||
|
{
|
||||||
|
case "运行中":
|
||||||
|
return "Running";
|
||||||
|
case "停止中":
|
||||||
|
return car.tags != null && car.tags.ContainsKey("WaitPass") ? "WaitPass" : "Stopping";
|
||||||
|
case "未准备":
|
||||||
|
return "NotReady";
|
||||||
|
case "充电中":
|
||||||
|
return "Charging";
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>从 status.enums["Soc"] 解析电量百分比。</summary>
|
||||||
|
private static double GetCharge(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace StandardScene.Scheduler.FassEvent
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// FASS 车辆事件配置项,对应 <c>Configs/EventCar/*.json</c> 数组中的单个元素。
|
||||||
|
/// 配置格式与 FASS.Scheduler 的 EventCar 模型保持一致,便于直接复用现场 JSON。
|
||||||
|
/// </summary>
|
||||||
|
public class FassEventCarConfig
|
||||||
|
{
|
||||||
|
/// <summary>是否启用本条事件规则。</summary>
|
||||||
|
public bool TriggerEnable { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 事件来源。StandardScene 进程仅处理值为 <c>Car</c> 的项(车辆定时轮询触发)。
|
||||||
|
/// </summary>
|
||||||
|
public string TriggerSource { get; set; } = "Car";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 触发时机。当前实现仅支持 <c>TimerTick</c>(与 FASS <c>EventCarService.CarTimerTick</c> 等价)。
|
||||||
|
/// </summary>
|
||||||
|
public string TriggerEvent { get; set; } = "TimerTick";
|
||||||
|
|
||||||
|
/// <summary>触发条件:车辆编号(逗号分隔,空表示不限制)。</summary>
|
||||||
|
public string TriggerCarCode { get; set; }
|
||||||
|
|
||||||
|
/// <summary>触发条件:上一地标号(逗号分隔)。</summary>
|
||||||
|
public string TriggerCarPrevNodeCode { get; set; }
|
||||||
|
|
||||||
|
/// <summary>触发条件:当前地标号(逗号分隔)。</summary>
|
||||||
|
public string TriggerCarCurrentNodeCode { get; set; }
|
||||||
|
|
||||||
|
/// <summary>触发条件:下一地标号(逗号分隔)。</summary>
|
||||||
|
public string TriggerCarNextNodeCode { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 触发条件:车辆 FASS 状态(逗号分隔)。
|
||||||
|
/// 常见值:Running、Stopping、WaitPass、Charging、Stopping,Charging 等。
|
||||||
|
/// </summary>
|
||||||
|
public string TriggerCarState { get; set; }
|
||||||
|
|
||||||
|
/// <summary>触发条件:车头角度(逗号分隔的整数字符串)。</summary>
|
||||||
|
public string TriggerCarAngle { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 条件事件列表。全部满足后才执行任务;
|
||||||
|
/// 对应 FASS 中 <c>ConditionHandler</c> 的逐项 AND 逻辑。
|
||||||
|
/// </summary>
|
||||||
|
public List<FassConditionEventConfig> ConditionEvents { get; set; } = new List<FassConditionEventConfig>();
|
||||||
|
|
||||||
|
/// <summary>条件满足后是否执行任务(false 时仅做条件判断,不下发 Start/Stop)。</summary>
|
||||||
|
public bool TaskEnable { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 任务类型。MagCar 当前支持 <c>Start</c>(放行)与 <c>Stop</c>(急停)。
|
||||||
|
/// </summary>
|
||||||
|
public string TaskType { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 单条条件事件配置,描述一个条件判断(交管、PLC 信号、电量等)。
|
||||||
|
/// </summary>
|
||||||
|
public class FassConditionEventConfig
|
||||||
|
{
|
||||||
|
/// <summary>是否启用本条件;false 时跳过(视为通过)。</summary>
|
||||||
|
public bool ConditionEnable { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 条件类型:<c>Method</c>(内置方法)、<c>Assembly</c>、<c>Plugin</c>。
|
||||||
|
/// StandardScene 当前仅实现 Method;后两者会记录日志并返回 false。
|
||||||
|
/// </summary>
|
||||||
|
public string ConditionType { get; set; } = "Method";
|
||||||
|
|
||||||
|
/// <summary>Assembly 条件:程序集文件路径(未实现)。</summary>
|
||||||
|
public string ConditionAssemblyFile { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Assembly/Plugin 条件:类型名(未实现)。</summary>
|
||||||
|
public string ConditionTypeName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Assembly/Plugin 条件:构造参数(未实现)。</summary>
|
||||||
|
public string ConditionTypeArgs { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Method 条件:方法名,如 NodeAllUnlockedNotCar、requestStaion、EmegerStop 等。
|
||||||
|
/// 名称与 FASS <c>EventCarService.InitEventMethod</c> 注册表一致(含历史拼写)。
|
||||||
|
/// </summary>
|
||||||
|
public string ConditionMethodName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Method 条件:方法参数,一般为逗号分隔的节点号或 PLC 地址。
|
||||||
|
/// 例如交管:<c>3,53,21</c>;PLC:<c>DB5524.0.0,DB5524.0.1</c>。
|
||||||
|
/// </summary>
|
||||||
|
public string ConditionMethodArgs { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 车辆事件处理用的只读快照,从 StandardScene <see cref="SimpleLite.RCS.Car"/> 提取 FASS 语义字段。
|
||||||
|
/// 每次 TimerTick 为每辆车构建一份,供触发器与条件方法使用。
|
||||||
|
/// </summary>
|
||||||
|
public class FassEventCarSnapshot
|
||||||
|
{
|
||||||
|
/// <summary>场景内车辆 ID(SimpleLib)。</summary>
|
||||||
|
public int CarId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>FASS 车辆编号(MagCar.VehicleCode 或 fields["Code"])。</summary>
|
||||||
|
public string Code { get; set; }
|
||||||
|
|
||||||
|
/// <summary>车型标识,用于 ME11 下料站逻辑(fields["CarModel"])。</summary>
|
||||||
|
public string CarModel { get; set; }
|
||||||
|
|
||||||
|
/// <summary>上一周期记录的地标号,用于 TriggerCarPrevNodeCode 匹配。</summary>
|
||||||
|
public string PrevNodeCode { get; set; }
|
||||||
|
|
||||||
|
/// <summary>当前地标号(status.enums["CurrentNode"] 或 siteID)。</summary>
|
||||||
|
public string CurrentNodeCode { get; set; }
|
||||||
|
|
||||||
|
/// <summary>下一地标号(pendingLocks / tags / enums 推断)。</summary>
|
||||||
|
public string NextNodeCode { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// FASS 状态字符串:Running、Stopping、WaitPass、Charging 等。
|
||||||
|
/// 由 <see cref="FassEventCarSnapshotFactory.GetFassState"/> 从中文状态映射。
|
||||||
|
/// </summary>
|
||||||
|
public string State { get; set; }
|
||||||
|
|
||||||
|
/// <summary>车头角度(度),对应 FASS Car.Angle。</summary>
|
||||||
|
public int Angle { get; set; }
|
||||||
|
|
||||||
|
/// <summary>电量百分比(Soc)。</summary>
|
||||||
|
public double Charge { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
using SimpleLite.RCS;
|
||||||
|
using SimpleCore;
|
||||||
|
using SimpleCore.PropType;
|
||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using SimpleLite.RCS.CarTypes;
|
||||||
|
|
||||||
|
namespace StandardScene.Scheduler.FassEvent
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 节点(地标)交管判断辅助类,替代 FASS <c>AlgorithmService</c> 中与 EventCar 相关的方法。
|
||||||
|
/// <para>
|
||||||
|
/// “锁定”判定依据 StandardScene 交通锁:holdingLocks、pendingLocks、aquiringLock,
|
||||||
|
/// 以及站点 tags 中的 unavailable。
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
internal static class FassEventNodeHelper
|
||||||
|
{
|
||||||
|
/// <summary>指定节点列表是否全部未被任何车辆占用。</summary>
|
||||||
|
public static bool NodeAllUnlocked(string[] nodeCodes)
|
||||||
|
{
|
||||||
|
return nodeCodes == null || nodeCodes.All(node => !IsNodeLocked(node, -1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>指定节点列表是否全部未被占用(排除当前车辆自身锁)。</summary>
|
||||||
|
public static bool NodeAllUnlockedNotCar(string carCode, string[] nodeCodes)
|
||||||
|
{
|
||||||
|
var excludeCarId = ResolveCarId(carCode);
|
||||||
|
return nodeCodes == null || nodeCodes.All(node => !IsNodeLocked(node, excludeCarId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>指定节点列表中是否存在任意一个被占用的节点。</summary>
|
||||||
|
public static bool NodeAnyLocked(string[] nodeCodes)
|
||||||
|
{
|
||||||
|
return nodeCodes != null && nodeCodes.Any(node => IsNodeLocked(node, -1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>指定节点列表中是否存在被其他车辆占用的节点(排除自身)。</summary>
|
||||||
|
public static bool NodeAnyLockedNotCar(string carCode, string[] nodeCodes)
|
||||||
|
{
|
||||||
|
var excludeCarId = ResolveCarId(carCode);
|
||||||
|
return nodeCodes != null && nodeCodes.Any(node => IsNodeLocked(node, excludeCarId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 节点全部解锁(排除自身)且指定节点范围内不存在处于运行态的其他车辆。
|
||||||
|
/// 对应 FASS <c>NodeAllUnlockedNotCarNotEndCarState</c>。
|
||||||
|
/// </summary>
|
||||||
|
public static bool NodeAllUnlockedNotCarNotEndCarState(string carCode, string runningState, string[] nodeCodes)
|
||||||
|
{
|
||||||
|
if (!NodeAllUnlockedNotCar(carCode, nodeCodes))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !ExistsRunningCarOnNodes(carCode, runningState, nodeCodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 判断除指定车辆外,是否有车辆在给定节点列表上处于“运行中”状态。
|
||||||
|
/// 逻辑对齐 FASS <c>IsExistRunningStateCar</c>:支持首参 Contains 匹配及 Skip(1) 尾节点匹配。
|
||||||
|
/// </summary>
|
||||||
|
public static bool ExistsRunningCarOnNodes(string excludeCarCode, string runningState, string[] nodeCodes)
|
||||||
|
{
|
||||||
|
if (nodeCodes == null || nodeCodes.Length == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var excludeCarId = ResolveCarId(excludeCarCode);
|
||||||
|
foreach (var car in SimpleLib.GetAllCars().OfType<Car>())
|
||||||
|
{
|
||||||
|
if (car.id == excludeCarId)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentNode = FassEventCarSnapshotFactory.GetCurrentNodeCode(car);
|
||||||
|
if (string.IsNullOrWhiteSpace(currentNode))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var state = FassEventCarSnapshotFactory.GetFassState(car);
|
||||||
|
if (!IsRunningState(state, runningState))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 与 FASS 一致:当前地标包含参数首项,或等于后续任一项。
|
||||||
|
if (nodeCodes.Any(node => string.Equals(node, currentNode, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| currentNode.Contains(node)))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodeCodes.Length > 1)
|
||||||
|
{
|
||||||
|
var tailNodes = nodeCodes.Skip(1).ToArray();
|
||||||
|
if (tailNodes.Any(node => string.Equals(node, currentNode, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 判断单个地标是否被锁定。
|
||||||
|
/// 节点号可解析为站点 ID,或通过 fields["TagValue"] 匹配地标标签。
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsNodeLocked(string nodeCode, int excludeCarId)
|
||||||
|
{
|
||||||
|
if (!TryResolveSiteId(nodeCode, out var siteId))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var site = SimpleLib.GetSite(siteId);
|
||||||
|
if (site?.tags?.Contains("unavailable") == true)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return SimpleLib.GetAllCars().Any(car =>
|
||||||
|
car.id != excludeCarId &&
|
||||||
|
(car.status.holdingLocks?.Contains(siteId) == true ||
|
||||||
|
car.status.pendingLocks?.Contains(siteId) == true ||
|
||||||
|
car.status.aquiringLock == siteId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>根据 FASS 车辆编号解析场景内 car.id。</summary>
|
||||||
|
private static int ResolveCarId(string carCode)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(carCode))
|
||||||
|
{
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
var car = SimpleLib.GetAllCars()
|
||||||
|
.OfType<Car>()
|
||||||
|
.FirstOrDefault(item => string.Equals(FassEventCarSnapshotFactory.GetCarCode(item), carCode, StringComparison.OrdinalIgnoreCase));
|
||||||
|
return car?.id ?? -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 将地标编码解析为站点 ID:纯数字直接查站;否则按 TagValue 字段匹配。
|
||||||
|
/// </summary>
|
||||||
|
private static bool TryResolveSiteId(string nodeCode, out int siteId)
|
||||||
|
{
|
||||||
|
siteId = 0;
|
||||||
|
if (string.IsNullOrWhiteSpace(nodeCode))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (int.TryParse(nodeCode, out siteId))
|
||||||
|
{
|
||||||
|
return SimpleLib.GetSite(siteId) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var site = SimpleLib.GetAllSites().FirstOrDefault(item =>
|
||||||
|
item.fields.TryGetValue("TagValue", out var tag) &&
|
||||||
|
string.Equals(tag, nodeCode, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (site == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
siteId = site.id;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 判断状态是否视为“运行中”。兼容 FASS 历史拼写 Runing 与 Running。
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsRunningState(string state, string runningState)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(state))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(state, runningState, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Equals(state, "Running", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
(string.Equals(runningState, "Runing", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(runningState, "Running", StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
<!-- 插件清单:随 dll 输出,供 SimpleLite plugins 选择性加载(约定 <dll>.scene.json) -->
|
<!-- 插件清单:随 dll 输出,供 SimpleLite plugins 选择性加载(约定 <dll>.scene.json) -->
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None Update="StandardScene.Magnetic.scene.json" CopyToOutputDirectory="PreserveNewest" />
|
<None Update="StandardScene.Magnetic.scene.json" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
<None Update="Configs\EventCar\FG2512073.json" CopyToOutputDirectory="PreserveNewest" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<!-- 基座:车型基类字段袋 / 通用 coder / 任务调度等留 Core;本 dll 为 scene.mag 平台(磁导航车型 + 磁循迹 coder) -->
|
<!-- 基座:车型基类字段袋 / 通用 coder / 任务调度等留 Core;本 dll 为 scene.mag 平台(磁导航车型 + 磁循迹 coder) -->
|
||||||
@@ -29,24 +30,25 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Reference Include="SimpleLite">
|
|
||||||
<HintPath>E:\Work\Core\Simple-FR\Simple\SimpleLite\bin\Debug\SimpleLite.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="SimpleCore">
|
|
||||||
<HintPath>E:\Work\Core\Simple-FR\Simple\SimpleCore\bin\Debug\netstandard2.0\SimpleCore.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="CommonUsage">
|
<Reference Include="CommonUsage">
|
||||||
<HintPath>D:\MDCS\Dependencies\Commons\CommonUsage.dll</HintPath>
|
<HintPath>$(StandRefDir)CommonUsage.dll</HintPath>
|
||||||
</Reference>
|
|
||||||
<Reference Include="Topaz">
|
|
||||||
<HintPath>E:\Work\Core\Simple-FR\Simple\tools\Topaz.dll</HintPath>
|
|
||||||
</Reference>
|
</Reference>
|
||||||
<Reference Include="LessokajiWeaverUtilities">
|
<Reference Include="LessokajiWeaverUtilities">
|
||||||
<HintPath>E:\Work\Core\Simple-FR\Simple\SimpleLite\bin\Debug\LessokajiWeaverUtilities.dll</HintPath>
|
<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>
|
</Reference>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="IoTClient" Version="1.0.40" />
|
||||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"coreVersion": ">=1.0.0",
|
"coreVersion": ">=1.0.0",
|
||||||
"requiresCore": "StandardScene.dll",
|
"requiresCore": "StandardScene.dll",
|
||||||
"provides": {
|
"provides": {
|
||||||
"carTypes": [ "MagCar" ],
|
"carTypes": [ "MagCar", "MagFass2Car" ],
|
||||||
"missionTypes": []
|
"missionTypes": [ "EventCarMission", "MagFass2LoopMission" ]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
using StandardScene.Magnetic.Protocol;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace StandardScene.Magnetic.Tasking
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 比对期望节点与上报节点,判定到站与动作是否完成(对齐 backend <c>CarResponseService</c>)。
|
||||||
|
/// </summary>
|
||||||
|
public static class Fass2ActionResolver
|
||||||
|
{
|
||||||
|
private static readonly (string Name, Func<Fass2NodeMessage, byte> Get, Action<Fass2NodeMessage, byte> Set)[] ActionFields =
|
||||||
|
{
|
||||||
|
("StartStop", n => n.StartStop, (n, v) => n.StartStop = v),
|
||||||
|
("Direction", n => n.Direction, (n, v) => n.Direction = v),
|
||||||
|
("Orientation", n => n.Orientation, (n, v) => n.Orientation = v),
|
||||||
|
("Byroad", n => n.Byroad, (n, v) => n.Byroad = v),
|
||||||
|
("Obstacle", n => n.Obstacle, (n, v) => n.Obstacle = v),
|
||||||
|
("Audio", n => n.Audio, (n, v) => n.Audio = v),
|
||||||
|
("Light", n => n.Light, (n, v) => n.Light = v),
|
||||||
|
("Charge", n => n.Charge, (n, v) => n.Charge = v),
|
||||||
|
("Rest", n => n.Rest, (n, v) => n.Rest = v),
|
||||||
|
("Lift", n => n.Lift, (n, v) => n.Lift = v),
|
||||||
|
("Clamp", n => n.Clamp, (n, v) => n.Clamp = v),
|
||||||
|
("Tray", n => n.Tray, (n, v) => n.Tray = v),
|
||||||
|
("Roll", n => n.Roll, (n, v) => n.Roll = v),
|
||||||
|
("Shutdown", n => n.Shutdown, (n, v) => n.Shutdown = v)
|
||||||
|
};
|
||||||
|
|
||||||
|
public static bool IsAtNode(Fass2StateReport report, ushort targetNode)
|
||||||
|
{
|
||||||
|
return report?.Node != null && report.Node.Node == targetNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsVehicleMoving(byte vehicleState)
|
||||||
|
{
|
||||||
|
return vehicleState == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool RequiresActionWait(Fass2NodeMessage expected)
|
||||||
|
{
|
||||||
|
if (expected == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expected.StartStop is Fass2TaskBuilder.StartStopStop or Fass2TaskBuilder.StartStopPrecision)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var field in ActionFields)
|
||||||
|
{
|
||||||
|
if (field.Get(expected) != 0)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return expected.Speed != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsStationActionComplete(Fass2NodeMessage expected, Fass2NodeMessage actual, byte vehicleState)
|
||||||
|
{
|
||||||
|
if (expected == null || actual == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actual.Node != expected.Node)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsVehicleMoving(vehicleState))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!RequiresActionWait(expected))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过站(StartStop=1) 只要求到点且非运行态,不要求 Orientation 等轨迹字段到位。
|
||||||
|
if (expected.StartStop == Fass2TaskBuilder.StartStopPass)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListPendingFields(expected, actual).Count == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IReadOnlyList<string> ListPendingFields(Fass2NodeMessage expected, Fass2NodeMessage actual)
|
||||||
|
{
|
||||||
|
var pending = new List<string>();
|
||||||
|
if (expected == null || actual == null)
|
||||||
|
{
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expected.StartStop != 0 && actual.StartStop != expected.StartStop)
|
||||||
|
{
|
||||||
|
pending.Add("StartStop");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expected.Speed != 0 && actual.Speed != expected.Speed)
|
||||||
|
{
|
||||||
|
pending.Add("Speed");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var field in ActionFields)
|
||||||
|
{
|
||||||
|
if (field.Name == "StartStop")
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var expectedValue = field.Get(expected);
|
||||||
|
if (expectedValue != 0 && field.Get(actual) != expectedValue)
|
||||||
|
{
|
||||||
|
pending.Add(field.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Fass2NodeMessage BuildActionPatch(Fass2NodeMessage expected, Fass2NodeMessage actual)
|
||||||
|
{
|
||||||
|
if (expected == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var patch = new Fass2NodeMessage { Node = expected.Node };
|
||||||
|
var hasPatch = false;
|
||||||
|
|
||||||
|
if (expected.StartStop != 0 && (actual == null || actual.StartStop != expected.StartStop))
|
||||||
|
{
|
||||||
|
patch.StartStop = expected.StartStop;
|
||||||
|
hasPatch = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expected.Speed != 0 && (actual == null || actual.Speed != expected.Speed))
|
||||||
|
{
|
||||||
|
patch.Speed = expected.Speed;
|
||||||
|
hasPatch = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var field in ActionFields)
|
||||||
|
{
|
||||||
|
if (field.Name == "StartStop")
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var expectedValue = field.Get(expected);
|
||||||
|
if (expectedValue != 0 && (actual == null || field.Get(actual) != expectedValue))
|
||||||
|
{
|
||||||
|
field.Set(patch, expectedValue);
|
||||||
|
hasPatch = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasPatch ? patch : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string DescribePending(Fass2NodeMessage expected, Fass2NodeMessage actual)
|
||||||
|
{
|
||||||
|
var pending = ListPendingFields(expected, actual);
|
||||||
|
if (pending.Count == 0)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
for (var i = 0; i < pending.Count; i++)
|
||||||
|
{
|
||||||
|
if (i > 0)
|
||||||
|
{
|
||||||
|
builder.Append(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.Append(pending[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
using SimpleCore;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace StandardScene.Magnetic.Tasking
|
||||||
|
{
|
||||||
|
internal static class Fass2PathFinder
|
||||||
|
{
|
||||||
|
public static List<int> GetSitesBetween(int startSiteId, int endSiteId)
|
||||||
|
{
|
||||||
|
if (startSiteId == endSiteId)
|
||||||
|
{
|
||||||
|
return new List<int> { startSiteId };
|
||||||
|
}
|
||||||
|
|
||||||
|
var adjacency = BuildSiteAdjacency();
|
||||||
|
var visited = new HashSet<int> { startSiteId };
|
||||||
|
var queue = new Queue<List<int>>();
|
||||||
|
queue.Enqueue(new List<int> { startSiteId });
|
||||||
|
|
||||||
|
while (queue.Count > 0)
|
||||||
|
{
|
||||||
|
var path = queue.Dequeue();
|
||||||
|
var current = path[path.Count - 1];
|
||||||
|
if (current == endSiteId)
|
||||||
|
{
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!adjacency.TryGetValue(current, out var neighbors))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var neighbor in neighbors)
|
||||||
|
{
|
||||||
|
if (visited.Contains(neighbor))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
visited.Add(neighbor);
|
||||||
|
var nextPath = new List<int>(path) { neighbor };
|
||||||
|
queue.Enqueue(nextPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new List<int>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<int, HashSet<int>> BuildSiteAdjacency()
|
||||||
|
{
|
||||||
|
var adjacency = new Dictionary<int, HashSet<int>>();
|
||||||
|
foreach (var track in SimpleLib.GetAllTracks())
|
||||||
|
{
|
||||||
|
var siteA = track.siteA;
|
||||||
|
var siteB = track.siteB;
|
||||||
|
switch (track.direction)
|
||||||
|
{
|
||||||
|
case 0:
|
||||||
|
AddAdjacency(adjacency, siteA, siteB);
|
||||||
|
AddAdjacency(adjacency, siteB, siteA);
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
AddAdjacency(adjacency, siteA, siteB);
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
AddAdjacency(adjacency, siteB, siteA);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
AddAdjacency(adjacency, siteA, siteB);
|
||||||
|
AddAdjacency(adjacency, siteB, siteA);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return adjacency;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddAdjacency(Dictionary<int, HashSet<int>> adjacency, int from, int to)
|
||||||
|
{
|
||||||
|
if (!adjacency.TryGetValue(from, out var set))
|
||||||
|
{
|
||||||
|
set = new HashSet<int>();
|
||||||
|
adjacency[from] = set;
|
||||||
|
}
|
||||||
|
|
||||||
|
set.Add(to);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
using SimpleCore.PropType;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace StandardScene.Magnetic.Tasking
|
||||||
|
{
|
||||||
|
internal static class Fass2SiteFieldReader
|
||||||
|
{
|
||||||
|
public static Fass2SiteActionData Read(Site site)
|
||||||
|
{
|
||||||
|
var data = new Fass2SiteActionData();
|
||||||
|
if (site?.fields == null)
|
||||||
|
{
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
data.StartStop = TryReadByte(site.fields, Fass2SiteFields.StartStop);
|
||||||
|
data.Byroad = TryReadByte(site.fields, Fass2SiteFields.Byroad);
|
||||||
|
data.Direction = TryReadByte(site.fields, Fass2SiteFields.Direction);
|
||||||
|
data.Orientation = TryReadByte(site.fields, Fass2SiteFields.Orientation);
|
||||||
|
data.Lift = TryReadByte(site.fields, Fass2SiteFields.Lift);
|
||||||
|
data.Roll = TryReadByte(site.fields, Fass2SiteFields.Roll);
|
||||||
|
data.Charge = TryReadByte(site.fields, Fass2SiteFields.Charge);
|
||||||
|
data.Obstacle = TryReadByte(site.fields, Fass2SiteFields.Obstacle);
|
||||||
|
data.Audio = TryReadByte(site.fields, Fass2SiteFields.Audio);
|
||||||
|
data.Light = TryReadByte(site.fields, Fass2SiteFields.Light);
|
||||||
|
data.Rest = TryReadByte(site.fields, Fass2SiteFields.Rest);
|
||||||
|
data.Clamp = TryReadByte(site.fields, Fass2SiteFields.Clamp);
|
||||||
|
data.Tray = TryReadByte(site.fields, Fass2SiteFields.Tray);
|
||||||
|
data.Shutdown = TryReadByte(site.fields, Fass2SiteFields.Shutdown);
|
||||||
|
data.PrecisionStop = TryReadBool(site.fields, Fass2SiteFields.PrecisionStop);
|
||||||
|
data.WaitMode = TryReadString(site.fields, Fass2SiteFields.WaitMode);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string BuildFieldsSignature(IReadOnlyList<int> siteIds, int fromIndex, Func<int, Site> getSite)
|
||||||
|
{
|
||||||
|
if (siteIds == null || siteIds.Count == 0)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts = new List<string>(siteIds.Count - fromIndex);
|
||||||
|
for (var i = Math.Max(0, fromIndex); i < siteIds.Count; i++)
|
||||||
|
{
|
||||||
|
var site = getSite(siteIds[i]);
|
||||||
|
if (site?.fields == null)
|
||||||
|
{
|
||||||
|
parts.Add($"{siteIds[i]}:");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var keys = new List<string>();
|
||||||
|
foreach (var pair in site.fields)
|
||||||
|
{
|
||||||
|
if (pair.Key.StartsWith("Fass2_", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
pair.Key.Equals(Fass2SiteFields.TagValue, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
keys.Add($"{pair.Key}={pair.Value}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keys.Sort(StringComparer.OrdinalIgnoreCase);
|
||||||
|
parts.Add($"{siteIds[i]}:{string.Join("|", keys)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join(";", parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte? TryReadByte(Dictionary<string, string> fields, string key)
|
||||||
|
{
|
||||||
|
if (!fields.TryGetValue(key, out var text) || string.IsNullOrWhiteSpace(text))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (byte.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value))
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryReadBool(Dictionary<string, string> fields, string key)
|
||||||
|
{
|
||||||
|
if (!fields.TryGetValue(key, out var text) || string.IsNullOrWhiteSpace(text))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return bool.TryParse(text, out var value) && value
|
||||||
|
|| text == "1"
|
||||||
|
|| string.Equals(text, "true", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string TryReadString(Dictionary<string, string> fields, string key)
|
||||||
|
{
|
||||||
|
return fields.TryGetValue(key, out var text) ? text : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
namespace StandardScene.Magnetic.Tasking
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// FASS 2.0 站点 <c>fields</c> 键名常量,用于地图编辑器配置站点动作。
|
||||||
|
/// </summary>
|
||||||
|
public static class Fass2SiteFields
|
||||||
|
{
|
||||||
|
public const string TagValue = "TagValue";
|
||||||
|
|
||||||
|
public const string StartStop = "Fass2_StartStop";
|
||||||
|
public const string Byroad = "Fass2_Byroad";
|
||||||
|
public const string Direction = "Fass2_Direction";
|
||||||
|
public const string Orientation = "Fass2_Orientation";
|
||||||
|
public const string Lift = "Fass2_Lift";
|
||||||
|
public const string Roll = "Fass2_Roll";
|
||||||
|
public const string Charge = "Fass2_Charge";
|
||||||
|
public const string Obstacle = "Fass2_Obstacle";
|
||||||
|
public const string Audio = "Fass2_Audio";
|
||||||
|
public const string Light = "Fass2_Light";
|
||||||
|
public const string Rest = "Fass2_Rest";
|
||||||
|
public const string Clamp = "Fass2_Clamp";
|
||||||
|
public const string Tray = "Fass2_Tray";
|
||||||
|
public const string Shutdown = "Fass2_Shutdown";
|
||||||
|
public const string PrecisionStop = "Fass2_PrecisionStop";
|
||||||
|
public const string WaitMode = "Fass2_WaitMode";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 从站点 fields 解析出的动作参数(映射到 25B 节点块)。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Fass2SiteActionData
|
||||||
|
{
|
||||||
|
public byte? StartStop { get; set; }
|
||||||
|
public byte? Byroad { get; set; }
|
||||||
|
public byte? Direction { get; set; }
|
||||||
|
public byte? Orientation { get; set; }
|
||||||
|
public byte? Lift { get; set; }
|
||||||
|
public byte? Roll { get; set; }
|
||||||
|
public byte? Charge { get; set; }
|
||||||
|
public byte? Obstacle { get; set; }
|
||||||
|
public byte? Audio { get; set; }
|
||||||
|
public byte? Light { get; set; }
|
||||||
|
public byte? Rest { get; set; }
|
||||||
|
public byte? Clamp { get; set; }
|
||||||
|
public byte? Tray { get; set; }
|
||||||
|
public byte? Shutdown { get; set; }
|
||||||
|
public bool PrecisionStop { get; set; }
|
||||||
|
public string WaitMode { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
using SimpleCore;
|
||||||
|
using SimpleCore.PropType;
|
||||||
|
using StandardScene.Magnetic.Protocol;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace StandardScene.Magnetic.Tasking
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 将站点路径与站点/边 fields 合并为 FASS 2.0 <c>0xB1</c> 节点序列。
|
||||||
|
/// 对齐 backend <c>CarRequestService.GetSendNodes</c> 的组包规则。
|
||||||
|
/// </summary>
|
||||||
|
public static class Fass2TaskBuilder
|
||||||
|
{
|
||||||
|
public const byte StartStopPass = 1;
|
||||||
|
public const byte StartStopStop = 2;
|
||||||
|
public const byte StartStopPrecision = 22;
|
||||||
|
|
||||||
|
public static Fass2TaskPlan BuildPath(int startSiteId, int goalSiteId, Fass2TaskBuildOptions options,
|
||||||
|
Func<int, ushort> resolveNodeId)
|
||||||
|
{
|
||||||
|
if (resolveNodeId == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(resolveNodeId));
|
||||||
|
}
|
||||||
|
|
||||||
|
options ??= new Fass2TaskBuildOptions();
|
||||||
|
var siteIds = Fass2PathFinder.GetSitesBetween(startSiteId, goalSiteId);
|
||||||
|
if (siteIds.Count == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"no path from site {startSiteId} to {goalSiteId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var nodes = BuildNodesForSites(siteIds, options, resolveNodeId);
|
||||||
|
return new Fass2TaskPlan
|
||||||
|
{
|
||||||
|
StartSiteId = startSiteId,
|
||||||
|
GoalSiteId = goalSiteId,
|
||||||
|
SiteIds = siteIds,
|
||||||
|
Nodes = nodes,
|
||||||
|
Batches = SplitBatches(nodes, options.MaxNodesPerFrame),
|
||||||
|
FieldsSignature = Fass2SiteFieldReader.BuildFieldsSignature(siteIds, 0, SimpleLib.GetSite)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Fass2NodeMessage[] BuildSegment(int srcSiteId, int dstSiteId, Fass2TaskBuildOptions options,
|
||||||
|
Func<int, ushort> resolveNodeId)
|
||||||
|
{
|
||||||
|
return BuildPath(srcSiteId, dstSiteId, options, resolveNodeId).Nodes.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IReadOnlyList<Fass2NodeMessage[]> SplitBatches(IReadOnlyList<Fass2NodeMessage> nodes, int maxPerFrame = 10)
|
||||||
|
{
|
||||||
|
if (nodes == null || nodes.Count == 0)
|
||||||
|
{
|
||||||
|
return Array.Empty<Fass2NodeMessage[]>();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxPerFrame <= 0 || maxPerFrame > 10)
|
||||||
|
{
|
||||||
|
maxPerFrame = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
var batches = new List<Fass2NodeMessage[]>();
|
||||||
|
for (var offset = 0; offset < nodes.Count; offset += maxPerFrame)
|
||||||
|
{
|
||||||
|
var count = Math.Min(maxPerFrame, nodes.Count - offset);
|
||||||
|
var batch = new Fass2NodeMessage[count];
|
||||||
|
for (var i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
batch[i] = nodes[offset + i];
|
||||||
|
}
|
||||||
|
|
||||||
|
batches.Add(batch);
|
||||||
|
}
|
||||||
|
|
||||||
|
return batches;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Fass2NodeMessage> BuildNodesForSites(IReadOnlyList<int> siteIds, Fass2TaskBuildOptions options,
|
||||||
|
Func<int, ushort> resolveNodeId)
|
||||||
|
{
|
||||||
|
var nodes = new List<Fass2NodeMessage>(siteIds.Count);
|
||||||
|
for (var i = 0; i < siteIds.Count; i++)
|
||||||
|
{
|
||||||
|
var siteId = siteIds[i];
|
||||||
|
var site = SimpleLib.GetSite(siteId);
|
||||||
|
var siteAction = Fass2SiteFieldReader.Read(site);
|
||||||
|
var isLast = i == siteIds.Count - 1;
|
||||||
|
var prevSiteId = i > 0 ? siteIds[i - 1] : siteId;
|
||||||
|
|
||||||
|
Fass2TrackMotionData trackMotion = null;
|
||||||
|
Site prevSite = null;
|
||||||
|
if (i > 0)
|
||||||
|
{
|
||||||
|
prevSite = SimpleLib.GetSite(prevSiteId);
|
||||||
|
var track = FindTrack(prevSiteId, siteId);
|
||||||
|
trackMotion = Fass2TrackFieldReader.Read(track);
|
||||||
|
}
|
||||||
|
|
||||||
|
var node = new Fass2NodeMessage
|
||||||
|
{
|
||||||
|
Node = resolveNodeId(siteId),
|
||||||
|
StartStop = ResolveStartStop(siteAction, isLast),
|
||||||
|
Distance = i > 0 ? ComputeEdgeDistance(prevSite, site) : (ushort)0,
|
||||||
|
Speed = SpeedToProtocol(trackMotion?.Speed ?? options.DefaultSpeed, options.CarSpeed),
|
||||||
|
Orientation = ResolveOrientation(prevSite, site, trackMotion),
|
||||||
|
Byroad = siteAction.Byroad ?? trackMotion?.Byroad ?? 0,
|
||||||
|
Direction = siteAction.Direction ?? trackMotion?.Direction ?? 0,
|
||||||
|
Lift = siteAction.Lift ?? 0,
|
||||||
|
Roll = siteAction.Roll ?? 0,
|
||||||
|
Charge = siteAction.Charge ?? 0,
|
||||||
|
Obstacle = siteAction.Obstacle ?? 0,
|
||||||
|
Audio = siteAction.Audio ?? 0,
|
||||||
|
Light = siteAction.Light ?? 0,
|
||||||
|
Rest = siteAction.Rest ?? 0,
|
||||||
|
Clamp = siteAction.Clamp ?? 0,
|
||||||
|
Tray = siteAction.Tray ?? 0,
|
||||||
|
Shutdown = siteAction.Shutdown ?? 0
|
||||||
|
};
|
||||||
|
|
||||||
|
if (siteAction.Orientation.HasValue)
|
||||||
|
{
|
||||||
|
node.Orientation = siteAction.Orientation.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes.Add(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte ResolveStartStop(Fass2SiteActionData siteAction, bool isLast)
|
||||||
|
{
|
||||||
|
if (siteAction.StartStop.HasValue)
|
||||||
|
{
|
||||||
|
return siteAction.StartStop.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLast)
|
||||||
|
{
|
||||||
|
return siteAction.PrecisionStop ? StartStopPrecision : StartStopStop;
|
||||||
|
}
|
||||||
|
|
||||||
|
return StartStopPass;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte ResolveOrientation(Site src, Site dst, Fass2TrackMotionData trackMotion)
|
||||||
|
{
|
||||||
|
if (trackMotion?.Orientation != null)
|
||||||
|
{
|
||||||
|
return trackMotion.Orientation.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (src == null || dst == null)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var angle = trackMotion != null && trackMotion.EnableCarAbsoluteDirection
|
||||||
|
? trackMotion.CarAbsoluteDirection
|
||||||
|
: Math.Atan2(dst.y - src.y, dst.x - src.x) / Math.PI * 180.0;
|
||||||
|
|
||||||
|
if (trackMotion?.Reverse == true)
|
||||||
|
{
|
||||||
|
angle += 180;
|
||||||
|
}
|
||||||
|
|
||||||
|
angle += trackMotion?.CarDirectionBias ?? 0;
|
||||||
|
angle %= 360;
|
||||||
|
if (angle < 0)
|
||||||
|
{
|
||||||
|
angle += 360;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (byte)((int)Math.Round(angle / 360.0 * 256) % 256);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ushort ComputeEdgeDistance(Site src, Site dst)
|
||||||
|
{
|
||||||
|
var dx = dst.x - src.x;
|
||||||
|
var dy = dst.y - src.y;
|
||||||
|
var length = Math.Sqrt(dx * dx + dy * dy);
|
||||||
|
return (ushort)Math.Min(ushort.MaxValue, Math.Max(1, Math.Round(length)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ushort SpeedToProtocol(double trackSpeed, double carSpeed)
|
||||||
|
{
|
||||||
|
var value = trackSpeed <= 0 ? carSpeed : trackSpeed;
|
||||||
|
var protocolSpeed = value <= 1
|
||||||
|
? (int)Math.Round(value * 600)
|
||||||
|
: (int)Math.Round(value * 10);
|
||||||
|
return (ushort)Math.Max(1, Math.Min(10000, protocolSpeed));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Track FindTrack(int fromSiteId, int toSiteId)
|
||||||
|
{
|
||||||
|
foreach (var track in SimpleLib.GetAllTracks())
|
||||||
|
{
|
||||||
|
if (track.direction == 0)
|
||||||
|
{
|
||||||
|
if ((track.siteA == fromSiteId && track.siteB == toSiteId) ||
|
||||||
|
(track.siteB == fromSiteId && track.siteA == toSiteId))
|
||||||
|
{
|
||||||
|
return track;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (track.direction == 1 && track.siteA == fromSiteId && track.siteB == toSiteId)
|
||||||
|
{
|
||||||
|
return track;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (track.direction == 2 && track.siteB == fromSiteId && track.siteA == toSiteId)
|
||||||
|
{
|
||||||
|
return track;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
using SimpleCore.PropType;
|
||||||
|
using StandardScene.Magnetic.Protocol;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace StandardScene.Magnetic.Tasking
|
||||||
|
{
|
||||||
|
public enum Fass2TaskPhase
|
||||||
|
{
|
||||||
|
Idle,
|
||||||
|
Planning,
|
||||||
|
Dispatching,
|
||||||
|
Moving,
|
||||||
|
AtStation,
|
||||||
|
SegmentDone,
|
||||||
|
Complete,
|
||||||
|
Fault,
|
||||||
|
Cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 一次 FASS 2.0 任务执行上下文,供状态机推进与重启恢复使用。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Fass2TaskContext
|
||||||
|
{
|
||||||
|
public Fass2TaskPhase Phase { get; set; } = Fass2TaskPhase.Idle;
|
||||||
|
public int StartSiteId { get; set; }
|
||||||
|
public int GoalSiteId { get; set; }
|
||||||
|
public int CurrentIndex { get; set; }
|
||||||
|
public ulong TaskId { get; set; }
|
||||||
|
public Fass2TaskPlan Plan { get; set; }
|
||||||
|
public string FieldsSignature { get; set; } = string.Empty;
|
||||||
|
public double DefaultSpeed { get; set; } = -1;
|
||||||
|
public string FaultReason { get; set; }
|
||||||
|
public DateTime StartedAt { get; set; }
|
||||||
|
public DateTime LastDispatchAt { get; set; } = DateTime.MinValue;
|
||||||
|
public ulong LastActionId { get; set; }
|
||||||
|
public DateTime LastActionSentAt { get; set; } = DateTime.MinValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Fass2TaskPersistence
|
||||||
|
{
|
||||||
|
public const string TagPhase = "Fass2Task_Phase";
|
||||||
|
public const string TagStartSite = "Fass2Task_StartSite";
|
||||||
|
public const string TagGoalSite = "Fass2Task_GoalSite";
|
||||||
|
public const string TagCurrentIndex = "Fass2Task_CurrentIndex";
|
||||||
|
public const string TagTaskId = "Fass2Task_TaskId";
|
||||||
|
public const string TagFieldsSignature = "Fass2Task_FieldsSig";
|
||||||
|
public const string TagDefaultSpeed = "Fass2Task_DefaultSpeed";
|
||||||
|
|
||||||
|
public static void Save(TagSet tags, Fass2TaskContext context)
|
||||||
|
{
|
||||||
|
if (tags == null || context == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SetTag(tags, TagPhase, context.Phase.ToString());
|
||||||
|
SetTag(tags, TagStartSite, context.StartSiteId.ToString());
|
||||||
|
SetTag(tags, TagGoalSite, context.GoalSiteId.ToString());
|
||||||
|
SetTag(tags, TagCurrentIndex, context.CurrentIndex.ToString());
|
||||||
|
SetTag(tags, TagTaskId, context.TaskId.ToString());
|
||||||
|
SetTag(tags, TagFieldsSignature, context.FieldsSignature ?? string.Empty);
|
||||||
|
SetTag(tags, TagDefaultSpeed, context.DefaultSpeed.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryLoad(TagSet tags, out Fass2TaskContext context)
|
||||||
|
{
|
||||||
|
context = null;
|
||||||
|
if (tags == null || !tags.TryGetValue(TagPhase, out var phaseText) ||
|
||||||
|
!Enum.TryParse(phaseText, out Fass2TaskPhase phase))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase is Fass2TaskPhase.Idle or Fass2TaskPhase.Complete or Fass2TaskPhase.Cancelled)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryReadInt(tags, TagStartSite, out var startSite) ||
|
||||||
|
!TryReadInt(tags, TagGoalSite, out var goalSite) ||
|
||||||
|
!TryReadInt(tags, TagCurrentIndex, out var currentIndex))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
tags.TryGetValue(TagFieldsSignature, out var fieldsSignature);
|
||||||
|
ulong.TryParse(tags.TryGetValue(TagTaskId, out var taskIdText) ? taskIdText : "0", out var taskId);
|
||||||
|
double.TryParse(
|
||||||
|
tags.TryGetValue(TagDefaultSpeed, out var speedText) ? speedText : "-1",
|
||||||
|
System.Globalization.NumberStyles.Float,
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture,
|
||||||
|
out var defaultSpeed);
|
||||||
|
|
||||||
|
context = new Fass2TaskContext
|
||||||
|
{
|
||||||
|
Phase = phase,
|
||||||
|
StartSiteId = startSite,
|
||||||
|
GoalSiteId = goalSite,
|
||||||
|
CurrentIndex = currentIndex,
|
||||||
|
TaskId = taskId,
|
||||||
|
FieldsSignature = fieldsSignature ?? string.Empty,
|
||||||
|
DefaultSpeed = defaultSpeed
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Clear(TagSet tags)
|
||||||
|
{
|
||||||
|
if (tags == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
RemoveTag(tags, TagPhase);
|
||||||
|
RemoveTag(tags, TagStartSite);
|
||||||
|
RemoveTag(tags, TagGoalSite);
|
||||||
|
RemoveTag(tags, TagCurrentIndex);
|
||||||
|
RemoveTag(tags, TagTaskId);
|
||||||
|
RemoveTag(tags, TagFieldsSignature);
|
||||||
|
RemoveTag(tags, TagDefaultSpeed);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetTag(TagSet tags, string key, string value)
|
||||||
|
{
|
||||||
|
if (tags.Contains(key))
|
||||||
|
{
|
||||||
|
tags.Remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
tags.Add(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RemoveTag(TagSet tags, string key)
|
||||||
|
{
|
||||||
|
if (tags.Contains(key))
|
||||||
|
{
|
||||||
|
tags.Remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryReadInt(TagSet tags, string key, out int value)
|
||||||
|
{
|
||||||
|
value = 0;
|
||||||
|
return tags.TryGetValue(key, out var text) && int.TryParse(text, out value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using StandardScene.Magnetic.Protocol;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace StandardScene.Magnetic.Tasking
|
||||||
|
{
|
||||||
|
public sealed class Fass2TaskBuildOptions
|
||||||
|
{
|
||||||
|
public bool UseTagValueAsNode { get; set; }
|
||||||
|
public double DefaultSpeed { get; set; } = 0.2;
|
||||||
|
public double CarSpeed { get; set; } = 1;
|
||||||
|
public int MaxNodesPerFrame { get; set; } = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 一次 FASS 2.0 路径任务计划:站点序列、节点报文、分批结果。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Fass2TaskPlan
|
||||||
|
{
|
||||||
|
public int StartSiteId { get; set; }
|
||||||
|
public int GoalSiteId { get; set; }
|
||||||
|
public IReadOnlyList<int> SiteIds { get; set; } = new List<int>();
|
||||||
|
public IReadOnlyList<Fass2NodeMessage> Nodes { get; set; } = new List<Fass2NodeMessage>();
|
||||||
|
public IReadOnlyList<Fass2NodeMessage[]> Batches { get; set; } = new List<Fass2NodeMessage[]>();
|
||||||
|
public string FieldsSignature { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,588 @@
|
|||||||
|
using SimpleCore;
|
||||||
|
using SimpleCore.PropType;
|
||||||
|
using StandardScene.Magnetic.Protocol;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace StandardScene.Magnetic.Tasking
|
||||||
|
{
|
||||||
|
public sealed class Fass2TaskCallbacks
|
||||||
|
{
|
||||||
|
public Func<int, int, double, Fass2TaskPlan> BuildPlan { get; set; }
|
||||||
|
public Action<Fass2NodeMessage[], ulong> SendNodes { get; set; }
|
||||||
|
public Action<Fass2NodeMessage, ulong> SendAction { get; set; }
|
||||||
|
public Action<byte, ushort> SendControl { get; set; }
|
||||||
|
public Func<int, ushort> ResolveNodeId { get; set; }
|
||||||
|
public Func<ushort, Site> ResolveSite { get; set; }
|
||||||
|
public Action<string> Log { get; set; }
|
||||||
|
public Action<Fass2TaskContext> Persist { get; set; }
|
||||||
|
public Action ClearPersisted { get; set; }
|
||||||
|
public Func<ulong> AllocateTaskId { get; set; }
|
||||||
|
public Func<ulong> AllocateActionId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class Fass2TaskTickResult
|
||||||
|
{
|
||||||
|
public Fass2TaskPhase Phase { get; set; }
|
||||||
|
public bool Dispatched { get; set; }
|
||||||
|
public bool Rebuilt { get; set; }
|
||||||
|
public bool ActionSent { get; set; }
|
||||||
|
public bool Advanced { get; set; }
|
||||||
|
public bool Completed { get; set; }
|
||||||
|
public bool Faulted { get; set; }
|
||||||
|
public string Message { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// FASS 2.0 任务状态机:规划 → 滑动窗口下发 → UDP/TCP 状态驱动推进 → 动作闭环。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Fass2TaskStateMachine
|
||||||
|
{
|
||||||
|
private readonly Fass2TaskCallbacks _callbacks;
|
||||||
|
private readonly object _syncRoot = new object();
|
||||||
|
private TaskCompletionSource<int> _completionSource;
|
||||||
|
|
||||||
|
public Fass2TaskStateMachine(Fass2TaskCallbacks callbacks)
|
||||||
|
{
|
||||||
|
_callbacks = callbacks ?? throw new ArgumentNullException(nameof(callbacks));
|
||||||
|
Context = new Fass2TaskContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Fass2TaskContext Context { get; }
|
||||||
|
|
||||||
|
public int LockCount { get; set; } = 4;
|
||||||
|
|
||||||
|
public int ResendIntervalMs { get; set; } = 500;
|
||||||
|
|
||||||
|
public int ActionRetryIntervalMs { get; set; } = 1000;
|
||||||
|
|
||||||
|
public bool StartBeforeMove { get; set; } = true;
|
||||||
|
|
||||||
|
public ushort HeadingAngle { get; set; }
|
||||||
|
|
||||||
|
public bool IsIdle
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
return Context.Phase is Fass2TaskPhase.Idle or Fass2TaskPhase.Complete or Fass2TaskPhase.Cancelled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsRunning
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
return Context.Phase is Fass2TaskPhase.Planning or Fass2TaskPhase.Dispatching
|
||||||
|
or Fass2TaskPhase.Moving or Fass2TaskPhase.AtStation or Fass2TaskPhase.SegmentDone;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public event Action<Fass2TaskContext> Completed;
|
||||||
|
public event Action<Fass2TaskContext, string> Faulted;
|
||||||
|
|
||||||
|
public void Begin(int startSiteId, int goalSiteId, double defaultSpeed = -1)
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
if (!IsIdle)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"task state machine busy, phase={Context.Phase}");
|
||||||
|
}
|
||||||
|
|
||||||
|
ResetCompletionSource();
|
||||||
|
Context.Phase = Fass2TaskPhase.Planning;
|
||||||
|
Context.StartSiteId = startSiteId;
|
||||||
|
Context.GoalSiteId = goalSiteId;
|
||||||
|
Context.CurrentIndex = 0;
|
||||||
|
Context.DefaultSpeed = defaultSpeed;
|
||||||
|
Context.StartedAt = DateTime.Now;
|
||||||
|
Context.FaultReason = null;
|
||||||
|
Context.Plan = null;
|
||||||
|
Context.FieldsSignature = string.Empty;
|
||||||
|
Context.LastDispatchAt = DateTime.MinValue;
|
||||||
|
Context.LastActionSentAt = DateTime.MinValue;
|
||||||
|
Context.LastActionId = 0;
|
||||||
|
Log($"task begin start={startSiteId}, goal={goalSiteId}, speed={defaultSpeed}");
|
||||||
|
Persist();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryRestore(Fass2TaskContext saved)
|
||||||
|
{
|
||||||
|
if (saved == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
if (IsRunning)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ResetCompletionSource();
|
||||||
|
Context.Phase = saved.Phase;
|
||||||
|
Context.StartSiteId = saved.StartSiteId;
|
||||||
|
Context.GoalSiteId = saved.GoalSiteId;
|
||||||
|
Context.CurrentIndex = saved.CurrentIndex;
|
||||||
|
Context.TaskId = saved.TaskId;
|
||||||
|
Context.DefaultSpeed = saved.DefaultSpeed;
|
||||||
|
Context.FieldsSignature = saved.FieldsSignature ?? string.Empty;
|
||||||
|
Context.StartedAt = DateTime.Now;
|
||||||
|
Context.LastDispatchAt = DateTime.MinValue;
|
||||||
|
Context.LastActionSentAt = DateTime.MinValue;
|
||||||
|
Context.Plan = null;
|
||||||
|
Context.FaultReason = null;
|
||||||
|
|
||||||
|
if (Context.Phase == Fass2TaskPhase.Planning)
|
||||||
|
{
|
||||||
|
Log($"task restored phase={Context.Phase}, goal={Context.GoalSiteId}, index={Context.CurrentIndex}");
|
||||||
|
Persist();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Context.Phase = Fass2TaskPhase.Planning;
|
||||||
|
Log($"task restored and normalized to Planning, goal={Context.GoalSiteId}, index={Context.CurrentIndex}");
|
||||||
|
Persist();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Fass2TaskTickResult Tick(Fass2StateReport report)
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
if (Context.Phase is Fass2TaskPhase.Idle or Fass2TaskPhase.Complete
|
||||||
|
or Fass2TaskPhase.Cancelled or Fass2TaskPhase.Fault)
|
||||||
|
{
|
||||||
|
return new Fass2TaskTickResult { Phase = Context.Phase };
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new Fass2TaskTickResult { Phase = Context.Phase };
|
||||||
|
|
||||||
|
if (report?.Alarm != 0)
|
||||||
|
{
|
||||||
|
return Fault($"alarm=0x{report.Alarm:X}", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (report?.State == 3)
|
||||||
|
{
|
||||||
|
return Fault("vehicle emergency stop", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (report?.State == 4)
|
||||||
|
{
|
||||||
|
return Fault("vehicle fault", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (Context.Phase)
|
||||||
|
{
|
||||||
|
case Fass2TaskPhase.Planning:
|
||||||
|
return PlanAndDispatch(report, result);
|
||||||
|
case Fass2TaskPhase.Dispatching:
|
||||||
|
Context.Phase = Fass2TaskPhase.Moving;
|
||||||
|
result.Phase = Context.Phase;
|
||||||
|
return result;
|
||||||
|
case Fass2TaskPhase.Moving:
|
||||||
|
return HandleMoving(report, result);
|
||||||
|
case Fass2TaskPhase.AtStation:
|
||||||
|
return HandleAtStation(report, result);
|
||||||
|
case Fass2TaskPhase.SegmentDone:
|
||||||
|
return HandleSegmentDone(report, result);
|
||||||
|
default:
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Cancel(string reason)
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
if (IsIdle)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Context.Phase = Fass2TaskPhase.Cancelled;
|
||||||
|
Context.FaultReason = reason;
|
||||||
|
Log($"task cancelled: {reason}");
|
||||||
|
ClearPersisted();
|
||||||
|
_completionSource?.TrySetException(new OperationCanceledException(reason));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task WaitAsync(int timeoutMs, Func<Fass2StateReport> poll, int pollIntervalMs,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (poll == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(poll));
|
||||||
|
}
|
||||||
|
|
||||||
|
TaskCompletionSource<int> waiter;
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
waiter = _completionSource ?? ResetCompletionSource();
|
||||||
|
}
|
||||||
|
|
||||||
|
var timeoutTask = Task.Delay(timeoutMs, cancellationToken);
|
||||||
|
var pollTask = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
Fass2StateReport report;
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
if (!IsRunning)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
report = poll();
|
||||||
|
Tick(report);
|
||||||
|
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
if (!IsRunning)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(Math.Max(20, pollIntervalMs), cancellationToken);
|
||||||
|
}
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
var finished = await Task.WhenAny(waiter.Task, timeoutTask);
|
||||||
|
if (finished == timeoutTask)
|
||||||
|
{
|
||||||
|
Cancel("wait timeout");
|
||||||
|
throw new TimeoutException(
|
||||||
|
$"FASS2 task timeout after {timeoutMs}ms, phase={Context.Phase}, index={Context.CurrentIndex}, goal={Context.GoalSiteId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
await waiter.Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// UDP 模式由 Hub 回调驱动 Tick,此处只等待完成信号,避免与 OnUdpStateReceived 争用锁导致死锁。
|
||||||
|
/// </summary>
|
||||||
|
public async Task WaitForCompletionAsync(int timeoutMs, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
TaskCompletionSource<int> waiter;
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
if (Context.Phase is Fass2TaskPhase.Complete or Fass2TaskPhase.Cancelled or Fass2TaskPhase.Fault)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
waiter = _completionSource ?? ResetCompletionSource();
|
||||||
|
}
|
||||||
|
|
||||||
|
var timeoutTask = Task.Delay(timeoutMs, cancellationToken);
|
||||||
|
var finished = await Task.WhenAny(waiter.Task, timeoutTask);
|
||||||
|
if (finished == timeoutTask)
|
||||||
|
{
|
||||||
|
Cancel("wait timeout");
|
||||||
|
throw new TimeoutException(
|
||||||
|
$"FASS2 task timeout after {timeoutMs}ms, phase={Context.Phase}, index={Context.CurrentIndex}, goal={Context.GoalSiteId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
await waiter.Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Fass2TaskTickResult PlanAndDispatch(Fass2StateReport report, Fass2TaskTickResult result)
|
||||||
|
{
|
||||||
|
var startSiteId = ResolveCurrentSiteId(report);
|
||||||
|
Context.Plan = _callbacks.BuildPlan(startSiteId, Context.GoalSiteId, Context.DefaultSpeed);
|
||||||
|
if (Context.Plan == null || Context.Plan.Nodes.Count == 0)
|
||||||
|
{
|
||||||
|
return Fault($"empty task plan from {startSiteId} to {Context.GoalSiteId}", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Context.CurrentIndex >= Context.Plan.Nodes.Count)
|
||||||
|
{
|
||||||
|
Context.CurrentIndex = Math.Max(0, Context.Plan.Nodes.Count - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
Context.FieldsSignature = Context.Plan.FieldsSignature;
|
||||||
|
Context.TaskId = _callbacks.AllocateTaskId();
|
||||||
|
|
||||||
|
if (StartBeforeMove && Context.CurrentIndex == 0)
|
||||||
|
{
|
||||||
|
_callbacks.SendControl(Fass2Protocol.CmdStart, HeadingAngle);
|
||||||
|
}
|
||||||
|
|
||||||
|
DispatchCurrentWindow();
|
||||||
|
Context.Phase = Fass2TaskPhase.Moving;
|
||||||
|
result.Phase = Context.Phase;
|
||||||
|
result.Dispatched = true;
|
||||||
|
result.Message = $"planned nodes={Context.Plan.Nodes.Count}, task={Context.TaskId}";
|
||||||
|
Log(result.Message);
|
||||||
|
Persist();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Fass2TaskTickResult HandleMoving(Fass2StateReport report, Fass2TaskTickResult result)
|
||||||
|
{
|
||||||
|
if (TryRebuildForFieldsChange(result))
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ShouldResend())
|
||||||
|
{
|
||||||
|
DispatchCurrentWindow();
|
||||||
|
result.Dispatched = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var expected = GetExpectedNode();
|
||||||
|
if (!Fass2ActionResolver.IsAtNode(report, expected.Node))
|
||||||
|
{
|
||||||
|
result.Message = $"moving node={report.Node.Node}, expect={expected.Node}";
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Fass2ActionResolver.IsVehicleMoving(report.State))
|
||||||
|
{
|
||||||
|
result.Message = $"at node={expected.Node}, still running";
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Context.Phase = Fass2TaskPhase.AtStation;
|
||||||
|
result.Phase = Context.Phase;
|
||||||
|
result.Message = $"arrived site index={Context.CurrentIndex}, node={expected.Node}";
|
||||||
|
Log(result.Message);
|
||||||
|
Persist();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Fass2TaskTickResult HandleAtStation(Fass2StateReport report, Fass2TaskTickResult result)
|
||||||
|
{
|
||||||
|
if (TryRebuildForFieldsChange(result))
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ShouldResend())
|
||||||
|
{
|
||||||
|
DispatchCurrentWindow();
|
||||||
|
result.Dispatched = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var expected = GetExpectedNode();
|
||||||
|
if (!Fass2ActionResolver.IsAtNode(report, expected.Node))
|
||||||
|
{
|
||||||
|
Context.Phase = Fass2TaskPhase.Moving;
|
||||||
|
result.Phase = Context.Phase;
|
||||||
|
result.Message = $"left station node={report.Node.Node}, expect={expected.Node}";
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Fass2ActionResolver.IsStationActionComplete(expected, report.Node, report.State))
|
||||||
|
{
|
||||||
|
if (ShouldSendActionPatch())
|
||||||
|
{
|
||||||
|
var patch = Fass2ActionResolver.BuildActionPatch(expected, report.Node);
|
||||||
|
if (patch != null)
|
||||||
|
{
|
||||||
|
Context.LastActionId = _callbacks.AllocateActionId();
|
||||||
|
Context.LastActionSentAt = DateTime.Now;
|
||||||
|
_callbacks.SendAction(patch, Context.LastActionId);
|
||||||
|
result.ActionSent = true;
|
||||||
|
result.Message =
|
||||||
|
$"action pending [{Fass2ActionResolver.DescribePending(expected, report.Node)}], sent 0xA1={Context.LastActionId}";
|
||||||
|
Log(result.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Context.Phase = Fass2TaskPhase.SegmentDone;
|
||||||
|
result.Phase = Context.Phase;
|
||||||
|
result.Message = $"station done index={Context.CurrentIndex}, node={expected.Node}";
|
||||||
|
Log(result.Message);
|
||||||
|
Persist();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Fass2TaskTickResult HandleSegmentDone(Fass2StateReport report, Fass2TaskTickResult result)
|
||||||
|
{
|
||||||
|
Context.CurrentIndex++;
|
||||||
|
result.Advanced = true;
|
||||||
|
|
||||||
|
if (Context.Plan == null || Context.CurrentIndex >= Context.Plan.Nodes.Count)
|
||||||
|
{
|
||||||
|
Context.Phase = Fass2TaskPhase.Complete;
|
||||||
|
result.Phase = Context.Phase;
|
||||||
|
result.Completed = true;
|
||||||
|
result.Message = $"task complete goal={Context.GoalSiteId}";
|
||||||
|
Log(result.Message);
|
||||||
|
ClearPersisted();
|
||||||
|
Completed?.Invoke(Context);
|
||||||
|
_completionSource?.TrySetResult(1);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Context.Phase = Fass2TaskPhase.Dispatching;
|
||||||
|
DispatchCurrentWindow();
|
||||||
|
Context.Phase = Fass2TaskPhase.Moving;
|
||||||
|
result.Phase = Context.Phase;
|
||||||
|
result.Dispatched = true;
|
||||||
|
result.Message = $"segment advanced index={Context.CurrentIndex}";
|
||||||
|
Log(result.Message);
|
||||||
|
Persist();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryRebuildForFieldsChange(Fass2TaskTickResult result)
|
||||||
|
{
|
||||||
|
if (Context.Plan?.SiteIds == null || Context.Plan.SiteIds.Count == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var newSignature = Fass2SiteFieldReader.BuildFieldsSignature(
|
||||||
|
Context.Plan.SiteIds, Context.CurrentIndex, SimpleLib.GetSite);
|
||||||
|
if (string.Equals(newSignature, Context.FieldsSignature, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentSiteId = Context.Plan.SiteIds[Context.CurrentIndex];
|
||||||
|
Log($"fields changed at index={Context.CurrentIndex}, rebuild from site={currentSiteId}");
|
||||||
|
Context.Plan = _callbacks.BuildPlan(currentSiteId, Context.GoalSiteId, Context.DefaultSpeed);
|
||||||
|
if (Context.Plan == null || Context.Plan.Nodes.Count == 0)
|
||||||
|
{
|
||||||
|
Fault($"rebuild failed from {currentSiteId} to {Context.GoalSiteId}", result);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Context.CurrentIndex = 0;
|
||||||
|
Context.FieldsSignature = Context.Plan.FieldsSignature;
|
||||||
|
Context.TaskId = _callbacks.AllocateTaskId();
|
||||||
|
DispatchCurrentWindow();
|
||||||
|
Context.Phase = Fass2TaskPhase.Moving;
|
||||||
|
result.Phase = Context.Phase;
|
||||||
|
result.Rebuilt = true;
|
||||||
|
result.Dispatched = true;
|
||||||
|
result.Message = $"rebuilt nodes={Context.Plan.Nodes.Count}, task={Context.TaskId}";
|
||||||
|
Persist();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DispatchCurrentWindow()
|
||||||
|
{
|
||||||
|
var window = BuildDispatchWindow();
|
||||||
|
if (window.Length == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("dispatch window is empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
_callbacks.SendNodes(window, Context.TaskId);
|
||||||
|
Context.LastDispatchAt = DateTime.Now;
|
||||||
|
Log($"dispatch window count={window.Length}, fromIndex={Context.CurrentIndex}, task={Context.TaskId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Fass2NodeMessage[] BuildDispatchWindow()
|
||||||
|
{
|
||||||
|
var nodes = Context.Plan.Nodes;
|
||||||
|
var start = Context.CurrentIndex;
|
||||||
|
var remaining = nodes.Count - start;
|
||||||
|
var windowSize = Math.Max(1, Math.Min(LockCount, remaining));
|
||||||
|
windowSize = Math.Min(windowSize, 10);
|
||||||
|
var window = new Fass2NodeMessage[windowSize];
|
||||||
|
for (var i = 0; i < windowSize; i++)
|
||||||
|
{
|
||||||
|
window[i] = nodes[start + i];
|
||||||
|
}
|
||||||
|
|
||||||
|
return window;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Fass2NodeMessage GetExpectedNode()
|
||||||
|
{
|
||||||
|
return Context.Plan.Nodes[Context.CurrentIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
private int ResolveCurrentSiteId(Fass2StateReport report)
|
||||||
|
{
|
||||||
|
if (Context.CurrentIndex > 0 && Context.Plan?.SiteIds != null &&
|
||||||
|
Context.CurrentIndex < Context.Plan.SiteIds.Count)
|
||||||
|
{
|
||||||
|
return Context.Plan.SiteIds[Context.CurrentIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (report?.Node != null && report.Node.Node != 0)
|
||||||
|
{
|
||||||
|
var site = _callbacks.ResolveSite(report.Node.Node);
|
||||||
|
if (site != null)
|
||||||
|
{
|
||||||
|
return site.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Context.StartSiteId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool ShouldResend()
|
||||||
|
{
|
||||||
|
return Context.LastDispatchAt == DateTime.MinValue ||
|
||||||
|
(DateTime.Now - Context.LastDispatchAt).TotalMilliseconds >= ResendIntervalMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool ShouldSendActionPatch()
|
||||||
|
{
|
||||||
|
return Context.LastActionSentAt == DateTime.MinValue ||
|
||||||
|
(DateTime.Now - Context.LastActionSentAt).TotalMilliseconds >= ActionRetryIntervalMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Fass2TaskTickResult Fault(string reason, Fass2TaskTickResult result)
|
||||||
|
{
|
||||||
|
Context.Phase = Fass2TaskPhase.Fault;
|
||||||
|
Context.FaultReason = reason;
|
||||||
|
result.Phase = Context.Phase;
|
||||||
|
result.Faulted = true;
|
||||||
|
result.Message = reason;
|
||||||
|
Log($"task fault: {reason}");
|
||||||
|
Persist();
|
||||||
|
Faulted?.Invoke(Context, reason);
|
||||||
|
_completionSource?.TrySetException(new InvalidOperationException(reason));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Persist()
|
||||||
|
{
|
||||||
|
_callbacks.Persist?.Invoke(Context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearPersisted()
|
||||||
|
{
|
||||||
|
_callbacks.ClearPersisted?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Log(string message)
|
||||||
|
{
|
||||||
|
_callbacks.Log?.Invoke(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TaskCompletionSource<int> ResetCompletionSource()
|
||||||
|
{
|
||||||
|
_completionSource = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
return _completionSource;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using SimpleCore.PropType;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace StandardScene.Magnetic.Tasking
|
||||||
|
{
|
||||||
|
internal sealed class Fass2TrackMotionData
|
||||||
|
{
|
||||||
|
public float Speed { get; set; } = 0.2f;
|
||||||
|
public bool Reverse { get; set; }
|
||||||
|
public float CarDirectionBias { get; set; }
|
||||||
|
public bool EnableCarAbsoluteDirection { get; set; }
|
||||||
|
public float CarAbsoluteDirection { get; set; }
|
||||||
|
public byte? Byroad { get; set; }
|
||||||
|
public byte? Direction { get; set; }
|
||||||
|
public byte? Orientation { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class Fass2TrackFieldReader
|
||||||
|
{
|
||||||
|
public static Fass2TrackMotionData Read(Track track)
|
||||||
|
{
|
||||||
|
var data = new Fass2TrackMotionData();
|
||||||
|
if (track?.fields == null)
|
||||||
|
{
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (track.fields.TryGetValue("Speed", out var speedText) &&
|
||||||
|
float.TryParse(speedText, NumberStyles.Float, CultureInfo.InvariantCulture, out var speed))
|
||||||
|
{
|
||||||
|
data.Speed = speed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (track.fields.TryGetValue("Reverse", out var reverseText) &&
|
||||||
|
bool.TryParse(reverseText, out var reverse))
|
||||||
|
{
|
||||||
|
data.Reverse = reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (track.fields.TryGetValue("CarDirectionBias", out var biasText) &&
|
||||||
|
float.TryParse(biasText, NumberStyles.Float, CultureInfo.InvariantCulture, out var bias))
|
||||||
|
{
|
||||||
|
data.CarDirectionBias = bias;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (track.fields.TryGetValue("EnableCarAbsoluteDirection", out var enableAbsText) &&
|
||||||
|
bool.TryParse(enableAbsText, out var enableAbs))
|
||||||
|
{
|
||||||
|
data.EnableCarAbsoluteDirection = enableAbs;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (track.fields.TryGetValue("CarAbsoluteDirection", out var absText) &&
|
||||||
|
float.TryParse(absText, NumberStyles.Float, CultureInfo.InvariantCulture, out var absDir))
|
||||||
|
{
|
||||||
|
data.CarAbsoluteDirection = absDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
data.Byroad = TryReadByte(track.fields, "Fass2_Byroad");
|
||||||
|
if (data.Byroad == null && track.fields.TryGetValue("magSelect", out var magSelect) &&
|
||||||
|
byte.TryParse(magSelect, NumberStyles.Integer, CultureInfo.InvariantCulture, out var magValue))
|
||||||
|
{
|
||||||
|
data.Byroad = magValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
data.Direction = TryReadByte(track.fields, "Fass2_Direction");
|
||||||
|
data.Orientation = TryReadByte(track.fields, "Fass2_Orientation");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte? TryReadByte(Dictionary<string, string> fields, string key)
|
||||||
|
{
|
||||||
|
if (!fields.TryGetValue(key, out var text) || string.IsNullOrWhiteSpace(text))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return byte.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)
|
||||||
|
? value
|
||||||
|
: (byte?)null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace StandardScene.Magnetic.Tasking
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// FASS 2.0 车型与 LoopMission 衔接接口。
|
||||||
|
/// LoopMission 仍通过 <c>goalSite</c> 标签分配目标;车型侧负责一次全程任务执行。
|
||||||
|
/// </summary>
|
||||||
|
public interface IFass2LoopCar
|
||||||
|
{
|
||||||
|
bool IsTaskIdle { get; }
|
||||||
|
|
||||||
|
bool HasGoalSite { get; }
|
||||||
|
|
||||||
|
int? GoalSiteId { get; }
|
||||||
|
|
||||||
|
/// <summary>执行 <c>goalSite</c>(或指定目标)对应的全程 FASS 2.0 任务。</summary>
|
||||||
|
Task ExecuteGoalTaskAsync(int? goalSiteId = null, double defaultSpeed = -1,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>任务到达终点后触发,参数为到达站 ID。</summary>
|
||||||
|
event Action<int> TaskCompleted;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace StandardScene.Magnetic.Tasking
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// MagFass2Car 本地文件日志,按协议车号分目录:logs/car{VehicleCode}/magfass2_yyyyMMdd.log
|
||||||
|
/// </summary>
|
||||||
|
public static class MagFass2CarFileLogger
|
||||||
|
{
|
||||||
|
private static readonly ConcurrentDictionary<ushort, object> CarLocks = new ConcurrentDictionary<ushort, object>();
|
||||||
|
private static string _baseDirectory = "logs";
|
||||||
|
private static bool _enabled = true;
|
||||||
|
|
||||||
|
public static void Configure(string baseDirectory, bool enabled)
|
||||||
|
{
|
||||||
|
_baseDirectory = string.IsNullOrWhiteSpace(baseDirectory) ? "logs" : baseDirectory.Trim();
|
||||||
|
_enabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetCarLogDirectory(ushort vehicleCode)
|
||||||
|
{
|
||||||
|
return Path.Combine(ResolveBaseDirectory(), $"car{vehicleCode}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetCurrentLogFilePath(ushort vehicleCode)
|
||||||
|
{
|
||||||
|
var fileName = $"magfass2_{DateTime.Now:yyyyMMdd}.log";
|
||||||
|
return Path.Combine(GetCarLogDirectory(vehicleCode), fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Write(ushort vehicleCode, string message)
|
||||||
|
{
|
||||||
|
if (!_enabled || vehicleCode == 0 || string.IsNullOrWhiteSpace(message))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var carLock = CarLocks.GetOrAdd(vehicleCode, _ => new object());
|
||||||
|
lock (carLock)
|
||||||
|
{
|
||||||
|
var directory = GetCarLogDirectory(vehicleCode);
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
var path = GetCurrentLogFilePath(vehicleCode);
|
||||||
|
File.AppendAllText(
|
||||||
|
path,
|
||||||
|
$"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] {message}{Environment.NewLine}",
|
||||||
|
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveBaseDirectory()
|
||||||
|
{
|
||||||
|
return Path.IsPathRooted(_baseDirectory)
|
||||||
|
? _baseDirectory
|
||||||
|
: Path.Combine(AppContext.BaseDirectory, _baseDirectory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -0,0 +1,209 @@
|
|||||||
|
# Export MagFass2Car guide Markdown to PDF (requires Chrome or Edge)
|
||||||
|
param(
|
||||||
|
[string]$InputMd = "$PSScriptRoot\MagFass2Car使用指南.md",
|
||||||
|
[string]$OutputPdf = "$PSScriptRoot\MagFass2Car使用指南.pdf"
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
|
|
||||||
|
function Escape-Html([string]$text) {
|
||||||
|
return [System.Net.WebUtility]::HtmlEncode($text)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Convert-MarkdownToHtml([string]$markdown) {
|
||||||
|
$lines = $markdown -split "`r?`n"
|
||||||
|
$sb = New-Object System.Text.StringBuilder
|
||||||
|
$inCode = $false
|
||||||
|
$inTable = $false
|
||||||
|
$tableRows = New-Object System.Collections.Generic.List[string[]]
|
||||||
|
$inList = $false
|
||||||
|
$listOrdered = $false
|
||||||
|
|
||||||
|
function Flush-Table {
|
||||||
|
param($builder, [ref]$inTableRef, $rows)
|
||||||
|
if (-not $inTableRef.Value) { return }
|
||||||
|
[void]$builder.AppendLine('<table>')
|
||||||
|
for ($i = 0; $i -lt $rows.Count; $i++) {
|
||||||
|
$cells = $rows[$i]
|
||||||
|
$tag = if ($i -eq 0) { 'th' } else { 'td' }
|
||||||
|
if ($i -eq 0) { [void]$builder.AppendLine('<thead><tr>') } elseif ($i -eq 1) { [void]$builder.AppendLine('<tbody><tr>') } else { [void]$builder.AppendLine('<tr>') }
|
||||||
|
foreach ($cell in $cells) {
|
||||||
|
$cellHtml = Escape-Html($cell.Trim())
|
||||||
|
$cellHtml = $cellHtml -replace '`([^`]+)`', '<code>$1</code>'
|
||||||
|
[void]$builder.AppendLine("<$tag>$cellHtml</$tag>")
|
||||||
|
}
|
||||||
|
[void]$builder.AppendLine('</tr>')
|
||||||
|
if ($i -eq 0) { [void]$builder.AppendLine('</thead>') }
|
||||||
|
}
|
||||||
|
if ($rows.Count -gt 1) { [void]$builder.AppendLine('</tbody>') }
|
||||||
|
[void]$builder.AppendLine('</table>')
|
||||||
|
$rows.Clear()
|
||||||
|
$inTableRef.Value = $false
|
||||||
|
}
|
||||||
|
|
||||||
|
function Close-List {
|
||||||
|
param($builder, [ref]$inListRef, [ref]$orderedRef)
|
||||||
|
if (-not $inListRef.Value) { return }
|
||||||
|
if ($orderedRef.Value) { [void]$builder.AppendLine('</ol>') } else { [void]$builder.AppendLine('</ul>') }
|
||||||
|
$inListRef.Value = $false
|
||||||
|
$orderedRef.Value = $false
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($line in $lines) {
|
||||||
|
if ($line -match '^\s*```') {
|
||||||
|
if ($inCode) {
|
||||||
|
[void]$sb.AppendLine('</code></pre>')
|
||||||
|
$inCode = $false
|
||||||
|
} else {
|
||||||
|
Flush-Table $sb ([ref]$inTable) $tableRows
|
||||||
|
Close-List $sb ([ref]$inList) ([ref]$listOrdered)
|
||||||
|
[void]$sb.AppendLine('<pre><code>')
|
||||||
|
$inCode = $true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($inCode) {
|
||||||
|
[void]$sb.AppendLine((Escape-Html $line))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($line -match '^\s*\|(.+)\|\s*$') {
|
||||||
|
Close-List $sb ([ref]$inList) ([ref]$listOrdered)
|
||||||
|
if ($line -match '^\s*\|[-:\s|]+\|\s*$') { continue }
|
||||||
|
$cells = ($line.Trim('|') -split '\|') | ForEach-Object { $_.Trim() }
|
||||||
|
$tableRows.Add($cells)
|
||||||
|
$inTable = $true
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
Flush-Table $sb ([ref]$inTable) $tableRows
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($line -match '^(#{1,6})\s+(.+)$') {
|
||||||
|
Close-List $sb ([ref]$inList) ([ref]$listOrdered)
|
||||||
|
$level = $Matches[1].Length
|
||||||
|
$text = $Matches[2]
|
||||||
|
[void]$sb.AppendLine("<h$level>$(Escape-Html $text)</h$level>")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($line -match '^>\s*(.+)$') {
|
||||||
|
Close-List $sb ([ref]$inList) ([ref]$listOrdered)
|
||||||
|
$text = $Matches[1]
|
||||||
|
[void]$sb.AppendLine("<blockquote><p>$(Escape-Html $text)</p></blockquote>")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($line -match '^---\s*$') {
|
||||||
|
Close-List $sb ([ref]$inList) ([ref]$listOrdered)
|
||||||
|
[void]$sb.AppendLine('<hr/>')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($line -match '^\d+\.\s+(.+)$') {
|
||||||
|
if (-not $inList) { [void]$sb.AppendLine('<ol>'); $inList = $true; $listOrdered = $true }
|
||||||
|
$text = Escape-Html $Matches[1]
|
||||||
|
$text = $text -replace '`([^`]+)`', '<code>$1</code>'
|
||||||
|
[void]$sb.AppendLine("<li>$text</li>")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($line -match '^-\s+(.+)$') {
|
||||||
|
if (-not $inList) { [void]$sb.AppendLine('<ul>'); $inList = $true; $listOrdered = $false }
|
||||||
|
$text = Escape-Html $Matches[1]
|
||||||
|
$text = $text -replace '`([^`]+)`', '<code>$1</code>'
|
||||||
|
[void]$sb.AppendLine("<li>$text</li>")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($line)) {
|
||||||
|
Close-List $sb ([ref]$inList) ([ref]$listOrdered)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
Close-List $sb ([ref]$inList) ([ref]$listOrdered)
|
||||||
|
$p = Escape-Html $line
|
||||||
|
$p = $p -replace '`([^`]+)`', '<code>$1</code>'
|
||||||
|
[void]$sb.AppendLine("<p>$p</p>")
|
||||||
|
}
|
||||||
|
|
||||||
|
Flush-Table $sb ([ref]$inTable) $tableRows
|
||||||
|
Close-List $sb ([ref]$inList) ([ref]$listOrdered)
|
||||||
|
if ($inCode) { [void]$sb.AppendLine('</code></pre>') }
|
||||||
|
return $sb.ToString()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path $InputMd)) {
|
||||||
|
throw "Markdown file not found: $InputMd"
|
||||||
|
}
|
||||||
|
|
||||||
|
$md = Get-Content -Path $InputMd -Raw -Encoding UTF8
|
||||||
|
$body = Convert-MarkdownToHtml $md
|
||||||
|
$htmlPath = [System.IO.Path]::ChangeExtension($OutputPdf, '.html')
|
||||||
|
|
||||||
|
$html = @"
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>MagFass2Car Guide</title>
|
||||||
|
<style>
|
||||||
|
@page { size: A4; margin: 18mm 16mm; }
|
||||||
|
body { font-family: "Microsoft YaHei", "Segoe UI", sans-serif; color: #222; line-height: 1.55; font-size: 11pt; }
|
||||||
|
h1 { font-size: 22pt; border-bottom: 2px solid #2563eb; padding-bottom: 8px; margin-top: 0; }
|
||||||
|
h2 { font-size: 16pt; color: #1e40af; margin-top: 22px; page-break-after: avoid; }
|
||||||
|
h3 { font-size: 13pt; color: #1d4ed8; margin-top: 16px; page-break-after: avoid; }
|
||||||
|
h4, h5 { font-size: 11.5pt; margin-top: 12px; page-break-after: avoid; }
|
||||||
|
p, li { orphans: 3; widows: 3; }
|
||||||
|
code, pre { font-family: Consolas, "Courier New", monospace; }
|
||||||
|
code { background: #f3f4f6; padding: 1px 4px; border-radius: 3px; font-size: 10pt; }
|
||||||
|
pre { background: #f8fafc; border: 1px solid #e5e7eb; padding: 10px; overflow-x: auto; font-size: 9.5pt; }
|
||||||
|
pre code { background: transparent; padding: 0; }
|
||||||
|
table { border-collapse: collapse; width: 100%; margin: 10px 0 14px; font-size: 10pt; page-break-inside: avoid; }
|
||||||
|
th, td { border: 1px solid #d1d5db; padding: 6px 8px; vertical-align: top; }
|
||||||
|
th { background: #eff6ff; text-align: left; }
|
||||||
|
tr:nth-child(even) td { background: #fafafa; }
|
||||||
|
blockquote { border-left: 4px solid #93c5fd; margin: 10px 0; padding: 6px 12px; color: #374151; background: #f8fafc; }
|
||||||
|
hr { border: none; border-top: 1px solid #e5e7eb; margin: 18px 0; }
|
||||||
|
ul, ol { margin: 6px 0 10px 20px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
$body
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"@
|
||||||
|
|
||||||
|
[System.IO.File]::WriteAllText($htmlPath, $html, [System.Text.UTF8Encoding]::new($false))
|
||||||
|
|
||||||
|
$browserCandidates = @(
|
||||||
|
"$env:ProgramFiles\Google\Chrome\Application\chrome.exe",
|
||||||
|
"${env:ProgramFiles(x86)}\Google\Chrome\Application\chrome.exe",
|
||||||
|
"${env:ProgramFiles(x86)}\Microsoft\Edge\Application\msedge.exe",
|
||||||
|
"$env:ProgramFiles\Microsoft\Edge\Application\msedge.exe"
|
||||||
|
)
|
||||||
|
$browser = $browserCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
|
||||||
|
if (-not $browser) {
|
||||||
|
throw "Chrome/Edge not found. HTML generated at: $htmlPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Test-Path $OutputPdf) { Remove-Item $OutputPdf -Force }
|
||||||
|
|
||||||
|
$resolvedHtml = (Resolve-Path -LiteralPath $htmlPath).Path
|
||||||
|
$fileUri = [System.Uri]::new($resolvedHtml).AbsoluteUri
|
||||||
|
$args = @(
|
||||||
|
"--headless=new",
|
||||||
|
"--disable-gpu",
|
||||||
|
"--no-pdf-header-footer",
|
||||||
|
"--print-to-pdf=$OutputPdf",
|
||||||
|
$fileUri
|
||||||
|
)
|
||||||
|
$proc = Start-Process -FilePath $browser -ArgumentList $args -Wait -PassThru
|
||||||
|
|
||||||
|
if ($proc.ExitCode -ne 0 -or -not (Test-Path $OutputPdf)) {
|
||||||
|
throw "PDF export failed with exit code $($proc.ExitCode). HTML: $htmlPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Output "PDF: $OutputPdf"
|
||||||
|
Write-Output "HTML: $htmlPath"
|
||||||
@@ -29,31 +29,8 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<!-- CycleGUI:TextViewer 等 UI 已从 WinForms 迁移到 CycleGUI(与 Core 一致,Private=false 由宿主加载) -->
|
<!-- CycleGUI:TextViewer 等 UI 已从 WinForms 迁移到 CycleGUI(与 Core 一致,Private=false 由宿主加载) -->
|
||||||
<ItemGroup>
|
|
||||||
<Reference Include="CycleGUI">
|
|
||||||
<HintPath>$(CGUILibDir)\CycleGUI.dll</HintPath>
|
|
||||||
<Private>false</Private>
|
|
||||||
</Reference>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<!-- 程序集引用:与 Core 保持一致的本地契约/工具 dll -->
|
<!-- 程序集引用:与 Core 保持一致的本地契约/工具 dll -->
|
||||||
<ItemGroup>
|
|
||||||
<Reference Include="SimpleLite">
|
|
||||||
<HintPath>E:\Work\Core\Simple-FR\Simple\SimpleLite\bin\Debug\SimpleLite.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="SimpleCore">
|
|
||||||
<HintPath>E:\Work\Core\Simple-FR\Simple\SimpleCore\bin\Debug\netstandard2.0\SimpleCore.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="CommonUsage">
|
|
||||||
<HintPath>D:\MDCS\Dependencies\Commons\CommonUsage.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="Topaz">
|
|
||||||
<HintPath>E:\Work\Core\Simple-FR\Simple\tools\Topaz.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="LessokajiWeaverUtilities">
|
|
||||||
<HintPath>E:\Work\Core\Simple-FR\Simple\SimpleLite\bin\Debug\LessokajiWeaverUtilities.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<!-- NuGet:VDA5050/MQTT 协议栈所需 -->
|
<!-- NuGet:VDA5050/MQTT 协议栈所需 -->
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -63,5 +40,28 @@
|
|||||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||||
<PackageReference Include="Jint" Version="4.6.3" />
|
<PackageReference Include="Jint" Version="4.6.3" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<Reference Include="CommonUsage">
|
||||||
|
<HintPath>$(StandRefDir)CommonUsage.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="CycleGUI">
|
||||||
|
<HintPath>$(StandRefDir)CycleGUI.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="LessokajiWeaverUtilities">
|
||||||
|
<HintPath>$(StandRefDir)LessokajiWeaverUtilities.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="MDCSToolBox">
|
||||||
|
<HintPath>$(StandRefDir)MDCSToolBox.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>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ namespace StandardScene.CarTypes
|
|||||||
version = _protocolVersion,
|
version = _protocolVersion,
|
||||||
manufacturer = _manufacturer,
|
manufacturer = _manufacturer,
|
||||||
serialNumber = _serialNumber,
|
serialNumber = _serialNumber,
|
||||||
actions = new List<actionState>
|
actions = new List<action>
|
||||||
{
|
{
|
||||||
new actionState
|
new action
|
||||||
{
|
{
|
||||||
actionId = actionId,
|
actionId = actionId,
|
||||||
actionType = actionType,
|
actionType = actionType,
|
||||||
|
|||||||
@@ -30,23 +30,23 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Reference Include="SimpleLite">
|
|
||||||
<HintPath>E:\Work\Core\Simple-FR\Simple\SimpleLite\bin\Debug\SimpleLite.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="SimpleCore">
|
|
||||||
<HintPath>E:\Work\Core\Simple-FR\Simple\SimpleCore\bin\Debug\netstandard2.0\SimpleCore.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="CommonUsage">
|
<Reference Include="CommonUsage">
|
||||||
<HintPath>D:\MDCS\Dependencies\Commons\CommonUsage.dll</HintPath>
|
<HintPath>$(StandRefDir)CommonUsage.dll</HintPath>
|
||||||
</Reference>
|
|
||||||
<Reference Include="MDCSToolBox">
|
|
||||||
<HintPath>D:\MDCS\Dependencies\Commons\MDCSToolBox.dll</HintPath>
|
|
||||||
</Reference>
|
|
||||||
<Reference Include="Topaz">
|
|
||||||
<HintPath>E:\Work\Core\Simple-FR\Simple\tools\Topaz.dll</HintPath>
|
|
||||||
</Reference>
|
</Reference>
|
||||||
<Reference Include="LessokajiWeaverUtilities">
|
<Reference Include="LessokajiWeaverUtilities">
|
||||||
<HintPath>E:\Work\Core\Simple-FR\Simple\SimpleLite\bin\Debug\LessokajiWeaverUtilities.dll</HintPath>
|
<HintPath>$(StandRefDir)LessokajiWeaverUtilities.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="MDCSToolBox">
|
||||||
|
<HintPath>$(StandRefDir)MDCSToolBox.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>
|
</Reference>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandardScene.Magnetic", "S
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandardScene.QrLidar", "StandardScene.QrLidar\StandardScene.QrLidar.csproj", "{7E5050AA-0007-4A11-9C22-0A0B0C0D0E07}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandardScene.QrLidar", "StandardScene.QrLidar\StandardScene.QrLidar.csproj", "{7E5050AA-0007-4A11-9C22-0A0B0C0D0E07}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandardScene.Fass2Simulator", "StandardScene.Fass2Simulator\StandardScene.Fass2Simulator.csproj", "{7E5050AA-0008-4A11-9C22-0A0B0C0D0E08}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -39,6 +41,10 @@ Global
|
|||||||
{7E5050AA-0007-4A11-9C22-0A0B0C0D0E07}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{7E5050AA-0007-4A11-9C22-0A0B0C0D0E07}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{7E5050AA-0007-4A11-9C22-0A0B0C0D0E07}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{7E5050AA-0007-4A11-9C22-0A0B0C0D0E07}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{7E5050AA-0007-4A11-9C22-0A0B0C0D0E07}.Release|Any CPU.Build.0 = Release|Any CPU
|
{7E5050AA-0007-4A11-9C22-0A0B0C0D0E07}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{7E5050AA-0008-4A11-9C22-0A0B0C0D0E08}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{7E5050AA-0008-4A11-9C22-0A0B0C0D0E08}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{7E5050AA-0008-4A11-9C22-0A0B0C0D0E08}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{7E5050AA-0008-4A11-9C22-0A0B0C0D0E08}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
|||||||
Reference in New Issue
Block a user