磁导航1.0内部交管和信号交互

This commit is contained in:
ykkokluo
2026-08-24 14:55:35 +08:00
parent cacc5fea5c
commit 244b0af6c8
60 changed files with 6395 additions and 294 deletions
@@ -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);
}
}
}
+630
View File
@@ -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;
}
}
}
+15
View File
@@ -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
]
}
]
}