2026-07-17 13:36:01 +08:00
|
|
|
|
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();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-08-24 14:55:35 +08:00
|
|
|
|
AppendLog("就绪。配置参数后点击「启动模拟」开始联调。完整日志将写入 log 目录。");
|
2026-07-17 13:36:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
2026-08-24 14:55:35 +08:00
|
|
|
|
LogDirectory = _config?.LogDirectory ?? "log"
|
2026-07-17 13:36:01 +08:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|