磁导航1.0内部交管和信号交互
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user