磁导航1.0内部交管和信号交互
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace StandardScene.MagCarSimulator
|
||||
{
|
||||
/// <summary>
|
||||
/// 模拟器日志:UI 回调 + 写入 exe 旁 <c>log/</c> 目录。
|
||||
/// </summary>
|
||||
public static class MagCarSimLog
|
||||
{
|
||||
private static readonly object SyncRoot = new object();
|
||||
private static StreamWriter _writer;
|
||||
private static bool _enabled = true;
|
||||
private static string _logDirectory;
|
||||
|
||||
public static event Action<string> MessageWritten;
|
||||
|
||||
public static string CurrentFilePath { get; private set; }
|
||||
|
||||
public static void Configure(MagCarSimConfig config)
|
||||
{
|
||||
lock (SyncRoot)
|
||||
{
|
||||
_enabled = config == null || config.EnableFileLog;
|
||||
_logDirectory = ResolveLogDirectory(config?.LogDirectory);
|
||||
if (_enabled)
|
||||
{
|
||||
EnsureWriterUnlocked();
|
||||
}
|
||||
else
|
||||
{
|
||||
CloseWriterUnlocked();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static string GetLogDirectory()
|
||||
{
|
||||
return _logDirectory ?? ResolveLogDirectory("log");
|
||||
}
|
||||
|
||||
public static void WriteLine(string message)
|
||||
{
|
||||
if (string.IsNullOrEmpty(message))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
MessageWritten?.Invoke(message);
|
||||
|
||||
if (!_enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (SyncRoot)
|
||||
{
|
||||
EnsureWriterUnlocked();
|
||||
_writer?.WriteLine(message);
|
||||
}
|
||||
}
|
||||
|
||||
public static void Shutdown()
|
||||
{
|
||||
lock (SyncRoot)
|
||||
{
|
||||
if (_writer != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
_writer.WriteLine($"===== 会话结束 {DateTime.Now:yyyy-MM-dd HH:mm:ss} =====");
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
CloseWriterUnlocked();
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureWriterUnlocked()
|
||||
{
|
||||
if (!_enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_writer != null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(GetLogDirectory());
|
||||
var fileName = $"magcar-sim_{DateTime.Now:yyyyMMdd}.log";
|
||||
CurrentFilePath = Path.Combine(GetLogDirectory(), fileName);
|
||||
_writer = new StreamWriter(CurrentFilePath, true, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false))
|
||||
{
|
||||
AutoFlush = true
|
||||
};
|
||||
_writer.WriteLine($"[{DateTime.Now:HH:mm:ss}] 文件日志已启用: {CurrentFilePath}");
|
||||
}
|
||||
|
||||
private static void CloseWriterUnlocked()
|
||||
{
|
||||
try
|
||||
{
|
||||
_writer?.Flush();
|
||||
_writer?.Dispose();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
_writer = null;
|
||||
}
|
||||
|
||||
private static string ResolveLogDirectory(string configuredPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configuredPath))
|
||||
{
|
||||
return Path.Combine(AppContext.BaseDirectory, "log");
|
||||
}
|
||||
|
||||
return Path.IsPathRooted(configuredPath)
|
||||
? configuredPath
|
||||
: Path.Combine(AppContext.BaseDirectory, configuredPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,630 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace StandardScene.MagCarSimulator
|
||||
{
|
||||
public sealed class MainForm : Form
|
||||
{
|
||||
private const int MaxLogLines = 400;
|
||||
private const int MaxPendingLogs = 300;
|
||||
|
||||
private readonly MagCarSimConfig _config;
|
||||
private readonly MagCarSimHost _host;
|
||||
private readonly StringBuilder _logBuffer = new StringBuilder();
|
||||
private readonly Queue<string> _pendingLogs = new Queue<string>();
|
||||
private readonly object _logLock = new object();
|
||||
private readonly Timer _uiTimer;
|
||||
private bool _logFlushScheduled;
|
||||
private int _logLineCount;
|
||||
|
||||
private TextBox _mapPathBox;
|
||||
private CheckBox _useTagValueBox;
|
||||
private Label _mapInfoLabel;
|
||||
private DataGridView _grid;
|
||||
private ListBox _siteList;
|
||||
private TextBox _logBox;
|
||||
private Button _listenButton;
|
||||
private Button _loopButton;
|
||||
private Button _stopButton;
|
||||
private Button _releaseButton;
|
||||
private Button _addButton;
|
||||
private Button _removeButton;
|
||||
private Button _saveButton;
|
||||
|
||||
public MainForm()
|
||||
{
|
||||
_config = MagCarSimConfig.Load();
|
||||
MagCarSimLog.Configure(_config);
|
||||
_host = new MagCarSimHost(_config);
|
||||
|
||||
Text = "MagCar 模拟器(FASS 1.0 / SimpleLite 地图循环)";
|
||||
Width = 1280;
|
||||
Height = 820;
|
||||
MinimumSize = new Size(980, 640);
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
Font = new Font("Microsoft YaHei UI", 9F);
|
||||
|
||||
BuildLayout();
|
||||
LoadConfigToUi();
|
||||
|
||||
MagCarSimLog.MessageWritten += OnLogMessage;
|
||||
_uiTimer = new Timer { Interval = 250 };
|
||||
_uiTimer.Tick += (_, __) => RefreshGridStatus();
|
||||
_uiTimer.Start();
|
||||
|
||||
FormClosed += (_, __) =>
|
||||
{
|
||||
MagCarSimLog.MessageWritten -= OnLogMessage;
|
||||
_uiTimer.Stop();
|
||||
_host.Dispose();
|
||||
MagCarSimLog.Shutdown();
|
||||
};
|
||||
|
||||
AppendLog($"就绪。文件日志目录: {MagCarSimLog.GetLogDirectory()}。加载 SimpleLite 地图后启动监听,再开始循环。普通站可叠车;停止点和交管点同时只允许一辆。");
|
||||
TryLoadMap(_config.MapPath, silent: true);
|
||||
}
|
||||
|
||||
private void BuildLayout()
|
||||
{
|
||||
var root = new TableLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
ColumnCount = 1,
|
||||
RowCount = 4,
|
||||
Padding = new Padding(8)
|
||||
};
|
||||
root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
root.RowStyles.Add(new RowStyle(SizeType.Percent, 42));
|
||||
root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
root.RowStyles.Add(new RowStyle(SizeType.Percent, 58));
|
||||
Controls.Add(root);
|
||||
|
||||
root.Controls.Add(BuildToolbar(), 0, 0);
|
||||
_grid = BuildGrid();
|
||||
root.Controls.Add(_grid, 0, 1);
|
||||
root.Controls.Add(BuildVehicleButtons(), 0, 2);
|
||||
|
||||
var split = new SplitContainer
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
Orientation = Orientation.Vertical,
|
||||
SplitterDistance = 280
|
||||
};
|
||||
_siteList = new ListBox { Dock = DockStyle.Fill, IntegralHeight = false };
|
||||
var sitePanel = new Panel { Dock = DockStyle.Fill, Padding = new Padding(0, 4, 4, 0) };
|
||||
sitePanel.Controls.Add(_siteList);
|
||||
sitePanel.Controls.Add(new Label
|
||||
{
|
||||
Text = "地图站点(带 [停止] 的为 Mag_NeedStop)",
|
||||
Dock = DockStyle.Top,
|
||||
Height = 22
|
||||
});
|
||||
split.Panel1.Controls.Add(sitePanel);
|
||||
|
||||
_logBox = new TextBox
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
Multiline = true,
|
||||
ReadOnly = true,
|
||||
ScrollBars = ScrollBars.Both,
|
||||
WordWrap = false,
|
||||
Font = new Font("Consolas", 9F)
|
||||
};
|
||||
var logPanel = new Panel { Dock = DockStyle.Fill, Padding = new Padding(4, 4, 0, 0) };
|
||||
logPanel.Controls.Add(_logBox);
|
||||
logPanel.Controls.Add(new Label
|
||||
{
|
||||
Text = "协议 / 运动日志",
|
||||
Dock = DockStyle.Top,
|
||||
Height = 22
|
||||
});
|
||||
split.Panel2.Controls.Add(logPanel);
|
||||
root.Controls.Add(split, 0, 3);
|
||||
}
|
||||
|
||||
private Control BuildToolbar()
|
||||
{
|
||||
var panel = new TableLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Top,
|
||||
AutoSize = true,
|
||||
ColumnCount = 8,
|
||||
RowCount = 2,
|
||||
Padding = new Padding(0, 0, 0, 6)
|
||||
};
|
||||
panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
|
||||
panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
|
||||
panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
|
||||
panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
|
||||
panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
|
||||
panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
|
||||
panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
|
||||
panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
|
||||
|
||||
panel.Controls.Add(new Label { Text = "地图", AutoSize = true, Anchor = AnchorStyles.Left }, 0, 0);
|
||||
_mapPathBox = new TextBox { Dock = DockStyle.Fill, Width = 480 };
|
||||
panel.Controls.Add(_mapPathBox, 1, 0);
|
||||
|
||||
var browse = new Button { Text = "浏览…", AutoSize = true };
|
||||
browse.Click += (_, __) => BrowseMap();
|
||||
panel.Controls.Add(browse, 2, 0);
|
||||
|
||||
var load = new Button { Text = "加载地图", AutoSize = true };
|
||||
load.Click += (_, __) => TryLoadMap(_mapPathBox.Text, silent: false);
|
||||
panel.Controls.Add(load, 3, 0);
|
||||
|
||||
_listenButton = new Button { Text = "启动监听", AutoSize = true };
|
||||
_listenButton.Click += (_, __) => StartListen();
|
||||
panel.Controls.Add(_listenButton, 4, 0);
|
||||
|
||||
_loopButton = new Button { Text = "开始循环", AutoSize = true };
|
||||
_loopButton.Click += (_, __) => StartLoop();
|
||||
panel.Controls.Add(_loopButton, 5, 0);
|
||||
|
||||
_stopButton = new Button { Text = "停止模拟", AutoSize = true, Enabled = false };
|
||||
_stopButton.Click += (_, __) => StopSim();
|
||||
panel.Controls.Add(_stopButton, 6, 0);
|
||||
|
||||
_saveButton = new Button { Text = "保存配置", AutoSize = true };
|
||||
_saveButton.Click += (_, __) => SaveConfig();
|
||||
panel.Controls.Add(_saveButton, 7, 0);
|
||||
|
||||
_useTagValueBox = new CheckBox { Text = "用 TagValue 作为节点号", AutoSize = true, Anchor = AnchorStyles.Left };
|
||||
panel.SetColumnSpan(_useTagValueBox, 2);
|
||||
panel.Controls.Add(_useTagValueBox, 0, 1);
|
||||
|
||||
_mapInfoLabel = new Label
|
||||
{
|
||||
Text = "未加载地图",
|
||||
AutoSize = true,
|
||||
Anchor = AnchorStyles.Left,
|
||||
ForeColor = Color.DimGray
|
||||
};
|
||||
panel.SetColumnSpan(_mapInfoLabel, 6);
|
||||
panel.Controls.Add(_mapInfoLabel, 2, 1);
|
||||
return panel;
|
||||
}
|
||||
|
||||
private Control BuildVehicleButtons()
|
||||
{
|
||||
var panel = new FlowLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
AutoSize = true,
|
||||
WrapContents = false,
|
||||
Padding = new Padding(0, 4, 0, 4)
|
||||
};
|
||||
_addButton = new Button { Text = "添加车辆", AutoSize = true };
|
||||
_addButton.Click += (_, __) => AddVehicleRow();
|
||||
_removeButton = new Button { Text = "删除选中", AutoSize = true };
|
||||
_removeButton.Click += (_, __) => RemoveSelectedVehicle();
|
||||
_releaseButton = new Button { Text = "放行选中车", AutoSize = true };
|
||||
_releaseButton.Click += (_, __) => ReleaseSelected();
|
||||
panel.Controls.Add(_addButton);
|
||||
panel.Controls.Add(_removeButton);
|
||||
panel.Controls.Add(_releaseButton);
|
||||
panel.Controls.Add(new Label
|
||||
{
|
||||
Text = "普通站可叠车。停止点和交管点同时只允许一辆,后车停在上一站保持运行中。仅 Mag_NeedStop 停车等 0x01。",
|
||||
AutoSize = true,
|
||||
Padding = new Padding(12, 8, 0, 0),
|
||||
ForeColor = Color.DimGray
|
||||
});
|
||||
return panel;
|
||||
}
|
||||
|
||||
private DataGridView BuildGrid()
|
||||
{
|
||||
var grid = new DataGridView
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
AllowUserToAddRows = false,
|
||||
AllowUserToDeleteRows = false,
|
||||
RowHeadersVisible = false,
|
||||
SelectionMode = DataGridViewSelectionMode.FullRowSelect,
|
||||
MultiSelect = false,
|
||||
AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill,
|
||||
BackgroundColor = Color.White
|
||||
};
|
||||
grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Name", HeaderText = "名称", FillWeight = 80 });
|
||||
grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Code", HeaderText = "车号", FillWeight = 50 });
|
||||
grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Port", HeaderText = "端口", FillWeight = 55 });
|
||||
grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Start", HeaderText = "起点", FillWeight = 50 });
|
||||
grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "End", HeaderText = "终点", FillWeight = 50 });
|
||||
grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Interval", HeaderText = "间隔ms", FillWeight = 60 });
|
||||
grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Site", HeaderText = "当前站", ReadOnly = true, FillWeight = 55 });
|
||||
grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Node", HeaderText = "节点", ReadOnly = true, FillWeight = 50 });
|
||||
grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "State", HeaderText = "状态", ReadOnly = true, FillWeight = 70 });
|
||||
grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Wait", HeaderText = "等待", ReadOnly = true, FillWeight = 55 });
|
||||
grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Clients", HeaderText = "连接", ReadOnly = true, FillWeight = 45 });
|
||||
grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Path", HeaderText = "循环路径", ReadOnly = true, FillWeight = 160 });
|
||||
return grid;
|
||||
}
|
||||
|
||||
private void LoadConfigToUi()
|
||||
{
|
||||
_mapPathBox.Text = _config.MapPath ?? "";
|
||||
_useTagValueBox.Checked = _config.UseTagValueAsNode;
|
||||
_grid.Rows.Clear();
|
||||
foreach (var vehicle in _config.Vehicles)
|
||||
{
|
||||
_grid.Rows.Add(
|
||||
vehicle.Name,
|
||||
vehicle.VehicleCode,
|
||||
vehicle.ListenPort,
|
||||
vehicle.StartSiteId,
|
||||
vehicle.EndSiteId,
|
||||
vehicle.IntervalMs,
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"");
|
||||
}
|
||||
}
|
||||
|
||||
private List<MagCarSimVehicleConfig> ReadVehiclesFromGrid()
|
||||
{
|
||||
var list = new List<MagCarSimVehicleConfig>();
|
||||
foreach (DataGridViewRow row in _grid.Rows)
|
||||
{
|
||||
if (row.IsNewRow)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var code = ToUShort(row.Cells["Code"].Value, 1);
|
||||
list.Add(new MagCarSimVehicleConfig
|
||||
{
|
||||
Name = Convert.ToString(row.Cells["Name"].Value),
|
||||
VehicleCode = code,
|
||||
ListenPort = ToInt(row.Cells["Port"].Value, 5001),
|
||||
StartSiteId = ToInt(row.Cells["Start"].Value, 1),
|
||||
EndSiteId = ToInt(row.Cells["End"].Value, 2),
|
||||
IntervalMs = ToInt(row.Cells["Interval"].Value, _config.DefaultIntervalMs),
|
||||
LoopSiteIds = PreserveLoop(code)
|
||||
});
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private List<int> PreserveLoop(ushort vehicleCode)
|
||||
{
|
||||
var existing = _config.Vehicles?.Find(v => v.VehicleCode == vehicleCode);
|
||||
if (existing?.LoopSiteIds == null || existing.LoopSiteIds.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new List<int>(existing.LoopSiteIds);
|
||||
}
|
||||
|
||||
private void BrowseMap()
|
||||
{
|
||||
using var dialog = new OpenFileDialog
|
||||
{
|
||||
Filter = "SimpleLite 地图 (*.json)|*.json|所有文件 (*.*)|*.*",
|
||||
Title = "选择 SimpleLite 地图"
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(_mapPathBox.Text) && File.Exists(_mapPathBox.Text))
|
||||
{
|
||||
dialog.InitialDirectory = Path.GetDirectoryName(_mapPathBox.Text);
|
||||
dialog.FileName = Path.GetFileName(_mapPathBox.Text);
|
||||
}
|
||||
|
||||
if (dialog.ShowDialog(this) == DialogResult.OK)
|
||||
{
|
||||
_mapPathBox.Text = dialog.FileName;
|
||||
TryLoadMap(dialog.FileName, silent: false);
|
||||
}
|
||||
}
|
||||
|
||||
private void TryLoadMap(string path, bool silent)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
if (!silent)
|
||||
{
|
||||
MessageBox.Show(this, "请先选择 SimpleLite 地图文件。", "加载地图", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_config.UseTagValueAsNode = _useTagValueBox.Checked;
|
||||
var map = _host.LoadMap(path);
|
||||
_mapPathBox.Text = map.FilePath;
|
||||
RefreshSiteList(map);
|
||||
var stops = map.ListNeedStopSiteIds();
|
||||
_mapInfoLabel.Text = $"站点 {map.Sites.Count},路径 {map.Tracks.Count},停止点 {(stops.Count == 0 ? "无(循环将一直过站)" : string.Join(",", stops))}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_mapInfoLabel.Text = "地图加载失败";
|
||||
if (!silent)
|
||||
{
|
||||
MessageBox.Show(this, ex.Message, "加载地图失败", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
}
|
||||
else
|
||||
{
|
||||
AppendLog("地图未加载:" + ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshSiteList(SimpleLiteMap map)
|
||||
{
|
||||
_siteList.Items.Clear();
|
||||
foreach (var site in map.Sites.Values)
|
||||
{
|
||||
var tag = site.NeedStop ? " [停止]" : "";
|
||||
var node = map.ResolveNode(site.Id);
|
||||
_siteList.Items.Add($"站 {site.Id} node={node}{tag}");
|
||||
}
|
||||
}
|
||||
|
||||
private void StartListen()
|
||||
{
|
||||
try
|
||||
{
|
||||
ApplyGridToHost();
|
||||
_host.StartListen();
|
||||
SetRunningUi(true, looping: false);
|
||||
AppendLog("TCP 监听已启动。请在 SimpleLite 中把 MagCar 的 address/Port/VehicleCode 配成与上表一致。");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(this, ex.Message, "启动监听失败", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void StartLoop()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_host.IsListening)
|
||||
{
|
||||
ApplyGridToHost();
|
||||
}
|
||||
|
||||
_host.StartLoops();
|
||||
SetRunningUi(true, looping: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(this, ex.Message, "开始循环失败", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void StopSim()
|
||||
{
|
||||
_host.StopAll();
|
||||
SetRunningUi(false, looping: false);
|
||||
AppendLog("模拟已停止。");
|
||||
RefreshGridStatus();
|
||||
}
|
||||
|
||||
private void ApplyGridToHost()
|
||||
{
|
||||
_config.MapPath = _mapPathBox.Text;
|
||||
_config.UseTagValueAsNode = _useTagValueBox.Checked;
|
||||
_config.Vehicles = ReadVehiclesFromGrid();
|
||||
if (!string.IsNullOrWhiteSpace(_config.MapPath))
|
||||
{
|
||||
TryLoadMap(_config.MapPath, silent: false);
|
||||
}
|
||||
|
||||
_host.RebuildVehicles(_config.Vehicles);
|
||||
}
|
||||
|
||||
private void AddVehicleRow()
|
||||
{
|
||||
if (_host.IsListening)
|
||||
{
|
||||
MessageBox.Show(this, "运行中不能改车辆列表,请先停止模拟。", "添加车辆", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
return;
|
||||
}
|
||||
|
||||
var nextCode = _grid.Rows.Count + 1;
|
||||
_grid.Rows.Add($"AGV-{nextCode}", nextCode, 5000 + nextCode, 1, 6, _config.DefaultIntervalMs, "", "", "", "", "", "");
|
||||
}
|
||||
|
||||
private void RemoveSelectedVehicle()
|
||||
{
|
||||
if (_host.IsListening)
|
||||
{
|
||||
MessageBox.Show(this, "运行中不能改车辆列表,请先停止模拟。", "删除车辆", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_grid.CurrentRow == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_grid.Rows.Remove(_grid.CurrentRow);
|
||||
}
|
||||
|
||||
private void ReleaseSelected()
|
||||
{
|
||||
if (_grid.CurrentRow == null)
|
||||
{
|
||||
MessageBox.Show(this, "请先选中一辆车。", "放行", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
return;
|
||||
}
|
||||
|
||||
var code = ToUShort(_grid.CurrentRow.Cells["Code"].Value, 0);
|
||||
if (!_host.TryRelease(code))
|
||||
{
|
||||
MessageBox.Show(this, $"未找到车号 {code},请先启动监听。", "放行", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveConfig()
|
||||
{
|
||||
try
|
||||
{
|
||||
_config.MapPath = _mapPathBox.Text;
|
||||
_config.UseTagValueAsNode = _useTagValueBox.Checked;
|
||||
_config.Vehicles = ReadVehiclesFromGrid();
|
||||
_config.Save();
|
||||
AppendLog("配置已保存到 " + MagCarSimConfig.SettingsPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(this, ex.Message, "保存失败", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void SetRunningUi(bool listening, bool looping)
|
||||
{
|
||||
_listenButton.Enabled = !listening;
|
||||
_loopButton.Enabled = !looping;
|
||||
_stopButton.Enabled = listening;
|
||||
_addButton.Enabled = !listening;
|
||||
_removeButton.Enabled = !listening;
|
||||
_grid.ReadOnly = listening;
|
||||
_useTagValueBox.Enabled = !listening;
|
||||
}
|
||||
|
||||
private void RefreshGridStatus()
|
||||
{
|
||||
var snapshots = _host.Snapshots();
|
||||
if (snapshots.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = 0; i < _grid.Rows.Count && i < snapshots.Count; i++)
|
||||
{
|
||||
var snap = snapshots[i];
|
||||
var row = _grid.Rows[i];
|
||||
row.Cells["Site"].Value = snap.CurrentSiteId;
|
||||
row.Cells["Node"].Value = snap.Node;
|
||||
row.Cells["State"].Value = snap.StateText;
|
||||
row.Cells["Wait"].Value = snap.WaitingRelease ? "等放行" : (snap.HoldingForStop ? "让行" : "");
|
||||
row.Cells["Clients"].Value = snap.ClientCount;
|
||||
row.Cells["Path"].Value = snap.PathText;
|
||||
row.DefaultCellStyle.BackColor = snap.WaitingRelease
|
||||
? Color.FromArgb(255, 236, 179)
|
||||
: snap.HoldingForStop
|
||||
? Color.FromArgb(207, 232, 255)
|
||||
: Color.White;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnLogMessage(string message)
|
||||
{
|
||||
lock (_logLock)
|
||||
{
|
||||
while (_pendingLogs.Count >= MaxPendingLogs)
|
||||
{
|
||||
_pendingLogs.Dequeue();
|
||||
}
|
||||
|
||||
_pendingLogs.Enqueue(message);
|
||||
}
|
||||
|
||||
if (_logFlushScheduled || !IsHandleCreated)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logFlushScheduled = true;
|
||||
try
|
||||
{
|
||||
BeginInvoke(new Action(FlushLogs));
|
||||
}
|
||||
catch
|
||||
{
|
||||
_logFlushScheduled = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void FlushLogs()
|
||||
{
|
||||
_logFlushScheduled = false;
|
||||
List<string> batch;
|
||||
lock (_logLock)
|
||||
{
|
||||
if (_pendingLogs.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
batch = new List<string>(_pendingLogs.Count);
|
||||
while (_pendingLogs.Count > 0)
|
||||
{
|
||||
batch.Add(_pendingLogs.Dequeue());
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var line in batch)
|
||||
{
|
||||
_logLineCount++;
|
||||
_logBuffer.AppendLine(line);
|
||||
}
|
||||
|
||||
while (_logLineCount > MaxLogLines)
|
||||
{
|
||||
var text = _logBuffer.ToString();
|
||||
var firstBreak = text.IndexOf('\n');
|
||||
if (firstBreak < 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
_logBuffer.Remove(0, firstBreak + 1);
|
||||
_logLineCount--;
|
||||
}
|
||||
|
||||
_logBox.Text = _logBuffer.ToString();
|
||||
_logBox.SelectionStart = _logBox.TextLength;
|
||||
_logBox.ScrollToCaret();
|
||||
}
|
||||
|
||||
private void AppendLog(string message)
|
||||
{
|
||||
lock (_logLock)
|
||||
{
|
||||
while (_pendingLogs.Count >= MaxPendingLogs)
|
||||
{
|
||||
_pendingLogs.Dequeue();
|
||||
}
|
||||
|
||||
_pendingLogs.Enqueue(message);
|
||||
}
|
||||
|
||||
if (IsHandleCreated)
|
||||
{
|
||||
if (!_logFlushScheduled)
|
||||
{
|
||||
_logFlushScheduled = true;
|
||||
BeginInvoke(new Action(FlushLogs));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
FlushLogs();
|
||||
}
|
||||
}
|
||||
|
||||
private static int ToInt(object value, int fallback)
|
||||
{
|
||||
return int.TryParse(Convert.ToString(value), out var n) ? n : fallback;
|
||||
}
|
||||
|
||||
private static ushort ToUShort(object value, ushort fallback)
|
||||
{
|
||||
return ushort.TryParse(Convert.ToString(value), out var n) ? n : fallback;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace StandardScene.MagCarSimulator
|
||||
{
|
||||
/// <summary>读取 mag-control-areas.json,收集触发点与管控区站点。</summary>
|
||||
public static class MagControlAreaFile
|
||||
{
|
||||
private static readonly char[] SiteSeparators = { ',', ';', '|', ' ', '\t' };
|
||||
|
||||
public static HashSet<int> LoadSiteIds(string path)
|
||||
{
|
||||
var ids = new HashSet<int>();
|
||||
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||
{
|
||||
return ids;
|
||||
}
|
||||
|
||||
var root = JToken.Parse(File.ReadAllText(path));
|
||||
if (root is not JArray rows)
|
||||
{
|
||||
return ids;
|
||||
}
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (row is not JObject obj)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (obj.Value<bool?>("IsUse") == false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
AddIfPositive(ids, obj.Value<int?>("TriggerSit") ?? 0);
|
||||
foreach (var part in (obj.Value<string>("ControlArea") ?? "").Split(SiteSeparators, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
if (int.TryParse(part.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var id))
|
||||
{
|
||||
AddIfPositive(ids, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
public static string GuessPath(string mapPath, string configuredPath)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(configuredPath) && File.Exists(configuredPath))
|
||||
{
|
||||
return Path.GetFullPath(configuredPath);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(mapPath))
|
||||
{
|
||||
return configuredPath;
|
||||
}
|
||||
|
||||
var mapDir = Path.GetDirectoryName(Path.GetFullPath(mapPath));
|
||||
if (string.IsNullOrWhiteSpace(mapDir))
|
||||
{
|
||||
return configuredPath;
|
||||
}
|
||||
|
||||
var guessed = Path.GetFullPath(Path.Combine(mapDir, "..", "config", "Signal", "mag-control-areas.json"));
|
||||
return File.Exists(guessed) ? guessed : configuredPath;
|
||||
}
|
||||
|
||||
private static void AddIfPositive(HashSet<int> ids, int id)
|
||||
{
|
||||
if (id > 0)
|
||||
{
|
||||
ids.Add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace StandardScene.MagCarSimulator
|
||||
{
|
||||
public static class MapPathFinder
|
||||
{
|
||||
/// <summary>
|
||||
/// 生成往返循环:起点→终点→起点(终点不重复,回到起点靠下标回绕)。
|
||||
/// </summary>
|
||||
public static List<int> BuildLoop(SimpleLiteMap map, int startSiteId, int endSiteId)
|
||||
{
|
||||
if (map == null)
|
||||
{
|
||||
throw new System.InvalidOperationException("尚未加载地图");
|
||||
}
|
||||
|
||||
if (startSiteId == endSiteId)
|
||||
{
|
||||
throw new System.InvalidOperationException($"起点和终点不能相同(站点 {startSiteId})");
|
||||
}
|
||||
|
||||
if (!map.Sites.ContainsKey(startSiteId))
|
||||
{
|
||||
throw new System.InvalidOperationException($"地图中没有起点 {startSiteId}");
|
||||
}
|
||||
|
||||
if (!map.Sites.ContainsKey(endSiteId))
|
||||
{
|
||||
throw new System.InvalidOperationException($"地图中没有终点 {endSiteId}");
|
||||
}
|
||||
|
||||
var forward = FindPath(map, startSiteId, endSiteId);
|
||||
if (forward == null)
|
||||
{
|
||||
throw new System.InvalidOperationException($"找不到路径 {startSiteId} → {endSiteId}");
|
||||
}
|
||||
|
||||
var back = FindPath(map, endSiteId, startSiteId);
|
||||
if (back == null)
|
||||
{
|
||||
throw new System.InvalidOperationException($"找不到返回路径 {endSiteId} → {startSiteId}");
|
||||
}
|
||||
|
||||
var loop = new List<int>(forward.Count + back.Count);
|
||||
loop.AddRange(forward);
|
||||
for (var i = 1; i < back.Count - 1; i++)
|
||||
{
|
||||
loop.Add(back[i]);
|
||||
}
|
||||
|
||||
if (loop.Count < 2)
|
||||
{
|
||||
throw new System.InvalidOperationException("循环路径至少需要两个站点");
|
||||
}
|
||||
|
||||
return loop;
|
||||
}
|
||||
|
||||
public static List<int> NormalizeLoop(IEnumerable<int> siteIds)
|
||||
{
|
||||
var loop = new List<int>();
|
||||
if (siteIds == null)
|
||||
{
|
||||
return loop;
|
||||
}
|
||||
|
||||
foreach (var id in siteIds)
|
||||
{
|
||||
if (id <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (loop.Count > 0 && loop[loop.Count - 1] == id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
loop.Add(id);
|
||||
}
|
||||
|
||||
if (loop.Count >= 2 && loop[0] == loop[loop.Count - 1])
|
||||
{
|
||||
loop.RemoveAt(loop.Count - 1);
|
||||
}
|
||||
|
||||
return loop;
|
||||
}
|
||||
|
||||
public static List<int> ResolveLoop(SimpleLiteMap map, MagCarSimVehicleConfig config)
|
||||
{
|
||||
if (map == null)
|
||||
{
|
||||
throw new System.InvalidOperationException("尚未加载地图");
|
||||
}
|
||||
|
||||
if (config?.LoopSiteIds != null && config.LoopSiteIds.Count >= 3)
|
||||
{
|
||||
var loop = NormalizeLoop(config.LoopSiteIds);
|
||||
if (loop.Count < 3)
|
||||
{
|
||||
throw new System.InvalidOperationException($"{config.Name} 环线站点不足");
|
||||
}
|
||||
|
||||
foreach (var id in loop)
|
||||
{
|
||||
if (!map.Sites.ContainsKey(id))
|
||||
{
|
||||
throw new System.InvalidOperationException($"{config.Name} 环线站点 {id} 不在地图中");
|
||||
}
|
||||
}
|
||||
|
||||
return loop;
|
||||
}
|
||||
|
||||
return BuildLoop(map, config.StartSiteId, config.EndSiteId);
|
||||
}
|
||||
|
||||
public static string FormatPath(IReadOnlyList<int> siteIds, int maxShow = 0)
|
||||
{
|
||||
if (siteIds == null || siteIds.Count == 0)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
var show = siteIds.Count;
|
||||
var truncated = false;
|
||||
if (maxShow > 0 && siteIds.Count > maxShow)
|
||||
{
|
||||
show = maxShow;
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
for (var i = 0; i < show; i++)
|
||||
{
|
||||
if (i > 0)
|
||||
{
|
||||
sb.Append("→");
|
||||
}
|
||||
|
||||
sb.Append(siteIds[i]);
|
||||
}
|
||||
|
||||
if (truncated)
|
||||
{
|
||||
sb.Append("→…共").Append(siteIds.Count).Append("站");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append("→").Append(siteIds[0]);
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public static List<int> FindPath(SimpleLiteMap map, int fromSiteId, int toSiteId)
|
||||
{
|
||||
if (fromSiteId == toSiteId)
|
||||
{
|
||||
return new List<int> { fromSiteId };
|
||||
}
|
||||
|
||||
var queue = new Queue<int>();
|
||||
var prev = new Dictionary<int, int>();
|
||||
var visited = new HashSet<int> { fromSiteId };
|
||||
queue.Enqueue(fromSiteId);
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var current = queue.Dequeue();
|
||||
if (!map.Adjacency.TryGetValue(current, out var nexts))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var next in nexts)
|
||||
{
|
||||
if (!visited.Add(next))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
prev[next] = current;
|
||||
if (next == toSiteId)
|
||||
{
|
||||
return Reconstruct(prev, fromSiteId, toSiteId);
|
||||
}
|
||||
|
||||
queue.Enqueue(next);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<int> Reconstruct(Dictionary<int, int> prev, int fromSiteId, int toSiteId)
|
||||
{
|
||||
var path = new List<int>();
|
||||
var current = toSiteId;
|
||||
path.Add(current);
|
||||
while (current != fromSiteId)
|
||||
{
|
||||
current = prev[current];
|
||||
path.Add(current);
|
||||
}
|
||||
|
||||
path.Reverse();
|
||||
return path;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace StandardScene.MagCarSimulator
|
||||
{
|
||||
public sealed class SimpleLiteSite
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
public double X { get; set; }
|
||||
public double Y { get; set; }
|
||||
public int? TagValue { get; set; }
|
||||
public bool NeedStop { get; set; }
|
||||
public IReadOnlyDictionary<string, string> Fields { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SimpleLiteTrack
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int SiteA { get; set; }
|
||||
public int SiteB { get; set; }
|
||||
public int Direction { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>解析 SimpleLite <c>maps/*.json</c> 的 Sites / Tracks。</summary>
|
||||
public sealed class SimpleLiteMap
|
||||
{
|
||||
public const string NeedStopField = "Mag_NeedStop";
|
||||
|
||||
public string FilePath { get; private set; }
|
||||
public string FileName { get; private set; }
|
||||
public IReadOnlyDictionary<int, SimpleLiteSite> Sites { get; private set; }
|
||||
public IReadOnlyList<SimpleLiteTrack> Tracks { get; private set; }
|
||||
public IReadOnlyDictionary<int, IReadOnlyList<int>> Adjacency { get; private set; }
|
||||
private readonly HashSet<int> _singleOccupancySites = new HashSet<int>();
|
||||
|
||||
public static SimpleLiteMap Load(string path, bool useTagValueAsNode)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||
{
|
||||
throw new FileNotFoundException("找不到 SimpleLite 地图文件", path);
|
||||
}
|
||||
|
||||
var root = JObject.Parse(File.ReadAllText(path));
|
||||
var sites = ParseSites(root["Sites"] as JObject);
|
||||
var tracks = ParseTracks(root["Tracks"] as JObject);
|
||||
var adjacency = BuildAdjacency(sites, tracks);
|
||||
|
||||
var map = new SimpleLiteMap
|
||||
{
|
||||
FilePath = Path.GetFullPath(path),
|
||||
FileName = Path.GetFileName(path),
|
||||
Sites = sites,
|
||||
Tracks = tracks,
|
||||
Adjacency = adjacency,
|
||||
UseTagValueAsNode = useTagValueAsNode
|
||||
};
|
||||
map.ResetSingleOccupancyFromStops();
|
||||
return map;
|
||||
}
|
||||
|
||||
public bool UseTagValueAsNode { get; private set; }
|
||||
|
||||
public bool TryGetSite(int siteId, out SimpleLiteSite site)
|
||||
{
|
||||
return Sites.TryGetValue(siteId, out site);
|
||||
}
|
||||
|
||||
public ushort ResolveNode(int siteId)
|
||||
{
|
||||
if (!Sites.TryGetValue(siteId, out var site))
|
||||
{
|
||||
return (ushort)Math.Clamp(siteId, 0, ushort.MaxValue);
|
||||
}
|
||||
|
||||
if (UseTagValueAsNode && site.TagValue.HasValue && site.TagValue.Value >= 0)
|
||||
{
|
||||
return (ushort)Math.Clamp(site.TagValue.Value, 0, ushort.MaxValue);
|
||||
}
|
||||
|
||||
return (ushort)Math.Clamp(site.Id, 0, ushort.MaxValue);
|
||||
}
|
||||
|
||||
public bool NeedStop(int siteId)
|
||||
{
|
||||
return Sites.TryGetValue(siteId, out var site) && site.NeedStop;
|
||||
}
|
||||
|
||||
/// <summary>停止点或交管点:同时只允许一辆。普通站可叠车。</summary>
|
||||
public bool SingleOccupancy(int siteId)
|
||||
{
|
||||
return _singleOccupancySites.Contains(siteId);
|
||||
}
|
||||
|
||||
public IReadOnlyList<int> ListSingleOccupancySiteIds()
|
||||
{
|
||||
var list = new List<int>(_singleOccupancySites);
|
||||
list.Sort();
|
||||
return list;
|
||||
}
|
||||
|
||||
public void ResetSingleOccupancyFromStops()
|
||||
{
|
||||
_singleOccupancySites.Clear();
|
||||
foreach (var site in Sites.Values)
|
||||
{
|
||||
if (site.NeedStop)
|
||||
{
|
||||
_singleOccupancySites.Add(site.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void AddSingleOccupancySites(IEnumerable<int> siteIds)
|
||||
{
|
||||
if (siteIds == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var id in siteIds)
|
||||
{
|
||||
if (id > 0 && Sites.ContainsKey(id))
|
||||
{
|
||||
_singleOccupancySites.Add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<int> ListNeedStopSiteIds()
|
||||
{
|
||||
var list = new List<int>();
|
||||
foreach (var site in Sites.Values)
|
||||
{
|
||||
if (site.NeedStop)
|
||||
{
|
||||
list.Add(site.Id);
|
||||
}
|
||||
}
|
||||
|
||||
list.Sort();
|
||||
return list;
|
||||
}
|
||||
|
||||
private static Dictionary<int, SimpleLiteSite> ParseSites(JObject sitesNode)
|
||||
{
|
||||
var result = new Dictionary<int, SimpleLiteSite>();
|
||||
if (sitesNode == null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
foreach (var property in sitesNode.Properties())
|
||||
{
|
||||
if (property.Value is not JObject obj)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var id = obj.Value<int?>("id") ?? ParseInt(property.Name);
|
||||
if (id <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var fields = ReadFields(obj["fields"] as JObject);
|
||||
var site = new SimpleLiteSite
|
||||
{
|
||||
Id = id,
|
||||
Name = obj.Value<string>("name") ?? "",
|
||||
X = obj.Value<double?>("x") ?? 0,
|
||||
Y = obj.Value<double?>("y") ?? 0,
|
||||
TagValue = TryReadInt(fields, "TagValue"),
|
||||
NeedStop = IsNeedStop(fields),
|
||||
Fields = fields
|
||||
};
|
||||
result[id] = site;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<SimpleLiteTrack> ParseTracks(JObject tracksNode)
|
||||
{
|
||||
var result = new List<SimpleLiteTrack>();
|
||||
if (tracksNode == null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
foreach (var property in tracksNode.Properties())
|
||||
{
|
||||
if (property.Value is not JObject obj)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var siteA = obj.Value<int?>("siteA") ?? obj.Value<int?>("_siteA") ?? 0;
|
||||
var siteB = obj.Value<int?>("siteB") ?? obj.Value<int?>("_siteB") ?? 0;
|
||||
if (siteA <= 0 || siteB <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Add(new SimpleLiteTrack
|
||||
{
|
||||
Id = obj.Value<int?>("id") ?? ParseInt(property.Name),
|
||||
SiteA = siteA,
|
||||
SiteB = siteB,
|
||||
Direction = obj.Value<int?>("direction") ?? 0
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Dictionary<int, IReadOnlyList<int>> BuildAdjacency(
|
||||
Dictionary<int, SimpleLiteSite> sites,
|
||||
List<SimpleLiteTrack> tracks)
|
||||
{
|
||||
var mutable = new Dictionary<int, List<int>>();
|
||||
foreach (var id in sites.Keys)
|
||||
{
|
||||
mutable[id] = new List<int>();
|
||||
}
|
||||
|
||||
foreach (var track in tracks)
|
||||
{
|
||||
AddEdge(mutable, track.SiteA, track.SiteB, track.Direction);
|
||||
}
|
||||
|
||||
var result = new Dictionary<int, IReadOnlyList<int>>();
|
||||
foreach (var pair in mutable)
|
||||
{
|
||||
result[pair.Key] = pair.Value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void AddEdge(Dictionary<int, List<int>> graph, int from, int to, int direction)
|
||||
{
|
||||
// 0 双向;1 仅 A→B;2 仅 B→A。其它值按双向处理。
|
||||
var aToB = direction != 2;
|
||||
var bToA = direction != 1;
|
||||
if (aToB)
|
||||
{
|
||||
AddUnique(graph, from, to);
|
||||
}
|
||||
|
||||
if (bToA)
|
||||
{
|
||||
AddUnique(graph, to, from);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddUnique(Dictionary<int, List<int>> graph, int from, int to)
|
||||
{
|
||||
if (!graph.TryGetValue(from, out var list))
|
||||
{
|
||||
list = new List<int>();
|
||||
graph[from] = list;
|
||||
}
|
||||
|
||||
if (!list.Contains(to))
|
||||
{
|
||||
list.Add(to);
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ReadFields(JObject fields)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (fields == null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
foreach (var property in fields.Properties())
|
||||
{
|
||||
result[property.Name] = property.Value?.Type == JTokenType.Null
|
||||
? ""
|
||||
: property.Value.ToString();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool IsNeedStop(Dictionary<string, string> fields)
|
||||
{
|
||||
if (!fields.TryGetValue(NeedStopField, out var raw))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
raw = (raw ?? "").Trim();
|
||||
return raw.Equals("true", StringComparison.OrdinalIgnoreCase)
|
||||
|| raw.Equals("1", StringComparison.OrdinalIgnoreCase)
|
||||
|| raw.Equals("yes", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static int? TryReadInt(Dictionary<string, string> fields, string key)
|
||||
{
|
||||
if (!fields.TryGetValue(key, out var raw) || string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)
|
||||
? value
|
||||
: (int?)null;
|
||||
}
|
||||
|
||||
private static int ParseInt(string text)
|
||||
{
|
||||
return int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)
|
||||
? value
|
||||
: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StandardScene.MagCarSimulator
|
||||
{
|
||||
public sealed class MagCarSimTcpServer : IDisposable
|
||||
{
|
||||
private readonly IPAddress _address;
|
||||
private readonly int _port;
|
||||
private readonly Func<byte[], byte[]> _handler;
|
||||
private readonly object _clientsLock = new object();
|
||||
private readonly HashSet<TcpClient> _clients = new HashSet<TcpClient>();
|
||||
private TcpListener _listener;
|
||||
private CancellationTokenSource _cts;
|
||||
private int _clientCount;
|
||||
private bool _running;
|
||||
|
||||
public MagCarSimTcpServer(IPAddress address, int port, Func<byte[], byte[]> handler)
|
||||
{
|
||||
_address = address ?? IPAddress.Any;
|
||||
_port = port;
|
||||
_handler = handler ?? throw new ArgumentNullException(nameof(handler));
|
||||
}
|
||||
|
||||
public bool IsRunning => _running;
|
||||
public int ClientCount => Volatile.Read(ref _clientCount);
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_running)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_cts = new CancellationTokenSource();
|
||||
_listener = new TcpListener(_address, _port);
|
||||
_listener.Server.NoDelay = true;
|
||||
_listener.Start();
|
||||
_running = true;
|
||||
_ = Task.Run(() => AcceptLoop(_cts.Token));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_running = false;
|
||||
try
|
||||
{
|
||||
_cts?.Cancel();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_listener?.Stop();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
List<TcpClient> clients;
|
||||
lock (_clientsLock)
|
||||
{
|
||||
clients = new List<TcpClient>(_clients);
|
||||
_clients.Clear();
|
||||
}
|
||||
|
||||
foreach (var client in clients)
|
||||
{
|
||||
try
|
||||
{
|
||||
client.Close();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
_cts?.Dispose();
|
||||
_cts = null;
|
||||
_listener = null;
|
||||
}
|
||||
|
||||
private async Task AcceptLoop(CancellationToken token)
|
||||
{
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
TcpClient client;
|
||||
try
|
||||
{
|
||||
client = await _listener.AcceptTcpClientAsync(token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (SocketException)
|
||||
{
|
||||
if (token.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
lock (_clientsLock)
|
||||
{
|
||||
_clients.Add(client);
|
||||
}
|
||||
|
||||
_ = Task.Run(() => HandleClient(client, token), token);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleClient(TcpClient client, CancellationToken token)
|
||||
{
|
||||
Interlocked.Increment(ref _clientCount);
|
||||
try
|
||||
{
|
||||
client.NoDelay = true;
|
||||
client.ReceiveTimeout = 0;
|
||||
using (var stream = client.GetStream())
|
||||
{
|
||||
var buffer = new byte[MagCarSimProtocol.FrameLength];
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
if (!ReadExact(stream, buffer, token))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
byte[] response;
|
||||
try
|
||||
{
|
||||
response = _handler(buffer);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MagCarSimLog.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] TCP 处理异常: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (response == null || response.Length != MagCarSimProtocol.FrameLength)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
stream.Write(response, 0, response.Length);
|
||||
stream.Flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (IsBenign(ex))
|
||||
{
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MagCarSimLog.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] TCP 连接异常: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (_clientsLock)
|
||||
{
|
||||
_clients.Remove(client);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
client.Dispose();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
Interlocked.Decrement(ref _clientCount);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ReadExact(NetworkStream stream, byte[] buffer, CancellationToken token)
|
||||
{
|
||||
var offset = 0;
|
||||
while (offset < buffer.Length)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
int read;
|
||||
try
|
||||
{
|
||||
read = stream.Read(buffer, offset, buffer.Length - offset);
|
||||
}
|
||||
catch (Exception ex) when (IsBenign(ex))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (read == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
offset += read;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsBenign(Exception ex)
|
||||
{
|
||||
for (var current = ex; current != null; current = current.InnerException)
|
||||
{
|
||||
if (current is ObjectDisposedException || current is OperationCanceledException)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (current is SocketException socketEx)
|
||||
{
|
||||
switch (socketEx.SocketErrorCode)
|
||||
{
|
||||
case SocketError.OperationAborted:
|
||||
case SocketError.Interrupted:
|
||||
case SocketError.ConnectionAborted:
|
||||
case SocketError.ConnectionReset:
|
||||
case SocketError.Shutdown:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace StandardScene.MagCarSimulator
|
||||
{
|
||||
internal static class Program
|
||||
{
|
||||
[STAThread]
|
||||
private static void Main()
|
||||
{
|
||||
ApplicationConfiguration.Initialize();
|
||||
Application.Run(new MainForm());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
using System;
|
||||
|
||||
namespace StandardScene.MagCarSimulator
|
||||
{
|
||||
/// <summary>
|
||||
/// FASS 1.0 32 字节帧,字段布局与 <c>MagCar.MagCarReport</c> 对齐。
|
||||
/// </summary>
|
||||
public static class MagCarSimProtocol
|
||||
{
|
||||
public const int FrameLength = 32;
|
||||
public const byte Begin = 0xBB;
|
||||
public const byte End = 0xEE;
|
||||
|
||||
public const byte CmdQueryState = 0x00;
|
||||
public const byte CmdStart = 0x01;
|
||||
public const byte CmdStop = 0x02;
|
||||
public const byte CmdSpeed = 0x03;
|
||||
public const byte CmdAngle = 0x04;
|
||||
public const byte CmdLegacyNode = 0x05;
|
||||
public const byte CmdTask = 0xA1;
|
||||
|
||||
public const byte StateIdle = 0;
|
||||
public const byte StateRunning = 1;
|
||||
public const byte StateStopping = 2;
|
||||
public const byte StateCharging = 3;
|
||||
|
||||
public static bool TryParse(byte[] bytes, out MagCarSimRequest request)
|
||||
{
|
||||
request = null;
|
||||
if (bytes == null || bytes.Length < FrameLength)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (bytes[0] != Begin || bytes[FrameLength - 1] != End)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
request = new MagCarSimRequest
|
||||
{
|
||||
Command = bytes[1],
|
||||
VehicleCode = ReadUInt16(bytes, 2),
|
||||
Data0 = ReadUInt16(bytes, 4),
|
||||
Data1 = ReadUInt16(bytes, 6)
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
public static byte[] BuildStatus(
|
||||
byte command,
|
||||
ushort vehicleCode,
|
||||
ushort node,
|
||||
byte state,
|
||||
byte charge,
|
||||
float current,
|
||||
float voltage,
|
||||
byte speed,
|
||||
ushort angle,
|
||||
byte task,
|
||||
byte lift,
|
||||
byte roll)
|
||||
{
|
||||
var bytes = new byte[FrameLength];
|
||||
bytes[0] = Begin;
|
||||
bytes[1] = command;
|
||||
WriteUInt16(bytes, 2, vehicleCode);
|
||||
WriteUInt16(bytes, 4, node);
|
||||
WriteUInt16(bytes, 25, angle);
|
||||
WriteSingle(bytes, 16, current);
|
||||
WriteSingle(bytes, 20, voltage);
|
||||
bytes[14] = state;
|
||||
bytes[15] = charge;
|
||||
bytes[24] = speed;
|
||||
bytes[27] = task;
|
||||
bytes[28] = lift;
|
||||
bytes[29] = roll;
|
||||
bytes[31] = End;
|
||||
return bytes;
|
||||
}
|
||||
|
||||
public static string CommandName(byte command)
|
||||
{
|
||||
switch (command)
|
||||
{
|
||||
case CmdQueryState: return "Query(0x00)";
|
||||
case CmdStart: return "Start(0x01)";
|
||||
case CmdStop: return "Stop(0x02)";
|
||||
case CmdSpeed: return "Speed(0x03)";
|
||||
case CmdAngle: return "Angle(0x04)";
|
||||
case CmdLegacyNode: return "LegacyNode(0x05)";
|
||||
case CmdTask: return "Task(0xA1)";
|
||||
default: return $"Unknown(0x{command:X2})";
|
||||
}
|
||||
}
|
||||
|
||||
public static string StateText(byte state)
|
||||
{
|
||||
switch (state)
|
||||
{
|
||||
case StateIdle: return "未准备";
|
||||
case StateRunning: return "运行中";
|
||||
case StateStopping: return "停止中";
|
||||
case StateCharging: return "充电中";
|
||||
default: return $"未知({state})";
|
||||
}
|
||||
}
|
||||
|
||||
public static string ToHex(byte[] bytes)
|
||||
{
|
||||
return bytes == null ? string.Empty : BitConverter.ToString(bytes).Replace("-", " ");
|
||||
}
|
||||
|
||||
public static ushort ReadUInt16(byte[] bytes, int offset)
|
||||
{
|
||||
return (ushort)((bytes[offset] << 8) | bytes[offset + 1]);
|
||||
}
|
||||
|
||||
public static void WriteUInt16(byte[] bytes, int offset, ushort value)
|
||||
{
|
||||
bytes[offset] = (byte)(value >> 8);
|
||||
bytes[offset + 1] = (byte)(value & 0xFF);
|
||||
}
|
||||
|
||||
public static void WriteSingle(byte[] bytes, int offset, float value)
|
||||
{
|
||||
var raw = BitConverter.GetBytes(value);
|
||||
if (BitConverter.IsLittleEndian)
|
||||
{
|
||||
Array.Reverse(raw);
|
||||
}
|
||||
|
||||
Buffer.BlockCopy(raw, 0, bytes, offset, 4);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class MagCarSimRequest
|
||||
{
|
||||
public byte Command { get; set; }
|
||||
public ushort VehicleCode { get; set; }
|
||||
public ushort Data0 { get; set; }
|
||||
public ushort Data1 { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace StandardScene.MagCarSimulator
|
||||
{
|
||||
public sealed class MagCarSimVehicleConfig
|
||||
{
|
||||
public string Name { get; set; } = "AGV";
|
||||
public ushort VehicleCode { get; set; } = 1;
|
||||
public int ListenPort { get; set; } = 5001;
|
||||
public int StartSiteId { get; set; } = 1;
|
||||
public int EndSiteId { get; set; } = 2;
|
||||
public int IntervalMs { get; set; } = 2000;
|
||||
|
||||
/// <summary>显式环线站点序列(不含回到起点的重复点)。有值时优先于起点/终点寻路。</summary>
|
||||
public List<int> LoopSiteIds { get; set; }
|
||||
}
|
||||
|
||||
public sealed class MagCarSimConfig
|
||||
{
|
||||
public string MapPath { get; set; } = "";
|
||||
public bool UseTagValueAsNode { get; set; }
|
||||
public string ListenAddress { get; set; } = "0.0.0.0";
|
||||
public bool LogRawFrames { get; set; }
|
||||
public bool LogQueries { get; set; }
|
||||
|
||||
/// <summary>文件日志根目录(相对 exe 或绝对路径),默认 log。</summary>
|
||||
public string LogDirectory { get; set; } = "log";
|
||||
|
||||
/// <summary>是否写入 log 目录下的 magcar-sim_yyyyMMdd.log。</summary>
|
||||
public bool EnableFileLog { get; set; } = true;
|
||||
|
||||
public int DefaultIntervalMs { get; set; } = 2000;
|
||||
|
||||
/// <summary>同一环线后车相对前车的发车间隔。≤0 时用该车 IntervalMs。</summary>
|
||||
public int LaunchStaggerMs { get; set; }
|
||||
|
||||
/// <summary>磁条交管表。空则按地图目录推断 SimpleLite/config/Signal/mag-control-areas.json。</summary>
|
||||
public string ControlAreasPath { get; set; }
|
||||
public List<MagCarSimVehicleConfig> Vehicles { get; set; } = new List<MagCarSimVehicleConfig>();
|
||||
|
||||
public static string SettingsPath => Path.Combine(AppContext.BaseDirectory, "appsettings.json");
|
||||
|
||||
public static MagCarSimConfig Load(string path = null)
|
||||
{
|
||||
path ??= SettingsPath;
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return CreateDefault();
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(path);
|
||||
var config = JsonConvert.DeserializeObject<MagCarSimConfig>(json) ?? CreateDefault();
|
||||
if (config.Vehicles == null || config.Vehicles.Count == 0)
|
||||
{
|
||||
config.Vehicles = CreateDefault().Vehicles;
|
||||
}
|
||||
|
||||
if (config.DefaultIntervalMs <= 0)
|
||||
{
|
||||
config.DefaultIntervalMs = 2000;
|
||||
}
|
||||
|
||||
foreach (var vehicle in config.Vehicles)
|
||||
{
|
||||
if (vehicle.IntervalMs <= 0)
|
||||
{
|
||||
vehicle.IntervalMs = config.DefaultIntervalMs;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(vehicle.Name))
|
||||
{
|
||||
vehicle.Name = $"AGV-{vehicle.VehicleCode}";
|
||||
}
|
||||
|
||||
if (vehicle.LoopSiteIds != null && vehicle.LoopSiteIds.Count > 0)
|
||||
{
|
||||
vehicle.LoopSiteIds = MapPathFinder.NormalizeLoop(vehicle.LoopSiteIds);
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
public void Save(string path = null)
|
||||
{
|
||||
path ??= SettingsPath;
|
||||
var json = JsonConvert.SerializeObject(this, Formatting.Indented);
|
||||
File.WriteAllText(path, json);
|
||||
}
|
||||
|
||||
public static MagCarSimConfig CreateDefault()
|
||||
{
|
||||
return new MagCarSimConfig
|
||||
{
|
||||
DefaultIntervalMs = 2000,
|
||||
Vehicles =
|
||||
{
|
||||
new MagCarSimVehicleConfig
|
||||
{
|
||||
Name = "AGV-1",
|
||||
VehicleCode = 1,
|
||||
ListenPort = 5001,
|
||||
StartSiteId = 1,
|
||||
EndSiteId = 6,
|
||||
IntervalMs = 2000
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
|
||||
namespace StandardScene.MagCarSimulator
|
||||
{
|
||||
public sealed class MagCarSimHost : IDisposable
|
||||
{
|
||||
private readonly object _syncRoot = new object();
|
||||
private readonly List<MagCarSimVehicle> _vehicles = new List<MagCarSimVehicle>();
|
||||
private Timer _timer;
|
||||
private SimpleLiteMap _map;
|
||||
private int _tickBusy;
|
||||
|
||||
public MagCarSimHost(MagCarSimConfig config)
|
||||
{
|
||||
Config = config ?? throw new ArgumentNullException(nameof(config));
|
||||
}
|
||||
|
||||
public MagCarSimConfig Config { get; }
|
||||
public SimpleLiteMap Map => _map;
|
||||
public bool IsListening { get; private set; }
|
||||
public bool IsLooping { get; private set; }
|
||||
|
||||
public IReadOnlyList<MagCarSimVehicle> Vehicles
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
return _vehicles.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public SimpleLiteMap LoadMap(string path)
|
||||
{
|
||||
var map = SimpleLiteMap.Load(path, Config.UseTagValueAsNode);
|
||||
var controlPath = MagControlAreaFile.GuessPath(map.FilePath, Config.ControlAreasPath);
|
||||
var controlSites = MagControlAreaFile.LoadSiteIds(controlPath);
|
||||
map.AddSingleOccupancySites(controlSites);
|
||||
lock (_syncRoot)
|
||||
{
|
||||
_map = map;
|
||||
Config.MapPath = map.FilePath;
|
||||
if (!string.IsNullOrWhiteSpace(controlPath))
|
||||
{
|
||||
Config.ControlAreasPath = controlPath;
|
||||
}
|
||||
|
||||
foreach (var vehicle in _vehicles)
|
||||
{
|
||||
vehicle.AttachMap(map);
|
||||
}
|
||||
}
|
||||
|
||||
var exclusive = map.ListSingleOccupancySiteIds();
|
||||
MagCarSimLog.WriteLine(
|
||||
$"[{DateTime.Now:HH:mm:ss.fff}] 已加载地图 {map.FileName},站点 {map.Sites.Count},路径 {map.Tracks.Count},停止/交管点 {(exclusive.Count == 0 ? "无" : string.Join(",", exclusive))}");
|
||||
return map;
|
||||
}
|
||||
|
||||
public void RebuildVehicles(IEnumerable<MagCarSimVehicleConfig> configs)
|
||||
{
|
||||
if (IsListening)
|
||||
{
|
||||
throw new InvalidOperationException("请先停止模拟再改车辆列表");
|
||||
}
|
||||
|
||||
lock (_syncRoot)
|
||||
{
|
||||
DisposeVehiclesUnlocked();
|
||||
foreach (var config in configs ?? Array.Empty<MagCarSimVehicleConfig>())
|
||||
{
|
||||
if (config == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var vehicle = new MagCarSimVehicle(Clone(config), Config);
|
||||
if (_map != null)
|
||||
{
|
||||
vehicle.AttachMap(_map);
|
||||
}
|
||||
|
||||
_vehicles.Add(vehicle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void StartListen()
|
||||
{
|
||||
List<MagCarSimVehicle> vehicles;
|
||||
SimpleLiteMap map;
|
||||
lock (_syncRoot)
|
||||
{
|
||||
if (IsListening)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_map == null)
|
||||
{
|
||||
throw new InvalidOperationException("请先加载 SimpleLite 地图");
|
||||
}
|
||||
|
||||
if (_vehicles.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("没有可启动的车辆");
|
||||
}
|
||||
|
||||
EnsureUniquePortsUnlocked();
|
||||
vehicles = _vehicles.ToList();
|
||||
map = _map;
|
||||
EnsureTimerUnlocked();
|
||||
IsListening = true;
|
||||
}
|
||||
|
||||
foreach (var vehicle in vehicles)
|
||||
{
|
||||
vehicle.AttachMap(map);
|
||||
vehicle.StartListen();
|
||||
}
|
||||
}
|
||||
|
||||
public void StartLoops()
|
||||
{
|
||||
if (!IsListening)
|
||||
{
|
||||
StartListen();
|
||||
}
|
||||
|
||||
List<MagCarSimVehicle> vehicles;
|
||||
lock (_syncRoot)
|
||||
{
|
||||
vehicles = _vehicles.ToList();
|
||||
IsLooping = true;
|
||||
}
|
||||
|
||||
var now = DateTime.Now;
|
||||
foreach (var group in vehicles.GroupBy(RingKey).OrderBy(g => g.Key))
|
||||
{
|
||||
var members = group.OrderBy(v => v.VehicleCode).ToList();
|
||||
var dispatchAt = now;
|
||||
foreach (var vehicle in members)
|
||||
{
|
||||
vehicle.StartLoop(dispatchAt);
|
||||
dispatchAt = dispatchAt.AddMilliseconds(StaggerMs(vehicle));
|
||||
}
|
||||
|
||||
MagCarSimLog.WriteLine(
|
||||
$"[{DateTime.Now:HH:mm:ss.fff}] 同环依次发车 {members[0].Name} 等 {members.Count} 台,间隔 {StaggerMs(members[0])}ms");
|
||||
}
|
||||
}
|
||||
|
||||
public void StopAll()
|
||||
{
|
||||
Timer timer;
|
||||
List<MagCarSimVehicle> vehicles;
|
||||
lock (_syncRoot)
|
||||
{
|
||||
timer = _timer;
|
||||
_timer = null;
|
||||
IsListening = false;
|
||||
IsLooping = false;
|
||||
vehicles = _vehicles.ToList();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
timer?.Dispose();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
foreach (var vehicle in vehicles)
|
||||
{
|
||||
vehicle.StopLoop();
|
||||
vehicle.StopListen();
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryRelease(ushort vehicleCode)
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
var vehicle = _vehicles.FirstOrDefault(v => v.VehicleCode == vehicleCode);
|
||||
if (vehicle == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
vehicle.Release();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public List<MagCarSimVehicleSnapshot> Snapshots()
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
return _vehicles.Select(v => v.Snapshot()).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
StopAll();
|
||||
lock (_syncRoot)
|
||||
{
|
||||
DisposeVehiclesUnlocked();
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureTimerUnlocked()
|
||||
{
|
||||
_timer ??= new Timer(_ => OnTick(), null, 50, 50);
|
||||
}
|
||||
|
||||
private void OnTick()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _tickBusy, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
List<MagCarSimVehicle> vehicles;
|
||||
SimpleLiteMap map;
|
||||
lock (_syncRoot)
|
||||
{
|
||||
vehicles = _vehicles.ToList();
|
||||
map = _map;
|
||||
}
|
||||
|
||||
var occupancy = SnapshotExclusiveOccupancy(map, vehicles);
|
||||
var now = DateTime.Now;
|
||||
foreach (var vehicle in vehicles)
|
||||
{
|
||||
vehicle.Tick(now, occupancy);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Exchange(ref _tickBusy, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureUniquePortsUnlocked()
|
||||
{
|
||||
var seen = new HashSet<int>();
|
||||
foreach (var vehicle in _vehicles)
|
||||
{
|
||||
if (!seen.Add(vehicle.ListenPort))
|
||||
{
|
||||
throw new InvalidOperationException($"端口 {vehicle.ListenPort} 被多辆车占用");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<int, ushort> SnapshotExclusiveOccupancy(
|
||||
SimpleLiteMap map,
|
||||
IEnumerable<MagCarSimVehicle> vehicles)
|
||||
{
|
||||
var occupancy = new Dictionary<int, ushort>();
|
||||
if (map == null)
|
||||
{
|
||||
return occupancy;
|
||||
}
|
||||
|
||||
foreach (var vehicle in vehicles)
|
||||
{
|
||||
if (vehicle.CurrentSiteId <= 0 ||
|
||||
!map.SingleOccupancy(vehicle.CurrentSiteId) ||
|
||||
occupancy.ContainsKey(vehicle.CurrentSiteId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
occupancy[vehicle.CurrentSiteId] = vehicle.VehicleCode;
|
||||
}
|
||||
|
||||
return occupancy;
|
||||
}
|
||||
|
||||
private int StaggerMs(MagCarSimVehicle vehicle)
|
||||
{
|
||||
if (Config.LaunchStaggerMs > 0)
|
||||
{
|
||||
return Math.Max(200, Config.LaunchStaggerMs);
|
||||
}
|
||||
|
||||
var interval = vehicle.Config.IntervalMs > 0 ? vehicle.Config.IntervalMs : Config.DefaultIntervalMs;
|
||||
return Math.Max(200, interval);
|
||||
}
|
||||
|
||||
private static string RingKey(MagCarSimVehicle vehicle)
|
||||
{
|
||||
var ids = vehicle.Config.LoopSiteIds;
|
||||
if (ids != null && ids.Count > 0)
|
||||
{
|
||||
return string.Join("-", ids);
|
||||
}
|
||||
|
||||
return $"s{vehicle.Config.StartSiteId}-e{vehicle.Config.EndSiteId}";
|
||||
}
|
||||
|
||||
private void DisposeVehiclesUnlocked()
|
||||
{
|
||||
foreach (var vehicle in _vehicles)
|
||||
{
|
||||
vehicle.Dispose();
|
||||
}
|
||||
|
||||
_vehicles.Clear();
|
||||
}
|
||||
|
||||
private static MagCarSimVehicleConfig Clone(MagCarSimVehicleConfig source)
|
||||
{
|
||||
return new MagCarSimVehicleConfig
|
||||
{
|
||||
Name = source.Name,
|
||||
VehicleCode = source.VehicleCode,
|
||||
ListenPort = source.ListenPort,
|
||||
StartSiteId = source.StartSiteId,
|
||||
EndSiteId = source.EndSiteId,
|
||||
IntervalMs = source.IntervalMs,
|
||||
LoopSiteIds = source.LoopSiteIds == null ? null : new List<int>(source.LoopSiteIds)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
|
||||
namespace StandardScene.MagCarSimulator
|
||||
{
|
||||
public sealed class MagCarSimVehicleSnapshot
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public ushort VehicleCode { get; set; }
|
||||
public int ListenPort { get; set; }
|
||||
public int StartSiteId { get; set; }
|
||||
public int EndSiteId { get; set; }
|
||||
public int IntervalMs { get; set; }
|
||||
public int CurrentSiteId { get; set; }
|
||||
public ushort Node { get; set; }
|
||||
public byte State { get; set; }
|
||||
public string StateText { get; set; }
|
||||
public bool WaitingRelease { get; set; }
|
||||
public bool HoldingForStop { get; set; }
|
||||
public bool Looping { get; set; }
|
||||
public bool Listening { get; set; }
|
||||
public int ClientCount { get; set; }
|
||||
public string PathText { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 单车循环:普通站可叠车;停止点与交管点同时只允许一辆,后车停在上一站并保持运行中;仅 Mag_NeedStop 停车等 0x01。
|
||||
/// </summary>
|
||||
public sealed class MagCarSimVehicle : IDisposable
|
||||
{
|
||||
private readonly object _syncRoot = new object();
|
||||
private readonly MagCarSimConfig _hostConfig;
|
||||
private MagCarSimTcpServer _server;
|
||||
private SimpleLiteMap _map;
|
||||
private List<int> _loopSiteIds = new List<int>();
|
||||
private int _index;
|
||||
private DateTime _nextHopAt = DateTime.MaxValue;
|
||||
private DateTime _dispatchAt = DateTime.MinValue;
|
||||
private DateTime _lastHoldLogAt = DateTime.MinValue;
|
||||
private bool _looping;
|
||||
private bool _dispatched;
|
||||
private bool _waitingRelease;
|
||||
private bool _pausedByStopCommand;
|
||||
private bool _holdingForStop;
|
||||
|
||||
public MagCarSimVehicle(MagCarSimVehicleConfig config, MagCarSimConfig hostConfig)
|
||||
{
|
||||
Config = config ?? throw new ArgumentNullException(nameof(config));
|
||||
_hostConfig = hostConfig ?? throw new ArgumentNullException(nameof(hostConfig));
|
||||
Name = string.IsNullOrWhiteSpace(config.Name) ? $"AGV-{config.VehicleCode}" : config.Name;
|
||||
Charge = 100;
|
||||
Voltage = 24f;
|
||||
Speed = 50;
|
||||
}
|
||||
|
||||
public MagCarSimVehicleConfig Config { get; }
|
||||
public string Name { get; }
|
||||
public ushort VehicleCode => Config.VehicleCode;
|
||||
public int ListenPort => Config.ListenPort;
|
||||
public int CurrentSiteId { get; private set; }
|
||||
public ushort Node { get; private set; }
|
||||
public byte State { get; private set; }
|
||||
public byte Charge { get; private set; }
|
||||
public float Current { get; private set; }
|
||||
public float Voltage { get; private set; }
|
||||
public byte Speed { get; private set; }
|
||||
public ushort Angle { get; private set; }
|
||||
public byte Task { get; private set; }
|
||||
public byte Lift { get; private set; }
|
||||
public byte Roll { get; private set; }
|
||||
public bool WaitingRelease => _waitingRelease;
|
||||
public bool HoldingForStop => _holdingForStop;
|
||||
public bool Looping => _looping;
|
||||
|
||||
public bool Listening => _server != null && _server.IsRunning;
|
||||
|
||||
public void AttachMap(SimpleLiteMap map)
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
_map = map;
|
||||
if (!_looping)
|
||||
{
|
||||
PlaceAtStartUnlocked();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void StartListen()
|
||||
{
|
||||
MagCarSimTcpServer server;
|
||||
lock (_syncRoot)
|
||||
{
|
||||
if (_server != null && _server.IsRunning)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_server?.Dispose();
|
||||
var address = string.IsNullOrWhiteSpace(_hostConfig.ListenAddress)
|
||||
? "0.0.0.0"
|
||||
: _hostConfig.ListenAddress;
|
||||
server = new MagCarSimTcpServer(IPAddress.Parse(address), Config.ListenPort, HandleFrame);
|
||||
_server = server;
|
||||
PlaceAtStartUnlocked();
|
||||
Log($"监听 {address}:{Config.ListenPort},当前站={CurrentSiteId} node={Node} {MagCarSimProtocol.StateText(State)}");
|
||||
}
|
||||
|
||||
server.Start();
|
||||
}
|
||||
|
||||
public void StopListen()
|
||||
{
|
||||
MagCarSimTcpServer server;
|
||||
lock (_syncRoot)
|
||||
{
|
||||
server = _server;
|
||||
_server = null;
|
||||
}
|
||||
|
||||
server?.Dispose();
|
||||
}
|
||||
|
||||
public void StartLoop(DateTime dispatchAt)
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
if (_map == null)
|
||||
{
|
||||
throw new InvalidOperationException($"{Name} 尚未加载地图");
|
||||
}
|
||||
|
||||
_loopSiteIds = MapPathFinder.ResolveLoop(_map, Config);
|
||||
_index = 0;
|
||||
if (Config.StartSiteId > 0)
|
||||
{
|
||||
var found = _loopSiteIds.IndexOf(Config.StartSiteId);
|
||||
if (found >= 0)
|
||||
{
|
||||
_index = found;
|
||||
}
|
||||
}
|
||||
|
||||
_looping = true;
|
||||
_dispatched = false;
|
||||
_holdingForStop = false;
|
||||
_pausedByStopCommand = false;
|
||||
_waitingRelease = false;
|
||||
_dispatchAt = dispatchAt;
|
||||
PlaceAtStartUnlocked();
|
||||
// 待命阶段若起点是停止点,先报运行中,避免交管误放行并占住 pending,导致真发车后长时间不放。
|
||||
if (_map != null && _map.NeedStop(CurrentSiteId))
|
||||
{
|
||||
State = MagCarSimProtocol.StateRunning;
|
||||
_waitingRelease = false;
|
||||
}
|
||||
|
||||
var delayMs = Math.Max(0, (int)(dispatchAt - DateTime.Now).TotalMilliseconds);
|
||||
Log($"待命发车,环线 {MapPathFinder.FormatPath(_loopSiteIds, 16)},间隔 {IntervalOrDefault()}ms" +
|
||||
(delayMs > 50 ? $",{delayMs}ms 后启动" : ",立即启动"));
|
||||
}
|
||||
}
|
||||
|
||||
public void StopLoop()
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
_looping = false;
|
||||
_dispatched = false;
|
||||
_waitingRelease = false;
|
||||
_holdingForStop = false;
|
||||
_pausedByStopCommand = false;
|
||||
_nextHopAt = DateTime.MaxValue;
|
||||
State = MagCarSimProtocol.StateStopping;
|
||||
Log("已停止循环");
|
||||
}
|
||||
}
|
||||
|
||||
public void Release()
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
ApplyStartUnlocked("界面放行");
|
||||
}
|
||||
}
|
||||
|
||||
public void Tick(DateTime now, Dictionary<int, ushort> occupancy)
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
if (!_looping || _pausedByStopCommand)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_dispatched)
|
||||
{
|
||||
TryDispatchUnlocked(now, occupancy);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_waitingRelease)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (State != MagCarSimProtocol.StateRunning)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (now < _nextHopAt)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AdvanceUnlocked(now, occupancy);
|
||||
}
|
||||
}
|
||||
|
||||
public MagCarSimVehicleSnapshot Snapshot()
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
return new MagCarSimVehicleSnapshot
|
||||
{
|
||||
Name = Name,
|
||||
VehicleCode = VehicleCode,
|
||||
ListenPort = ListenPort,
|
||||
StartSiteId = Config.StartSiteId,
|
||||
EndSiteId = Config.EndSiteId,
|
||||
IntervalMs = Config.IntervalMs,
|
||||
CurrentSiteId = CurrentSiteId,
|
||||
Node = Node,
|
||||
State = State,
|
||||
StateText = MagCarSimProtocol.StateText(State),
|
||||
WaitingRelease = _waitingRelease,
|
||||
HoldingForStop = _holdingForStop,
|
||||
Looping = _looping,
|
||||
Listening = Listening,
|
||||
ClientCount = _server?.ClientCount ?? 0,
|
||||
PathText = MapPathFinder.FormatPath(_loopSiteIds, 12)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
_looping = false;
|
||||
}
|
||||
|
||||
StopListen();
|
||||
}
|
||||
|
||||
private byte[] HandleFrame(byte[] request)
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
if (!MagCarSimProtocol.TryParse(request, out var parsed))
|
||||
{
|
||||
Log("收到非法帧");
|
||||
return BuildStatusUnlocked(MagCarSimProtocol.CmdQueryState);
|
||||
}
|
||||
|
||||
if (parsed.VehicleCode != 0 && parsed.VehicleCode != VehicleCode)
|
||||
{
|
||||
Log($"帧车号 {parsed.VehicleCode} 与本车 {VehicleCode} 不一致,仍按本车应答");
|
||||
}
|
||||
|
||||
if (_hostConfig.LogRawFrames)
|
||||
{
|
||||
Log($"RX {MagCarSimProtocol.CommandName(parsed.Command)} {MagCarSimProtocol.ToHex(request)}");
|
||||
}
|
||||
else if (parsed.Command != MagCarSimProtocol.CmdQueryState || _hostConfig.LogQueries)
|
||||
{
|
||||
Log($"RX {MagCarSimProtocol.CommandName(parsed.Command)}");
|
||||
}
|
||||
|
||||
switch (parsed.Command)
|
||||
{
|
||||
case MagCarSimProtocol.CmdStart:
|
||||
ApplyStartUnlocked("0x01");
|
||||
break;
|
||||
case MagCarSimProtocol.CmdStop:
|
||||
_pausedByStopCommand = true;
|
||||
State = MagCarSimProtocol.StateStopping;
|
||||
Log("收到停止,暂停循环");
|
||||
break;
|
||||
case MagCarSimProtocol.CmdSpeed:
|
||||
Speed = (byte)Math.Clamp((int)parsed.Data0 >> 8, 0, 100);
|
||||
break;
|
||||
case MagCarSimProtocol.CmdAngle:
|
||||
Angle = parsed.Data0;
|
||||
break;
|
||||
case MagCarSimProtocol.CmdLegacyNode:
|
||||
case MagCarSimProtocol.CmdTask:
|
||||
Log($"忽略路径命令 {MagCarSimProtocol.CommandName(parsed.Command)}(本模拟器按 WinForm 循环跑)");
|
||||
break;
|
||||
}
|
||||
|
||||
return BuildStatusUnlocked(parsed.Command);
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyStartUnlocked(string source)
|
||||
{
|
||||
if (!_dispatched)
|
||||
{
|
||||
Log($"待命中忽略放行({source})");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_waitingRelease)
|
||||
{
|
||||
_waitingRelease = false;
|
||||
_pausedByStopCommand = false;
|
||||
State = MagCarSimProtocol.StateRunning;
|
||||
_nextHopAt = DateTime.Now.AddMilliseconds(IntervalOrDefault());
|
||||
Log($"放行成功({source}),站点 {CurrentSiteId} 改为运行中,{IntervalOrDefault()}ms 后前往下一站");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_pausedByStopCommand && _looping)
|
||||
{
|
||||
_pausedByStopCommand = false;
|
||||
State = MagCarSimProtocol.StateRunning;
|
||||
_nextHopAt = DateTime.Now.AddMilliseconds(IntervalOrDefault());
|
||||
Log($"从暂停恢复({source})");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_looping && State != MagCarSimProtocol.StateRunning)
|
||||
{
|
||||
State = MagCarSimProtocol.StateRunning;
|
||||
_nextHopAt = DateTime.Now.AddMilliseconds(IntervalOrDefault());
|
||||
Log($"启动运行({source})");
|
||||
}
|
||||
}
|
||||
|
||||
private void PlaceAtStartUnlocked()
|
||||
{
|
||||
CurrentSiteId = Config.StartSiteId;
|
||||
Node = _map == null ? (ushort)Math.Clamp(Config.StartSiteId, 0, ushort.MaxValue) : _map.ResolveNode(Config.StartSiteId);
|
||||
State = MagCarSimProtocol.StateStopping;
|
||||
_waitingRelease = false;
|
||||
_pausedByStopCommand = false;
|
||||
}
|
||||
|
||||
private void ArriveUnlocked(int siteId, bool isStart)
|
||||
{
|
||||
CurrentSiteId = siteId;
|
||||
Node = _map.ResolveNode(siteId);
|
||||
var needStop = _map.NeedStop(siteId);
|
||||
if (needStop)
|
||||
{
|
||||
State = MagCarSimProtocol.StateStopping;
|
||||
_waitingRelease = true;
|
||||
_nextHopAt = DateTime.MaxValue;
|
||||
Log($"{(isStart ? "起点" : "到站")} {siteId} node={Node} Mag_NeedStop=true → 停止中,等待放行");
|
||||
return;
|
||||
}
|
||||
|
||||
State = MagCarSimProtocol.StateRunning;
|
||||
_waitingRelease = false;
|
||||
_nextHopAt = DateTime.Now.AddMilliseconds(IntervalOrDefault());
|
||||
if (isStart)
|
||||
{
|
||||
Log($"起点 {siteId} node={Node} 过站,{IntervalOrDefault()}ms 后下一地标");
|
||||
}
|
||||
}
|
||||
|
||||
private void TryDispatchUnlocked(DateTime now, Dictionary<int, ushort> occupancy)
|
||||
{
|
||||
if (now < _dispatchAt)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_loopSiteIds.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var startSite = _loopSiteIds[_index];
|
||||
if (IsOccupiedUnlocked(startSite, occupancy))
|
||||
{
|
||||
HoldForOccupiedUnlocked(now, startSite, "发车");
|
||||
return;
|
||||
}
|
||||
|
||||
OccupyUnlocked(occupancy, startSite);
|
||||
_dispatched = true;
|
||||
_holdingForStop = false;
|
||||
ArriveUnlocked(startSite, isStart: true);
|
||||
}
|
||||
|
||||
private void AdvanceUnlocked(DateTime now, Dictionary<int, ushort> occupancy)
|
||||
{
|
||||
if (_loopSiteIds.Count < 2)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var nextIndex = (_index + 1) % _loopSiteIds.Count;
|
||||
var nextSite = _loopSiteIds[nextIndex];
|
||||
if (IsOccupiedUnlocked(nextSite, occupancy))
|
||||
{
|
||||
HoldForOccupiedUnlocked(now, nextSite, "进站");
|
||||
return;
|
||||
}
|
||||
|
||||
MoveOccupancyUnlocked(occupancy, CurrentSiteId, nextSite);
|
||||
_holdingForStop = false;
|
||||
_index = nextIndex;
|
||||
ArriveUnlocked(nextSite, isStart: false);
|
||||
}
|
||||
|
||||
private bool IsOccupiedUnlocked(int siteId, Dictionary<int, ushort> occupancy)
|
||||
{
|
||||
if (_map == null || occupancy == null || !_map.SingleOccupancy(siteId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return occupancy.TryGetValue(siteId, out var occupier) && occupier != VehicleCode;
|
||||
}
|
||||
|
||||
private void OccupyUnlocked(Dictionary<int, ushort> occupancy, int siteId)
|
||||
{
|
||||
if (occupancy == null || siteId <= 0 || _map == null || !_map.SingleOccupancy(siteId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
occupancy[siteId] = VehicleCode;
|
||||
}
|
||||
|
||||
private void MoveOccupancyUnlocked(Dictionary<int, ushort> occupancy, int fromSiteId, int toSiteId)
|
||||
{
|
||||
if (occupancy == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (fromSiteId > 0 &&
|
||||
occupancy.TryGetValue(fromSiteId, out var occupier) &&
|
||||
occupier == VehicleCode)
|
||||
{
|
||||
occupancy.Remove(fromSiteId);
|
||||
}
|
||||
|
||||
OccupyUnlocked(occupancy, toSiteId);
|
||||
}
|
||||
|
||||
private void HoldForOccupiedUnlocked(DateTime now, int occupiedSiteId, string action)
|
||||
{
|
||||
_holdingForStop = true;
|
||||
State = MagCarSimProtocol.StateRunning;
|
||||
_waitingRelease = false;
|
||||
_nextHopAt = now.AddMilliseconds(200);
|
||||
if ((now - _lastHoldLogAt).TotalMilliseconds >= 5000)
|
||||
{
|
||||
_lastHoldLogAt = now;
|
||||
Log($"停止/交管点 {occupiedSiteId} 已有车,{action}受阻,保持站点 {CurrentSiteId} 运行中");
|
||||
}
|
||||
}
|
||||
|
||||
private int IntervalOrDefault()
|
||||
{
|
||||
var value = Config.IntervalMs > 0 ? Config.IntervalMs : _hostConfig.DefaultIntervalMs;
|
||||
return Math.Max(200, value);
|
||||
}
|
||||
|
||||
private byte[] BuildStatusUnlocked(byte command)
|
||||
{
|
||||
return MagCarSimProtocol.BuildStatus(
|
||||
command,
|
||||
VehicleCode,
|
||||
Node,
|
||||
State,
|
||||
Charge,
|
||||
Current,
|
||||
Voltage,
|
||||
Speed,
|
||||
Angle,
|
||||
Task,
|
||||
Lift,
|
||||
Roll);
|
||||
}
|
||||
|
||||
private void Log(string message)
|
||||
{
|
||||
MagCarSimLog.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] [{Name}/{VehicleCode}] {message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<RootNamespace>StandardScene.MagCarSimulator</RootNamespace>
|
||||
<AssemblyName>StandardScene.MagCarSimulator</AssemblyName>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<ImplicitUsings>disable</ImplicitUsings>
|
||||
<Nullable>disable</Nullable>
|
||||
<Deterministic>true</Deterministic>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<ApplicationIcon />
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,574 @@
|
||||
{
|
||||
"MapPath": "D:\\工作\\stand\\SimpleLite\\maps\\复杂交管.json",
|
||||
"UseTagValueAsNode": false,
|
||||
"ListenAddress": "0.0.0.0",
|
||||
"LogRawFrames": false,
|
||||
"LogQueries": false,
|
||||
"LogDirectory": "log",
|
||||
"EnableFileLog": true,
|
||||
"DefaultIntervalMs": 2000,
|
||||
"LaunchStaggerMs": 0,
|
||||
"ControlAreasPath": "D:\\工作\\stand\\SimpleLite\\config\\Signal\\mag-control-areas.json",
|
||||
"Vehicles": [
|
||||
{
|
||||
"Name": "西环-1",
|
||||
"VehicleCode": 1,
|
||||
"ListenPort": 5001,
|
||||
"StartSiteId": 1,
|
||||
"EndSiteId": 7,
|
||||
"IntervalMs": 1800,
|
||||
"LoopSiteIds": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
104,
|
||||
100,
|
||||
103,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
9,
|
||||
10,
|
||||
11,
|
||||
12,
|
||||
201,
|
||||
13,
|
||||
14
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "西环-2",
|
||||
"VehicleCode": 2,
|
||||
"ListenPort": 5002,
|
||||
"StartSiteId": 4,
|
||||
"EndSiteId": 7,
|
||||
"IntervalMs": 1880,
|
||||
"LoopSiteIds": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
104,
|
||||
100,
|
||||
103,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
9,
|
||||
10,
|
||||
11,
|
||||
12,
|
||||
201,
|
||||
13,
|
||||
14
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "西环-3",
|
||||
"VehicleCode": 3,
|
||||
"ListenPort": 5003,
|
||||
"StartSiteId": 8,
|
||||
"EndSiteId": 7,
|
||||
"IntervalMs": 1960,
|
||||
"LoopSiteIds": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
104,
|
||||
100,
|
||||
103,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
9,
|
||||
10,
|
||||
11,
|
||||
12,
|
||||
201,
|
||||
13,
|
||||
14
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "西环-4",
|
||||
"VehicleCode": 4,
|
||||
"ListenPort": 5004,
|
||||
"StartSiteId": 12,
|
||||
"EndSiteId": 7,
|
||||
"IntervalMs": 2040,
|
||||
"LoopSiteIds": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
104,
|
||||
100,
|
||||
103,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
9,
|
||||
10,
|
||||
11,
|
||||
12,
|
||||
201,
|
||||
13,
|
||||
14
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "西环-5",
|
||||
"VehicleCode": 5,
|
||||
"ListenPort": 5005,
|
||||
"StartSiteId": 13,
|
||||
"EndSiteId": 7,
|
||||
"IntervalMs": 2120,
|
||||
"LoopSiteIds": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
104,
|
||||
100,
|
||||
103,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
9,
|
||||
10,
|
||||
11,
|
||||
12,
|
||||
201,
|
||||
13,
|
||||
14
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "东环-1",
|
||||
"VehicleCode": 6,
|
||||
"ListenPort": 5006,
|
||||
"StartSiteId": 15,
|
||||
"EndSiteId": 23,
|
||||
"IntervalMs": 1900,
|
||||
"LoopSiteIds": [
|
||||
15,
|
||||
16,
|
||||
17,
|
||||
18,
|
||||
19,
|
||||
20,
|
||||
202,
|
||||
21,
|
||||
22,
|
||||
23,
|
||||
24,
|
||||
25,
|
||||
26,
|
||||
6,
|
||||
103,
|
||||
100,
|
||||
104,
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "东环-2",
|
||||
"VehicleCode": 7,
|
||||
"ListenPort": 5007,
|
||||
"StartSiteId": 18,
|
||||
"EndSiteId": 23,
|
||||
"IntervalMs": 1980,
|
||||
"LoopSiteIds": [
|
||||
15,
|
||||
16,
|
||||
17,
|
||||
18,
|
||||
19,
|
||||
20,
|
||||
202,
|
||||
21,
|
||||
22,
|
||||
23,
|
||||
24,
|
||||
25,
|
||||
26,
|
||||
6,
|
||||
103,
|
||||
100,
|
||||
104,
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "东环-3",
|
||||
"VehicleCode": 8,
|
||||
"ListenPort": 5008,
|
||||
"StartSiteId": 20,
|
||||
"EndSiteId": 23,
|
||||
"IntervalMs": 2060,
|
||||
"LoopSiteIds": [
|
||||
15,
|
||||
16,
|
||||
17,
|
||||
18,
|
||||
19,
|
||||
20,
|
||||
202,
|
||||
21,
|
||||
22,
|
||||
23,
|
||||
24,
|
||||
25,
|
||||
26,
|
||||
6,
|
||||
103,
|
||||
100,
|
||||
104,
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "东环-4",
|
||||
"VehicleCode": 9,
|
||||
"ListenPort": 5009,
|
||||
"StartSiteId": 22,
|
||||
"EndSiteId": 23,
|
||||
"IntervalMs": 2140,
|
||||
"LoopSiteIds": [
|
||||
15,
|
||||
16,
|
||||
17,
|
||||
18,
|
||||
19,
|
||||
20,
|
||||
202,
|
||||
21,
|
||||
22,
|
||||
23,
|
||||
24,
|
||||
25,
|
||||
26,
|
||||
6,
|
||||
103,
|
||||
100,
|
||||
104,
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "东环-5",
|
||||
"VehicleCode": 10,
|
||||
"ListenPort": 5010,
|
||||
"StartSiteId": 25,
|
||||
"EndSiteId": 23,
|
||||
"IntervalMs": 2220,
|
||||
"LoopSiteIds": [
|
||||
15,
|
||||
16,
|
||||
17,
|
||||
18,
|
||||
19,
|
||||
20,
|
||||
202,
|
||||
21,
|
||||
22,
|
||||
23,
|
||||
24,
|
||||
25,
|
||||
26,
|
||||
6,
|
||||
103,
|
||||
100,
|
||||
104,
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "北环-1",
|
||||
"VehicleCode": 11,
|
||||
"ListenPort": 5011,
|
||||
"StartSiteId": 35,
|
||||
"EndSiteId": 28,
|
||||
"IntervalMs": 2000,
|
||||
"LoopSiteIds": [
|
||||
35,
|
||||
36,
|
||||
9,
|
||||
37,
|
||||
38,
|
||||
27,
|
||||
101,
|
||||
100,
|
||||
102,
|
||||
28,
|
||||
29,
|
||||
30,
|
||||
24,
|
||||
31,
|
||||
32,
|
||||
33,
|
||||
203,
|
||||
34
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "北环-2",
|
||||
"VehicleCode": 12,
|
||||
"ListenPort": 5012,
|
||||
"StartSiteId": 37,
|
||||
"EndSiteId": 28,
|
||||
"IntervalMs": 2080,
|
||||
"LoopSiteIds": [
|
||||
35,
|
||||
36,
|
||||
9,
|
||||
37,
|
||||
38,
|
||||
27,
|
||||
101,
|
||||
100,
|
||||
102,
|
||||
28,
|
||||
29,
|
||||
30,
|
||||
24,
|
||||
31,
|
||||
32,
|
||||
33,
|
||||
203,
|
||||
34
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "北环-3",
|
||||
"VehicleCode": 13,
|
||||
"ListenPort": 5013,
|
||||
"StartSiteId": 29,
|
||||
"EndSiteId": 28,
|
||||
"IntervalMs": 2160,
|
||||
"LoopSiteIds": [
|
||||
35,
|
||||
36,
|
||||
9,
|
||||
37,
|
||||
38,
|
||||
27,
|
||||
101,
|
||||
100,
|
||||
102,
|
||||
28,
|
||||
29,
|
||||
30,
|
||||
24,
|
||||
31,
|
||||
32,
|
||||
33,
|
||||
203,
|
||||
34
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "北环-4",
|
||||
"VehicleCode": 14,
|
||||
"ListenPort": 5014,
|
||||
"StartSiteId": 32,
|
||||
"EndSiteId": 28,
|
||||
"IntervalMs": 2240,
|
||||
"LoopSiteIds": [
|
||||
35,
|
||||
36,
|
||||
9,
|
||||
37,
|
||||
38,
|
||||
27,
|
||||
101,
|
||||
100,
|
||||
102,
|
||||
28,
|
||||
29,
|
||||
30,
|
||||
24,
|
||||
31,
|
||||
32,
|
||||
33,
|
||||
203,
|
||||
34
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "北环-5",
|
||||
"VehicleCode": 15,
|
||||
"ListenPort": 5015,
|
||||
"StartSiteId": 203,
|
||||
"EndSiteId": 28,
|
||||
"IntervalMs": 2320,
|
||||
"LoopSiteIds": [
|
||||
35,
|
||||
36,
|
||||
9,
|
||||
37,
|
||||
38,
|
||||
27,
|
||||
101,
|
||||
100,
|
||||
102,
|
||||
28,
|
||||
29,
|
||||
30,
|
||||
24,
|
||||
31,
|
||||
32,
|
||||
33,
|
||||
203,
|
||||
34
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "南环-1",
|
||||
"VehicleCode": 16,
|
||||
"ListenPort": 5016,
|
||||
"StartSiteId": 39,
|
||||
"EndSiteId": 28,
|
||||
"IntervalMs": 2100,
|
||||
"LoopSiteIds": [
|
||||
39,
|
||||
40,
|
||||
204,
|
||||
41,
|
||||
42,
|
||||
43,
|
||||
17,
|
||||
44,
|
||||
45,
|
||||
28,
|
||||
102,
|
||||
100,
|
||||
101,
|
||||
27,
|
||||
46,
|
||||
47,
|
||||
2,
|
||||
48
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "南环-2",
|
||||
"VehicleCode": 17,
|
||||
"ListenPort": 5017,
|
||||
"StartSiteId": 204,
|
||||
"EndSiteId": 28,
|
||||
"IntervalMs": 2180,
|
||||
"LoopSiteIds": [
|
||||
39,
|
||||
40,
|
||||
204,
|
||||
41,
|
||||
42,
|
||||
43,
|
||||
17,
|
||||
44,
|
||||
45,
|
||||
28,
|
||||
102,
|
||||
100,
|
||||
101,
|
||||
27,
|
||||
46,
|
||||
47,
|
||||
2,
|
||||
48
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "南环-3",
|
||||
"VehicleCode": 18,
|
||||
"ListenPort": 5018,
|
||||
"StartSiteId": 42,
|
||||
"EndSiteId": 28,
|
||||
"IntervalMs": 2260,
|
||||
"LoopSiteIds": [
|
||||
39,
|
||||
40,
|
||||
204,
|
||||
41,
|
||||
42,
|
||||
43,
|
||||
17,
|
||||
44,
|
||||
45,
|
||||
28,
|
||||
102,
|
||||
100,
|
||||
101,
|
||||
27,
|
||||
46,
|
||||
47,
|
||||
2,
|
||||
48
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "南环-4",
|
||||
"VehicleCode": 19,
|
||||
"ListenPort": 5019,
|
||||
"StartSiteId": 45,
|
||||
"EndSiteId": 28,
|
||||
"IntervalMs": 2340,
|
||||
"LoopSiteIds": [
|
||||
39,
|
||||
40,
|
||||
204,
|
||||
41,
|
||||
42,
|
||||
43,
|
||||
17,
|
||||
44,
|
||||
45,
|
||||
28,
|
||||
102,
|
||||
100,
|
||||
101,
|
||||
27,
|
||||
46,
|
||||
47,
|
||||
2,
|
||||
48
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "南环-5",
|
||||
"VehicleCode": 20,
|
||||
"ListenPort": 5020,
|
||||
"StartSiteId": 47,
|
||||
"EndSiteId": 28,
|
||||
"IntervalMs": 2420,
|
||||
"LoopSiteIds": [
|
||||
39,
|
||||
40,
|
||||
204,
|
||||
41,
|
||||
42,
|
||||
43,
|
||||
17,
|
||||
44,
|
||||
45,
|
||||
28,
|
||||
102,
|
||||
100,
|
||||
101,
|
||||
27,
|
||||
46,
|
||||
47,
|
||||
2,
|
||||
48
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user