新增1.0和2.0两种协议车型车型
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user