磁导航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
+1 -1
View File
@@ -79,7 +79,7 @@ namespace StandardScene.CarTypes
[FieldMember] public bool EnableDetailLog = true;
[FieldMember] public bool LogRawFrame = true;
[FieldMember] public bool EnableFileLog = true;
[FieldMember] public string LogDirectory = "logs";
[FieldMember] public string LogDirectory = "log";
[FieldMember] public float CarLength = 1200;
[FieldMember] public float CarWidth = 800;
+262 -9
View File
@@ -17,6 +17,7 @@ using System.Drawing.Drawing2D;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
@@ -101,6 +102,9 @@ namespace StandardScene.CarTypes
/// </summary>
private DateTime _lastPollTime = DateTime.MinValue;
/// <summary>最近一次与 AGV 通讯成功的时间;用于「断开超过 OfflineAfterMs → 离线」。</summary>
private DateTime _lastCommOkTime = DateTime.Now;
/// <summary>
/// AGV 服务端监听端口,默认 5000。
/// </summary>
@@ -135,6 +139,12 @@ namespace StandardScene.CarTypes
/// </summary>
[FieldMember] public int PollIntervalMs = 1000;
/// <summary>
/// 连续通讯失败超过该毫秒数后,概况显示「离线」。
/// 默认 20000(20 秒);期间失败会显示「连接中」。
/// </summary>
[FieldMember] public int OfflineAfterMs = 20000;
/// <summary>
/// 移动任务的预留超时时间,单位秒。
/// 当前 MagCar 不下发脚本,保留该字段用于后续任务调度或界面配置兼容。
@@ -280,11 +290,28 @@ namespace StandardScene.CarTypes
}
catch (Exception ex)
{
lstatus = IsBenignSocketFault(ex) ? lstatus : "离线";
if (!IsBenignSocketFault(ex))
{
DetailLog($"keepAlive poll failed: {ex.Message}");
}
ApplyOfflineAfterDisconnect(ex);
}
}
/// <summary>
/// 通讯失败时:未满 <see cref="OfflineAfterMs"/> 显示「连接中」,满 20 秒(可配)后显示「离线」。
/// </summary>
private void ApplyOfflineAfterDisconnect(Exception ex)
{
var offlineAfter = OfflineAfterMs < 1000 ? 1000 : OfflineAfterMs;
if ((DateTime.Now - _lastCommOkTime).TotalMilliseconds >= offlineAfter)
{
lstatus = "离线";
}
else if (lstatus != "离线")
{
lstatus = "连接中";
}
if (!IsBenignSocketFault(ex))
{
DetailLog($"keepAlive poll failed: {ex.Message}");
}
}
@@ -590,6 +617,13 @@ namespace StandardScene.CarTypes
try
{
ConnectWithTimeout(client);
if (IsLoopbackSelfConnect(client))
{
AbortClientQuietly(client);
throw new IOException(
$"TCP self-connect {address}:{Port} (local ephemeral hit listen port); refuse and retry");
}
client.SendTimeout = SendTimeoutMs;
client.ReceiveTimeout = ReceiveTimeoutMs;
_persistentClient = client;
@@ -619,7 +653,51 @@ namespace StandardScene.CarTypes
{
try
{
return _persistentClient != null && _persistentClient.Connected;
if (_persistentClient == null || !_persistentClient.Connected)
{
return false;
}
// 本机动态端口若从 1024 起,连 500x 且模拟器未监听时,Windows 可能自连成功且 Connected=true
// 之后会一直把自身请求当响应(State=0 → 未准备),必须主动拆掉重连。
if (IsLoopbackSelfConnect(_persistentClient))
{
ClosePersistentConnection("loopback self-connect");
return false;
}
return true;
}
catch
{
return false;
}
}
/// <summary>
/// 检测 Windows/localhost 上「源端口=目标端口」的自连(无真实服务端)。
/// </summary>
private static bool IsLoopbackSelfConnect(TcpClient client)
{
try
{
if (client?.Client == null)
{
return false;
}
if (!(client.Client.LocalEndPoint is IPEndPoint local) ||
!(client.Client.RemoteEndPoint is IPEndPoint remote))
{
return false;
}
if (local.Port != remote.Port)
{
return false;
}
return IPAddress.IsLoopback(local.Address) && IPAddress.IsLoopback(remote.Address);
}
catch
{
@@ -680,6 +758,13 @@ namespace StandardScene.CarTypes
{
DetailLog($"short connect begin {address}:{Port}, timeout={ConnectTimeoutMs}ms");
ConnectWithTimeout(client);
if (IsLoopbackSelfConnect(client))
{
AbortClientQuietly(client);
throw new IOException(
$"TCP self-connect {address}:{Port} (local ephemeral hit listen port); refuse and retry");
}
DetailLog($"short connect ok {address}:{Port}");
client.SendTimeout = SendTimeoutMs;
@@ -785,11 +870,17 @@ namespace StandardScene.CarTypes
if (!string.IsNullOrEmpty(message) &&
(message.Contains("已中止 I/O", StringComparison.Ordinal) ||
message.Contains("I/O operation", StringComparison.OrdinalIgnoreCase) ||
message.Contains("operation was aborted", StringComparison.OrdinalIgnoreCase)))
message.Contains("operation was aborted", StringComparison.OrdinalIgnoreCase) ||
message.Contains("self-connect", StringComparison.OrdinalIgnoreCase)))
{
return true;
}
}
if (current is TimeoutException)
{
return true;
}
}
return false;
@@ -899,6 +990,7 @@ namespace StandardScene.CarTypes
private void ApplyReport(MagCarReport report)
{
LastReport = report;
_lastCommOkTime = DateTime.Now;
lstatus = report.Alarm != 0 ? "报警" : report.State == 0 ? "未准备" : "上线";
th = report.Angle;
@@ -925,12 +1017,108 @@ namespace StandardScene.CarTypes
haveCoordination = true;
siteID = site.id;
x = site.x;
y = site.y;
ApplyStackedSitePose(site);
SyncTrafficFromReport(site, report);
DetailLog($"report applied, node={report.Node}, site={siteID}, x={x:0.###}, y={y:0.###}, th={th:0.###}, state={StateText(report.State)}, soc={report.Charge}, current={report.Current:0.###}, voltage={report.Voltage:0.###}, speed={report.Speed}, task={report.Task}, lift={report.Lift}, roll={report.Roll}, alarm=0x{report.Alarm:X}");
}
/// <summary>
/// 同一普通站多车时,沿路网方向按车号依次错开坐标,避免 SimpleLite 地图上完全重叠。
/// 站点逻辑仍用 <see cref="Car.siteID"/>,不改变交管占用。
/// </summary>
private void ApplyStackedSitePose(Site site)
{
if (site == null)
{
return;
}
var ids = SameSiteCarIds(site.id);
var index = Math.Max(0, ids.IndexOf(id));
var (ux, uy) = SiteSpreadAxis(site);
var step = Math.Max(CarLength * 0.9f, 900f);
var centered = index - (ids.Count - 1) * 0.5;
x = (float)(site.x + ux * step * centered);
y = (float)(site.y + uy * step * centered);
}
private List<int> SameSiteCarIds(int siteId)
{
var ids = new List<int>();
try
{
foreach (var car in SimpleLib.GetAllCars().OfType<Car>())
{
if (car != null && car.siteID == siteId && !ids.Contains(car.id))
{
ids.Add(car.id);
}
}
}
catch
{
}
if (!ids.Contains(id))
{
ids.Add(id);
}
ids.Sort();
return ids;
}
private static (double ux, double uy) SiteSpreadAxis(Site site)
{
double bestDx = 0;
double bestDy = 0;
double bestLen = 0;
try
{
foreach (var track in SimpleLib.GetAllTracks())
{
if (track == null)
{
continue;
}
var otherId = track.siteA == site.id ? track.siteB
: track.siteB == site.id ? track.siteA
: 0;
if (otherId <= 0)
{
continue;
}
var other = SimpleLib.GetSite(otherId);
if (other == null)
{
continue;
}
var dx = other.x - site.x;
var dy = other.y - site.y;
var len = Math.Sqrt(dx * dx + dy * dy);
if (len > bestLen)
{
bestLen = len;
bestDx = dx;
bestDy = dy;
}
}
}
catch
{
}
if (bestLen < 1)
{
return (1, 0);
}
return (bestDx / bestLen, bestDy / bestLen);
}
/// <summary>
/// 将 AGV 上报的当前地标同步到交通锁(holdingLocks)。
/// GhostCar 调度依赖 holdingLocks/GetLastSite,仅改 x/y/siteID 会导致“画面在动、初始化占点不动”。
@@ -954,6 +1142,25 @@ namespace StandardScene.CarTypes
return;
}
// 他车仍占本站锁但物理站已离开 → 回收陈旧锁,否则 TrafficReset 失败,画面占点不跟着走。
var holder = FindOtherCarHoldingSite(site.id);
if (holder != null)
{
if (!TryReleaseStaleHolder(holder, site.id))
{
if (_lastSyncedTrafficSiteId != -site.id)
{
DetailLog(
$"traffic sync skip, site={site.id} held by car={holder.id}, selfLock={lockedSite}, node={report.Node}");
_lastSyncedTrafficSiteId = -site.id;
}
return;
}
DetailLog($"traffic sync released stale holder car={holder.id}, site={site.id}");
}
try
{
DetailLog($"traffic sync TrafficReset, lock={lockedSite}, targetSite={site.id}, node={report.Node}, state={StateText(report.State)}");
@@ -966,6 +1173,52 @@ namespace StandardScene.CarTypes
}
}
private Car FindOtherCarHoldingSite(int siteId)
{
foreach (var other in SimpleLib.GetAllCars().OfType<Car>())
{
if (other == null || other.id == id)
{
continue;
}
if (other.status?.holdingLocks != null &&
Array.IndexOf(other.status.holdingLocks, siteId) >= 0)
{
return other;
}
}
return null;
}
private bool TryReleaseStaleHolder(Car other, int siteId)
{
if (other == null)
{
return false;
}
var online = other.tags?.Contains("Online") == true;
var physicalSiteId = other.siteID > 0 ? other.siteID : other.GetLastSite();
var stale = !online || (physicalSiteId > 0 && physicalSiteId != siteId);
if (!stale)
{
return false;
}
try
{
TrafficControl.Leave(other, siteId);
return Array.IndexOf(other.status.holdingLocks ?? Array.Empty<int>(), siteId) < 0;
}
catch (Exception ex)
{
DetailLog($"traffic sync release stale holder failed: car={other.id}, site={siteId}, {ex.Message}");
return false;
}
}
/// <summary>
/// 将 AGV 返回的节点号解析为场景站点。
/// 根据 <see cref="UseTagValueAsNode"/> 决定优先匹配站点 TagValue 还是直接匹配站点 id。
@@ -9,6 +9,7 @@ namespace StandardScene.Magnetic.Tasking
{
/// <summary>
/// 按 holdingLocks + 物理 siteID 统计管控站点列表上的他车占用。
/// Mag2 仍读地图 Fass2_ControlArea 字段;磁条交管进程只放行 MagCar,不介入本路径。
/// </summary>
internal static class Fass2ControlAreaOccupancy
{
@@ -6,17 +6,17 @@ using System.Text;
namespace StandardScene.Magnetic.Tasking
{
/// <summary>
/// Mag2Car 本地文件日志,按协议车号分目录:logs/car{VehicleCode}/mag2_yyyyMMdd.log
/// Mag2Car 本地文件日志,按协议车号分目录:log/car{VehicleCode}/mag2_yyyyMMdd.log
/// </summary>
public static class Mag2CarFileLogger
{
private static readonly ConcurrentDictionary<ushort, object> CarLocks = new ConcurrentDictionary<ushort, object>();
private static string _baseDirectory = "logs";
private static string _baseDirectory = "log";
private static bool _enabled = true;
public static void Configure(string baseDirectory, bool enabled)
{
_baseDirectory = string.IsNullOrWhiteSpace(baseDirectory) ? "logs" : baseDirectory.Trim();
_baseDirectory = string.IsNullOrWhiteSpace(baseDirectory) ? "log" : baseDirectory.Trim();
_enabled = enabled;
}