Files
StandardSence/StandardScene.Magnetic/CarTypes/MagCar.cs
T

1424 lines
54 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using LessokajiWeaverUtilities.Utilities;
using Simple3;
using Simple3.RCS;
using Simple3.RCS.CarTypes;
using Simple3.Props;
using Simple3.UI;
using SimpleCore;
using SimpleCore.Compiler;
using SimpleCore.Library;
using SimpleCore.PropType;
using SimpleCore.Traffic;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using SimpleCore.BasicProps;
namespace StandardScene.CarTypes
{
/// <summary>
/// 基于 FASS 1.0 小车 TCP 协议实现的磁导航小车类型。
/// 该类型直接按 32 字节协议帧与现场 AGV 服务端通讯,通过 <see cref="MagCarInterface"/> 执行调度脚本。
/// 默认使用长连接发送命令和读取状态;现场服务端不支持长连接时,可关闭 <see cref="UsePersistentConnection"/> 回退到短连接兼容模式。
/// </summary>
[TemplateTrackCoderSettings(
priority = 0,
templateString = "agv.MagCarGo(${src.id},${dst.id},${track.Speed});",
trackFields = typeof(BasicTrackFields))]
[CarType(Name = "MagCar")]
[I18N.DocumentTranslation(Name = "MagCar", locale = "en")]
[EnvelopConfig(lengthX = 1200, lengthY = 800, centerX = 0, centerY = 0)]
public class MagCar : GhostCar
{
/// <summary>
/// FASS 1.0 协议帧起始字节,所有发送和接收帧第 0 字节均应为 0xBB。
/// </summary>
private const byte Begin = 0xBB;
/// <summary>
/// FASS 1.0 协议帧结束字节,所有发送和接收帧第 31 字节均应为 0xEE。
/// </summary>
private const byte End = 0xEE;
/// <summary>
/// 查询 AGV 当前状态的命令码,响应中会带回节点、报警、状态、电量、速度、角度等信息。
/// </summary>
private const byte CmdQueryState = 0x00;
/// <summary>
/// 启动或解除停止状态的命令码,数据区通常携带当前车头角度。
/// </summary>
private const byte CmdStart = 0x01;
/// <summary>
/// 停止命令码,数据区可携带停止保持秒数;当前实现通常传 0 表示立即停止。
/// </summary>
private const byte CmdStop = 0x02;
/// <summary>
/// 设置速度百分比的命令码,数据区第一个字节为 0-100 的速度百分比。
/// </summary>
private const byte CmdSpeed = 0x03;
/// <summary>
/// 设置车头角度的命令码,数据区携带角度值。
/// </summary>
private const byte CmdAngle = 0x04;
/// <summary>
/// 旧版节点下发命令码,只下发目标节点,用于兼容不支持 0xA1 任务命令的现场服务端。
/// </summary>
private const byte CmdLegacyNode = 0x05;
/// <summary>
/// FASS 1.0 任务下发命令码,数据区携带起点节点、终点节点和任务动作。
/// </summary>
private const byte CmdTask = 0xA1;
/// <summary>
/// TCP 发送、接收和长连接重连的同步锁。
/// 多线程调度、keepAlive 和人工方法可能同时触发通讯,统一加锁可避免多个线程同时写同一个 Socket。
/// </summary>
private readonly object _syncRoot = new object();
/// <summary>
/// 长连接模式下复用的 TCP 客户端。
/// 发送或读取失败时会关闭并置空,下一次命令会自动重新连接。
/// </summary>
private TcpClient _persistentClient;
/// <summary>
/// 最近一次 keepAlive 实际查询小车状态的时间。
/// 外部框架可能高频调用 keepAlive,本字段用于按 <see cref="PollIntervalMs"/> 节流实际 TCP 查询频率。
/// </summary>
private DateTime _lastPollTime = DateTime.MinValue;
/// <summary>
/// AGV 服务端监听端口,默认 5000。
/// </summary>
[FieldMember] public int Port = 5000;
/// <summary>
/// 协议中的车辆编号,写入发送帧第 2-3 字节,使用大端字节序。
/// 多车共用同一服务端时用于区分车辆。
/// </summary>
[FieldMember] public ushort VehicleCode = 0;
/// <summary>
/// TCP 建连超时时间,单位毫秒。
/// 长连接首次连接或重连、短连接每次命令连接时都会使用该值。
/// </summary>
[FieldMember] public int ConnectTimeoutMs = 500;
/// <summary>
/// TCP 发送超时时间,单位毫秒。
/// </summary>
[FieldMember] public int SendTimeoutMs = 500;
/// <summary>
/// TCP 接收超时时间,单位毫秒。
/// 读取响应时会一直读满 32 字节,超过该时间仍未收到完整帧则抛出异常。
/// </summary>
[FieldMember] public int ReceiveTimeoutMs = 500;
/// <summary>
/// keepAlive 中实际查询 AGV 状态的最小间隔,单位毫秒。
/// 默认 1000,表示最多每秒向小车查询一次状态。
/// </summary>
[FieldMember] public int PollIntervalMs = 1000;
/// <summary>
/// 移动任务的预留超时时间,单位秒。
/// 当前 MagCar 不下发脚本,保留该字段用于后续任务调度或界面配置兼容。
/// </summary>
[FieldMember] public int MoveTimeoutSeconds = 120;
/// <summary>
/// 是否使用 0xA1 任务命令下发起点和终点。
/// 为 false 时改用 0x05 旧版目标节点命令,适配早期或定制 AGV 服务端。
/// </summary>
[FieldMember] public bool UseTaskCommand = true;
/// <summary>
/// 移动前是否先发送启动命令。
/// 当前文件保留该配置以兼容调度策略,具体移动流程可在后续路径执行逻辑中使用。
/// </summary>
[FieldMember] public bool StartBeforeMove = true;
/// <summary>
/// 是否按接口文档使用 ushort 大端字段写入角度、节点、停止秒数等数据。
/// 为 false 时只写入低 1 字节,用于兼容旧版 0x05 单字节节点协议。
/// </summary>
[FieldMember] public bool UseDocumentUShortFields = true;
/// <summary>
/// 是否使用站点字段 TagValue 作为 AGV 节点号。
/// 为 true 时,收到的 Node 会先匹配站点 fields["TagValue"];为 false 时直接用站点 id 匹配。
/// </summary>
[FieldMember] public bool UseTagValueAsNode = false;
/// <summary>
/// 是否启用 TCP 长连接。
/// 默认 true;若现场 AGV 服务端不支持同一连接多次收发,可设为 false 回退到每条命令新建一次短连接。
/// </summary>
[FieldMember] public bool UsePersistentConnection = true;
/// <summary>
/// 是否输出 MagCar 详细日志。
/// 打开后会记录连接、发送、接收、状态解析、站点映射和异常信息。
/// </summary>
[FieldMember] public bool EnableDetailLog = true;
/// <summary>
/// 是否在详细日志中输出原始 32 字节协议帧。
/// 调试协议字段时建议打开;现场日志量过大时可关闭。
/// </summary>
[FieldMember] public bool LogRawFrame = true;
/// <summary>
/// 小车绘制长度,单位与场景坐标一致,默认 1200。
/// 仅影响画面显示,不参与 AGV 协议通讯。
/// </summary>
[FieldMember] public float CarLength = 1200;
/// <summary>
/// 小车绘制宽度,单位与场景坐标一致,默认 800。
/// 仅影响画面显示,不参与 AGV 协议通讯。
/// </summary>
[FieldMember] public float CarWidth = 800;
/// <summary>
/// 最近一次从 AGV 返回帧中解析出的完整状态快照。
/// UI 显示、状态枚举和站点定位均以该对象为基础。
/// </summary>
public MagCarReport LastReport { get; private set; } = new MagCarReport();
/// <summary>
/// 创建 MagCar 默认实例,供 StandardScene 通过车型注册机制实例化。
/// 默认地址为 127.0.0.1,实际现场地址应在车属性中配置。
/// </summary>
/// <returns>初始化后的 MagCar 实例。</returns>
public new static async Task<MagCar> Create()
{
return new MagCar
{
lstatus = "连接中",
address = "127.0.0.1",
name = "MagCar",
haveCoordination = true,
speed = 1
};
}
/// <summary>
/// 在场景画布中绘制小车外形和朝向箭头。
/// 绘制尺寸来自 <see cref="CarLength"/> 和 <see cref="CarWidth"/>。
/// </summary>
/// <param name="eGraphics">场景传入的绘图对象,坐标系已由基类转换到小车本地坐标。</param>
protected override void draw(Graphics eGraphics)
{
var halfLength = CarLength / 2;
var halfWidth = CarWidth / 2;
eGraphics.FillRectangle(Brushes.Gray, -halfLength, -halfWidth, CarLength, CarWidth);
eGraphics.DrawRectangle(Pens.White, -halfLength, -halfWidth, CarLength, CarWidth);
using (var orientPen = new Pen(Color.White, 3))
{
orientPen.CustomEndCap = new AdjustableArrowCap(6, 6, true);
orientPen.StartCap = LineCap.RoundAnchor;
eGraphics.DrawLine(orientPen, 0, 0, halfLength, 0);
}
}
/// <summary>
/// 生成场景中小车悬浮或列表展示的简要状态文本。
/// 内容包括车辆名称、站点、电量、速度和报警摘要。
/// </summary>
/// <returns>用于界面显示的多行状态字符串。</returns>
public override string SetDisplayInfo()
{
try
{
var report = LastReport;
var state = StateText(report.State);
var alarm = report.Alarm == 0 ? "" : $"|alarm:0x{report.Alarm:X}";
return $"{name}({id})\n{state}|site:{siteID}|soc:{report.Charge}|speed:{report.Speed}{alarm}";
}
catch
{
return $"{name}({id})\n状态未知";
}
}
/// <summary>
/// StandardScene 周期调用的保活入口。
/// 方法内部按 <see cref="PollIntervalMs"/> 节流,实际执行时发送 0x00 查询状态命令,
/// 并将返回的 Node、状态、电量、报警等字段同步到车对象和 status.enums。
/// </summary>
public override void keepAlive()
{
if ((DateTime.Now - _lastPollTime).TotalMilliseconds < PollIntervalMs)
{
return;
}
_lastPollTime = DateTime.Now;
try
{
DetailLog($"keepAlive poll begin, interval={PollIntervalMs}ms, endpoint={address}:{Port}");
var report = RefreshState();
haveCoordination = true;
DetailLog($"keepAlive poll ok, node={report.Node}, state={StateText(report.State)}, soc={report.Charge}, speed={report.Speed}, alarm=0x{report.Alarm:X}");
}
catch (Exception ex)
{
lstatus = IsBenignSocketFault(ex) ? lstatus : "离线";
if (!IsBenignSocketFault(ex))
{
DetailLog($"keepAlive poll failed: {ex.Message}");
}
}
}
private bool _running;
/// <summary>
/// 最近一次已同步到交通管控层的站点 id。
/// keepAlive 仅更新 x/y 时,holdingLocks 不会自动跟随,需要显式同步。
/// </summary>
private int _lastSyncedTrafficSiteId = -1;
/// <summary>
/// 执行调度编译出的移动脚本。
/// 脚本中的 <c>agv.MagCarGo</c> 会通过 FASS 1.0 协议下发移动任务,并轮询状态直到到达目标节点。
/// </summary>
/// <param name="script">调度层编译出的脚本文本。</param>
public override async Task actualSendScript(string script)
{
if (_running)
{
throw new InvalidOperationException($"MagCar {id} already running script");
}
_running = true;
try
{
DetailLog($"script begin, length={(script ?? string.Empty).Length}");
var agv = new MagCarInterface(id);
var tcs = new TaskCompletionSource<int>();
new Thread(() =>
{
try
{
SelfEvaluating(agv, script);
tcs.SetResult(1);
}
catch (Exception ex)
{
tcs.SetException(ex);
}
})
{ Name = $"eva_{name}({id}):{status.programs.now.name}" }.Start();
await tcs.Task;
await agv.WaitAsync();
DetailLog("script completed");
}
catch (Exception ex)
{
DetailLog($"script failed: {ExceptionFormatter.FormatEx(ex)}");
throw;
}
finally
{
_running = false;
}
}
[MethodMember(Name = "查询状态", Description = "按FASS 1.0协议查询AGV状态")]
/// <summary>
/// 人工查询 AGV 状态的界面方法。
/// 调用后立即发送 0x00 查询命令,并用返回帧刷新 <see cref="LastReport"/>、站点位置和 status.enums。
/// </summary>
public void QueryState()
{
RefreshState();
}
[MethodMember(Name = "启动", Description = "发送启动指令")]
/// <summary>
/// 人工启动小车的界面方法。
/// 按当前车头角度发送 0x01 启动命令。
/// </summary>
public void StartMagCar()
{
SendStart((ushort)Math.Round(th));
}
[MethodMember(Name = "停止", Description = "发送停止指令")]
/// <summary>
/// 人工停止小车的界面方法。
/// 发送 0x02 停止命令,当前停止秒数传 0。
/// </summary>
public void StopMagCar()
{
SendStop(0);
}
[MethodMember(Name = "进入小车远程", Description = "进入小车远程桌面")]
[I18N.DocumentTranslation(Name = "Open remote desktop", Description = "Open the car's remote desktop", locale = "en")]
/// <summary>
/// 打开 Windows 远程桌面客户端并连接到当前小车地址。
/// 该方法只用于运维进入车载电脑,不参与 AGV 协议通讯。
/// </summary>
public void Mstsc()
{
Process.Start(
new ProcessStartInfo
{
FileName = "mstsc",
Arguments = $"/v:{address}",
UseShellExecute = false,
CreateNoWindow = true
}
);
}
/// <summary>
/// 调度系统触发的急停入口。
/// 记录急停原因后发送 0x02 停止命令。
/// </summary>
/// <param name="reason">触发急停的上层原因,写入诊断日志便于追查。</param>
public void EmergencyStop(string reason)
{
Diagnosis.Post($"car{name}:MagCar EmergencyStop {reason}");
SendStop(0);
}
/// <summary>
/// 调度系统触发的急停释放入口。
/// 记录释放日志后按当前车头角度发送 0x01 启动命令。
/// </summary>
public void EmergencyRelease()
{
Diagnosis.Post($"car{name}:MagCar EmergencyRelease");
SendStart((ushort)Math.Round(th));
}
[MethodMember(Name = "重置TCP连接", Description = "关闭当前长连接,下次命令自动重连")]
/// <summary>
/// 人工重置长连接的界面方法。
/// 关闭当前 TCP 长连接;下一次 keepAlive 或命令发送会自动重新连接。
/// </summary>
public void ResetTcpConnection()
{
lock (_syncRoot)
{
ClosePersistentConnection("manual reset");
}
}
/// <summary>
/// 发送 0x01 启动命令。
/// </summary>
/// <param name="angle">启动时同步给 AGV 的车头角度,按协议大端 ushort 写入数据区。</param>
public void SendStart(ushort angle)
{
SendCommand(CmdStart, BuildUShortOrLegacyByte(angle));
}
/// <summary>
/// 发送 0x02 停止命令。
/// </summary>
/// <param name="seconds">停止保持秒数;当前现场通常传 0 表示立即停止。</param>
public void SendStop(ushort seconds)
{
SendCommand(CmdStop, BuildUShortOrLegacyByte(seconds));
}
/// <summary>
/// 发送 0x03 速度设置命令。
/// </summary>
/// <param name="percent">速度百分比,推荐范围 0-100。</param>
public void SendSpeedPercent(byte percent)
{
SendCommand(CmdSpeed, percent);
}
/// <summary>
/// 发送 0x04 角度设置命令。
/// </summary>
/// <param name="angle">目标车头角度,单位度,按协议大端 ushort 写入数据区。</param>
public void SendAngle(ushort angle)
{
SendCommand(CmdAngle, BuildUShortOrLegacyByte(angle));
}
/// <summary>
/// 下发移动任务。
/// 默认使用 0xA1 命令同时发送起点和终点;当 <see cref="UseTaskCommand"/> 为 false 时,
/// 退回 0x05 旧版目标节点命令。
/// </summary>
/// <param name="fromNode">起点节点号,通常由当前站点或路径起点转换得到。</param>
/// <param name="toNode">终点节点号,通常由目标站点转换得到。</param>
public void SendMoveTask(ushort fromNode, ushort toNode)
{
if (UseTaskCommand)
{
DetailLog($"send move task by 0xA1, fromNode={fromNode}, toNode={toNode}");
SendCommand(CmdTask, Combine(
GetBigEndian(fromNode),
GetBigEndian(toNode),
new[] { (byte)0x03 }));
return;
}
DetailLog($"send legacy node by 0x05, toNode={toNode}");
SendCommand(CmdLegacyNode, BuildUShortOrLegacyByte(toNode));
}
/// <summary>
/// 主动查询并刷新 AGV 状态。
/// 成功后会解析响应帧并调用 <see cref="ApplyReport(MagCarReport)"/> 更新车辆运行状态。
/// </summary>
/// <returns>本次查询解析出的状态报告。</returns>
public MagCarReport RefreshState()
{
var response = SendCommand(CmdQueryState);
var report = MagCarReport.Parse(response);
ApplyReport(report);
return report;
}
/// <summary>
/// 构建并发送一条 FASS 1.0 命令帧。
/// 该方法统一负责日志、线程加锁、发送接收、响应解析和状态同步。
/// </summary>
/// <param name="command">协议命令码。</param>
/// <param name="data">需要写入数据区的协议字段,最多拷贝 26 字节。</param>
/// <returns>AGV 返回的完整 32 字节响应帧。</returns>
private byte[] SendCommand(byte command, params byte[] data)
{
var request = BuildMessage(command, data);
DetailLog($"TX {CommandName(command)}, dataLen={(data == null ? 0 : data.Length)}");
if (LogRawFrame)
{
DetailLog($"TX raw {ToHex(request)}");
}
byte[] response;
lock (_syncRoot)
{
response = SendAndReceive(request);
}
DetailLog($"RX {CommandName(command)}, bytes={response.Length}");
if (LogRawFrame)
{
DetailLog($"RX raw {ToHex(response)}");
}
if (response.Length >= 32)
{
ApplyReport(MagCarReport.Parse(response));
}
return response;
}
/// <summary>
/// 根据连接模式发送请求并读取响应。
/// 长连接和短连接都必须返回完整 32 字节响应帧。
/// </summary>
/// <param name="request">已构建好的 32 字节请求帧。</param>
/// <returns>完整 32 字节响应帧。</returns>
private byte[] SendAndReceive(byte[] request)
{
if (UsePersistentConnection)
{
return SendAndReceivePersistent(request);
}
return SendAndReceiveShort(request);
}
/// <summary>
/// 使用长连接发送请求并读取响应。
/// 发送或读取过程中出现异常时会关闭长连接,确保下一次命令重新建连。
/// </summary>
/// <param name="request">已构建好的 32 字节请求帧。</param>
/// <returns>完整 32 字节响应帧。</returns>
private byte[] SendAndReceivePersistent(byte[] request)
{
try
{
EnsurePersistentConnection();
var stream = _persistentClient.GetStream();
stream.Write(request, 0, request.Length);
stream.Flush();
return ReadExact(stream, 32);
}
catch (Exception ex)
{
ClosePersistentConnection($"persistent send/read failed: {ex.Message}");
throw;
}
}
/// <summary>
/// 确保长连接客户端已经连接。
/// 若当前连接为空或已断开,会先清理旧连接再按配置超时时间重新连接 AGV 服务端。
/// </summary>
private void EnsurePersistentConnection()
{
if (IsPersistentConnected())
{
return;
}
ClosePersistentConnection("prepare reconnect");
DetailLog($"persistent connect begin {address}:{Port}, timeout={ConnectTimeoutMs}ms");
var client = new TcpClient();
try
{
ConnectWithTimeout(client);
client.SendTimeout = SendTimeoutMs;
client.ReceiveTimeout = ReceiveTimeoutMs;
_persistentClient = client;
DetailLog($"persistent connect ok {address}:{Port}");
}
catch
{
try
{
client.Close();
}
catch
{
}
_persistentClient = null;
throw;
}
}
/// <summary>
/// 判断当前长连接对象是否存在且 TcpClient 仍处于 Connected 状态。
/// 该检查只作为发送前的快速判断,真正断链仍可能在写入或读取时暴露。
/// </summary>
/// <returns>当前长连接可复用时返回 true。</returns>
private bool IsPersistentConnected()
{
try
{
return _persistentClient != null && _persistentClient.Connected;
}
catch
{
return false;
}
}
/// <summary>
/// 关闭并释放当前长连接对象。
/// 该方法不会抛出关闭异常,保证故障恢复路径不会被二次异常打断。
/// </summary>
/// <param name="reason">关闭连接的原因,写入详细日志。</param>
private void ClosePersistentConnection(string reason)
{
var client = _persistentClient;
if (client == null)
{
return;
}
_persistentClient = null;
DetailLog($"persistent connection closed, reason={reason}");
try
{
if (client.Connected)
{
client.Client?.Shutdown(SocketShutdown.Both);
}
}
catch (Exception ex) when (IsBenignSocketFault(ex))
{
}
catch
{
}
try
{
client.Close();
}
catch (Exception ex) when (IsBenignSocketFault(ex))
{
}
catch
{
}
}
/// <summary>
/// 使用短连接兼容模式发送请求。
/// 每条命令都会新建 TCP 连接、发送、读满响应后立即释放连接。
/// </summary>
/// <param name="request">已构建好的 32 字节请求帧。</param>
/// <returns>完整 32 字节响应帧。</returns>
private byte[] SendAndReceiveShort(byte[] request)
{
using (var client = new TcpClient())
{
DetailLog($"short connect begin {address}:{Port}, timeout={ConnectTimeoutMs}ms");
ConnectWithTimeout(client);
DetailLog($"short connect ok {address}:{Port}");
client.SendTimeout = SendTimeoutMs;
client.ReceiveTimeout = ReceiveTimeoutMs;
using (var stream = client.GetStream())
{
stream.Write(request, 0, request.Length);
stream.Flush();
return ReadExact(stream, 32);
}
}
}
/// <summary>
/// 使用 <see cref="TcpClient.ConnectAsync(string, int, CancellationToken)"/> 实现带超时的 TCP 连接。
/// 避免 BeginConnect 超时后直接 Close 触发“已中止 I/O 操作”的 SocketException。
/// </summary>
/// <param name="client">待连接的 TcpClient 实例。</param>
private void ConnectWithTimeout(TcpClient client)
{
if (string.IsNullOrWhiteSpace(address))
{
throw new InvalidOperationException("MagCar address is empty");
}
using var cts = new CancellationTokenSource(ConnectTimeoutMs);
try
{
client.ConnectAsync(address, Port, cts.Token).GetAwaiter().GetResult();
}
catch (OperationCanceledException) when (cts.IsCancellationRequested)
{
AbortClientQuietly(client);
throw new TimeoutException($"connect {address}:{Port} timeout ({ConnectTimeoutMs}ms)");
}
catch (Exception ex) when (IsBenignSocketFault(ex) && cts.IsCancellationRequested)
{
AbortClientQuietly(client);
throw new TimeoutException($"connect {address}:{Port} timeout ({ConnectTimeoutMs}ms)", ex);
}
}
/// <summary>超时或取消建连时安静关闭客户端,吞掉预期的套接字中止异常。</summary>
private static void AbortClientQuietly(TcpClient client)
{
if (client == null)
{
return;
}
try
{
client.Client?.Close();
}
catch (Exception ex) when (IsBenignSocketFault(ex))
{
}
catch
{
}
try
{
client.Close();
}
catch (Exception ex) when (IsBenignSocketFault(ex))
{
}
catch
{
}
}
/// <summary>
/// 判断是否为连接关闭/超时取消导致的预期套接字异常(无需打印完整堆栈)。
/// </summary>
private static bool IsBenignSocketFault(Exception ex)
{
for (var current = ex; current != null; current = current.InnerException)
{
if (current is ObjectDisposedException)
{
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;
}
}
if (current is IOException)
{
var message = current.Message;
if (!string.IsNullOrEmpty(message) &&
(message.Contains("已中止 I/O", StringComparison.Ordinal) ||
message.Contains("I/O operation", StringComparison.OrdinalIgnoreCase) ||
message.Contains("operation was aborted", StringComparison.OrdinalIgnoreCase)))
{
return true;
}
}
}
return false;
}
/// <summary>
/// 从网络流中精确读取指定长度的数据。
/// TCP 是字节流,不能假设一次 Read 就能拿到完整 32 字节协议包,因此这里循环读取直到读满。
/// </summary>
/// <param name="stream">已连接的网络流。</param>
/// <param name="length">期望读取的字节数,MagCar 响应固定为 32。</param>
/// <returns>长度等于 <paramref name="length"/> 的字节数组。</returns>
private static byte[] ReadExact(NetworkStream stream, int length)
{
var buffer = new byte[length];
var offset = 0;
try
{
while (offset < length)
{
var read = stream.Read(buffer, offset, length - offset);
if (read == 0)
{
throw new IOException("remote closed before full MagCar response");
}
offset += read;
}
return buffer;
}
catch (Exception ex) when (IsBenignSocketFault(ex))
{
throw new IOException("remote closed before full MagCar response", ex);
}
}
/// <summary>
/// 按 FASS 1.0 格式构造 32 字节发送帧。
/// 帧结构为:起始字节、命令码、车辆编号、数据区、保留字节和结束字节。
/// </summary>
/// <param name="command">协议命令码。</param>
/// <param name="data">数据区内容,最多写入第 4-29 字节。</param>
/// <returns>完整 32 字节请求帧。</returns>
private byte[] BuildMessage(byte command, byte[] data)
{
var bytes = new byte[32];
bytes[0] = Begin;
bytes[1] = command;
var code = GetBigEndian(VehicleCode);
bytes[2] = code[0];
bytes[3] = code[1];
if (data != null && data.Length > 0)
{
Array.Copy(data, 0, bytes, 4, Math.Min(data.Length, 26));
}
bytes[30] = 0x00;
bytes[31] = End;
return bytes;
}
/// <summary>
/// 根据兼容开关构造数值字段。
/// 标准文档模式返回 ushort 大端两字节;旧版兼容模式只返回低 1 字节。
/// </summary>
/// <param name="value">待写入协议数据区的数值。</param>
/// <returns>符合当前兼容模式的字节数组。</returns>
private byte[] BuildUShortOrLegacyByte(ushort value)
{
if (UseDocumentUShortFields)
{
return GetBigEndian(value);
}
return new[] { (byte)Clamp(value, 0, 255) };
}
/// <summary>
/// 轮询 AGV 状态,直到车辆到达指定节点或超时。
/// </summary>
/// <param name="targetNode">目标节点号。</param>
/// <param name="timeoutMs">超时时间,单位毫秒。</param>
private void WaitUntilArrived(ushort targetNode, int timeoutMs)
{
var deadline = DateTime.Now.AddMilliseconds(timeoutMs <= 0 ? MoveTimeoutSeconds * 1000 : timeoutMs);
while (DateTime.Now < deadline)
{
var report = RefreshState();
if (report.Node == targetNode && report.State != 1)
{
DetailLog($"arrived node={targetNode}");
return;
}
Thread.Sleep(Math.Max(50, Math.Min(PollIntervalMs, 500)));
}
throw new TimeoutException($"MagCar {name}({id}) move timeout, target node={targetNode}, current={LastReport.Node}");
}
/// <summary>
/// 将 AGV 返回状态应用到车对象。
/// 该方法会更新 <see cref="LastReport"/>、在线状态、车头角度、status.enums
/// 并把返回的 Node 映射为场景站点后同步 <see cref="Car.siteID"/>、坐标 x/y。
/// </summary>
/// <param name="report">已解析的 AGV 状态报告。</param>
private void ApplyReport(MagCarReport report)
{
LastReport = report;
lstatus = report.Alarm != 0 ? "报警" : report.State == 0 ? "未准备" : "上线";
th = report.Angle;
status.enums["Protocol"] = "FASS1.0";
status.enums["CurrentNode"] = report.Node.ToString();
status.enums["Alarm"] = $"0x{report.Alarm:X}";
status.enums["State"] = StateText(report.State);
status.enums["Soc"] = report.Charge.ToString();
status.enums["ElectricCurrent"] = report.Current.ToString("0.###");
status.enums["Voltage"] = report.Voltage.ToString("0.###");
status.enums["Speed"] = report.Speed.ToString();
status.enums["Angle"] = report.Angle.ToString();
status.enums["Task"] = report.Task.ToString();
status.enums["Lift"] = report.Lift.ToString();
status.enums["Roll"] = report.Roll.ToString();
var site = ResolveSite(report.Node);
if (site == null)
{
haveCoordination = false;
DetailLog($"report applied without mapped site, node={report.Node}, state={StateText(report.State)}, alarm=0x{report.Alarm:X}");
return;
}
haveCoordination = true;
siteID = site.id;
x = site.x;
y = site.y;
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>
/// 将 AGV 上报的当前地标同步到交通锁(holdingLocks)。
/// GhostCar 调度依赖 holdingLocks/GetLastSite,仅改 x/y/siteID 会导致“画面在动、初始化占点不动”。
/// </summary>
private void SyncTrafficFromReport(Site site, MagCarReport report)
{
if (_running || site == null)
{
return;
}
var lockedSite = status.holdingLocks.Length > 0 ? status.holdingLocks[0] : -1;
if (lockedSite == site.id)
{
_lastSyncedTrafficSiteId = site.id;
return;
}
if (_lastSyncedTrafficSiteId == site.id && lockedSite == site.id)
{
return;
}
try
{
DetailLog($"traffic sync TrafficReset, lock={lockedSite}, targetSite={site.id}, node={report.Node}, state={StateText(report.State)}");
TrafficReset(site, true, strict: false);
_lastSyncedTrafficSiteId = site.id;
}
catch (Exception ex)
{
DetailLog($"traffic sync failed: {ex.Message}");
}
}
/// <summary>
/// 将 AGV 返回的节点号解析为场景站点。
/// 根据 <see cref="UseTagValueAsNode"/> 决定优先匹配站点 TagValue 还是直接匹配站点 id。
/// </summary>
/// <param name="node">AGV 协议返回的当前节点号。</param>
/// <returns>匹配到的站点;未匹配或节点为 0 时返回 null。</returns>
private Site ResolveSite(ushort node)
{
if (node == 0)
{
return null;
}
if (UseTagValueAsNode)
{
var byTagValue = SimpleLib.GetAllSites().FirstOrDefault(site =>
site.fields.TryGetValue("TagValue", out var tag) &&
int.TryParse(tag, out var tagValue) &&
tagValue == node);
if (byTagValue != null)
{
return byTagValue;
}
}
return SimpleLib.GetAllSites().FirstOrDefault(site => site.id == node);
}
/// <summary>
/// 将场景站点 id 转换为 AGV 节点号。
/// 当 <see cref="UseTagValueAsNode"/> 为 true 且站点配置了可解析的 TagValue 时使用 TagValue
/// 否则直接使用站点 id 并限制在 ushort 范围内。
/// </summary>
/// <param name="siteId">场景站点 id。</param>
/// <returns>下发给 AGV 的节点号。</returns>
private ushort ResolveNodeId(int siteId)
{
var site = SimpleLib.GetSite(siteId);
if (UseTagValueAsNode &&
site.fields.TryGetValue("TagValue", out var tag) &&
ushort.TryParse(tag, out var tagValue))
{
return tagValue;
}
return (ushort)Clamp(siteId, 0, ushort.MaxValue);
}
/// <summary>
/// 将路径或车辆配置中的速度换算为协议需要的百分比。
/// 输入小于等于 1 时按比例值处理;大于 1 时按已经是百分比处理。
/// </summary>
/// <param name="trackSpeed">路径速度配置;小于等于 0 时回退到车辆自身 speed 字段。</param>
/// <returns>0-100 范围内的速度百分比。</returns>
private byte SpeedToPercent(double trackSpeed)
{
if (trackSpeed <= 0)
{
return (byte)Clamp((int)Math.Round(speed * 100), 0, 100);
}
if (trackSpeed <= 1)
{
return (byte)Clamp((int)Math.Round(trackSpeed * 100), 0, 100);
}
return (byte)Clamp((int)Math.Round(trackSpeed), 0, 100);
}
/// <summary>
/// 根据起点和终点坐标计算车头方向角。
/// 支持反向行驶、角度偏置和绝对角度覆盖。
/// </summary>
/// <param name="srcX">起点 X 坐标。</param>
/// <param name="srcY">起点 Y 坐标。</param>
/// <param name="dstX">终点 X 坐标。</param>
/// <param name="dstY">终点 Y 坐标。</param>
/// <param name="reverse">是否按反向行驶计算,true 时角度增加 180 度。</param>
/// <param name="bias">额外角度偏置,单位度。</param>
/// <param name="enableAbs">是否直接使用绝对角度。</param>
/// <param name="absDir">启用绝对角度时使用的方向角。</param>
/// <returns>归一化到 0-359 的方向角。</returns>
private static ushort DirectionAngle(double srcX, double srcY, double dstX, double dstY, bool reverse, double bias, bool enableAbs, double absDir)
{
var angle = enableAbs ? absDir : Math.Atan2(dstY - srcY, dstX - srcX) / Math.PI * 180.0;
if (reverse)
{
angle += 180;
}
angle += bias;
angle %= 360;
if (angle < 0)
{
angle += 360;
}
return (ushort)Math.Round(angle);
}
/// <summary>
/// 将 ushort 转换为大端字节序。
/// FASS 1.0 协议中的车辆编号、节点号和角度字段均按大端写入。
/// </summary>
/// <param name="value">待转换的 ushort 值。</param>
/// <returns>长度为 2 的大端字节数组。</returns>
private static byte[] GetBigEndian(ushort value)
{
var bytes = BitConverter.GetBytes(value);
if (BitConverter.IsLittleEndian)
{
Array.Reverse(bytes);
}
return bytes;
}
/// <summary>
/// 顺序拼接多个字节数组。
/// 主要用于组装 0xA1 任务命令的数据区。
/// </summary>
/// <param name="arrays">需要按顺序拼接的字节数组集合。</param>
/// <returns>拼接后的新字节数组。</returns>
private static byte[] Combine(params byte[][] arrays)
{
var result = new byte[arrays.Sum(array => array.Length)];
var offset = 0;
foreach (var array in arrays)
{
Buffer.BlockCopy(array, 0, result, offset, array.Length);
offset += array.Length;
}
return result;
}
/// <summary>
/// 将整数限制在指定闭区间内。
/// </summary>
/// <param name="value">原始值。</param>
/// <param name="min">允许的最小值。</param>
/// <param name="max">允许的最大值。</param>
/// <returns>限制后的值。</returns>
private static int Clamp(int value, int min, int max)
{
if (value < min)
{
return min;
}
return value > max ? max : value;
}
/// <summary>
/// 将 ushort 值限制在指定闭区间内。
/// </summary>
/// <param name="value">原始 ushort 值。</param>
/// <param name="min">允许的最小值。</param>
/// <param name="max">允许的最大值。</param>
/// <returns>限制后的值。</returns>
private static int Clamp(ushort value, int min, int max)
{
return Clamp((int)value, min, max);
}
/// <summary>
/// 将协议状态字节转换为便于日志和界面阅读的中文文本。
/// </summary>
/// <param name="state">AGV 返回帧第 14 字节的状态码。</param>
/// <returns>状态文本;未知状态会带出原始状态码。</returns>
private static string StateText(byte state)
{
switch (state)
{
case 0x00:
return "未准备";
case 0x01:
return "运行中";
case 0x02:
return "停止中";
case 0x03:
return "充电中";
default:
return $"未知({state})";
}
}
/// <summary>
/// 输出 MagCar 专用详细日志。
/// 日志同时写入车辆调试信息和 Diagnosis 通道,便于现场排查通讯问题。
/// </summary>
/// <param name="message">需要记录的日志正文。</param>
private void DetailLog(string message)
{
if (!EnableDetailLog)
{
return;
}
var text = $"[MagCar:{name}({id})] {DateTime.Now:HH:mm:ss.fff} {message}";
AppendDebug(text);
Diagnosis.Post(text, "MagCar", true);
}
/// <summary>
/// 将协议命令码转换为带十六进制值的命令名称。
/// </summary>
/// <param name="command">协议命令码。</param>
/// <returns>用于日志输出的命令名称。</returns>
private static string CommandName(byte command)
{
switch (command)
{
case CmdQueryState:
return "QueryState(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})";
}
}
/// <summary>
/// 将字节数组格式化为空格分隔的十六进制字符串。
/// 用于输出原始协议帧日志。
/// </summary>
/// <param name="bytes">待格式化的字节数组。</param>
/// <returns>十六进制字符串;输入为 null 时返回空字符串。</returns>
private static string ToHex(byte[] bytes)
{
return bytes == null ? string.Empty : BitConverter.ToString(bytes).Replace("-", " ");
}
/// <summary>
/// MagCar 调度脚本执行接口,将路径段转换为 FASS 1.0 移动任务。
/// </summary>
internal sealed class MagCarInterface : AGVInterface
{
private readonly MagCar _car;
public MagCarInterface(int id)
{
_car = (MagCar)SimpleLib.GetCar(id);
}
public override bool TryLock(int siteId)
{
if (!_car.status.usage.Get().scheduling)
{
throw new Exception("abandoned");
}
return TrafficControl.TryLock(_car, siteId);
}
public override void Leave(int siteId)
{
TrafficControl.Leave(_car, siteId);
}
public void MagCarGo(int srcId, int dstId, double speed = -1)
{
var promise = new TaskCompletionSource<int>();
Queue(async () =>
{
while (!TryLock(dstId))
{
await Task.Delay(50);
}
var fromNode = _car.ResolveNodeId(srcId);
var toNode = _car.ResolveNodeId(dstId);
_car.DetailLog($"MagCarGo src={srcId}, dst={dstId}, fromNode={fromNode}, toNode={toNode}, speed={speed}");
if (_car.StartBeforeMove)
{
_car.SendStart((ushort)Math.Round(_car.th));
}
_car.SendSpeedPercent(_car.SpeedToPercent(speed));
_car.SendMoveTask(fromNode, toNode);
_car.WaitUntilArrived(toNode, _car.MoveTimeoutSeconds * 1000);
promise.SetResult(1);
}, async () =>
{
await promise.Task;
Leave(srcId);
});
}
}
/// <summary>
/// AGV 返回的 32 字节状态帧解析结果。
/// 字段位置遵循 FASS 1.0 协议:Node 在第 4-5 字节,Alarm 在第 6-13 字节,
/// State 在第 14 字节,Charge 在第 15 字节,Current/Voltage 为大端 float。
/// </summary>
public class MagCarReport
{
/// <summary>
/// 响应帧中的命令码,通常与请求命令码一致,用于确认本次返回对应的命令类型。
/// </summary>
public byte Command { get; private set; }
/// <summary>
/// 响应帧中的车辆编号,来自第 2-3 字节,按大端 ushort 解析。
/// </summary>
public ushort Code { get; private set; }
/// <summary>
/// AGV 当前所在节点号,来自第 4-5 字节。
/// <see cref="ApplyReport(MagCarReport)"/> 会将该值映射到场景站点并更新车辆坐标。
/// </summary>
public ushort Node { get; private set; }
/// <summary>
/// AGV 报警位图,来自第 6-13 字节。
/// 值为 0 表示无报警,非 0 时车辆在线状态会显示为报警。
/// </summary>
public ulong Alarm { get; private set; }
/// <summary>
/// AGV 运行状态码,来自第 14 字节。
/// 当前按 0 未准备、1 运行中、2 停止中、3 充电中展示。
/// </summary>
public byte State { get; private set; }
/// <summary>
/// 电量百分比,来自第 15 字节,也会写入 status.enums["Soc"]。
/// </summary>
public byte Charge { get; private set; }
/// <summary>
/// 当前电流,来自第 16-19 字节,按大端 IEEE754 float 解析。
/// </summary>
public float Current { get; private set; }
/// <summary>
/// 当前电压,来自第 20-23 字节,按大端 IEEE754 float 解析。
/// </summary>
public float Voltage { get; private set; }
/// <summary>
/// AGV 当前速度百分比或协议返回速度值,来自第 24 字节。
/// </summary>
public byte Speed { get; private set; }
/// <summary>
/// AGV 当前车头角度,来自第 25-26 字节,按大端 ushort 解析。
/// 该值会同步到车辆 th 字段。
/// </summary>
public ushort Angle { get; private set; }
/// <summary>
/// 当前任务状态或任务编号,来自第 27 字节。
/// </summary>
public byte Task { get; private set; }
/// <summary>
/// 顶升机构状态,来自第 28 字节。
/// </summary>
public byte Lift { get; private set; }
/// <summary>
/// 滚筒机构状态,来自第 29 字节。
/// </summary>
public byte Roll { get; private set; }
/// <summary>
/// 将 AGV 返回的原始 32 字节帧解析为 <see cref="MagCarReport"/>。
/// 方法会校验帧长、起始字节和结束字节,字段按 FASS 1.0 大端格式读取。
/// </summary>
/// <param name="bytes">AGV 返回的原始响应帧。</param>
/// <returns>解析后的状态报告。</returns>
public static MagCarReport Parse(byte[] bytes)
{
if (bytes == null || bytes.Length < 32)
{
throw new ArgumentException("MagCar response must be 32 bytes");
}
if (bytes[0] != Begin || bytes[31] != End)
{
throw new ArgumentException($"invalid MagCar frame: begin=0x{bytes[0]:X2}, end=0x{bytes[31]:X2}");
}
return new MagCarReport
{
Command = bytes[1],
Code = ReadUInt16(bytes, 2),
Node = ReadUInt16(bytes, 4),
Alarm = ReadUInt64(bytes, 6),
State = bytes[14],
Charge = bytes[15],
Current = ReadSingle(bytes, 16),
Voltage = ReadSingle(bytes, 20),
Speed = bytes[24],
Angle = ReadUInt16(bytes, 25),
Task = bytes[27],
Lift = bytes[28],
Roll = bytes[29]
};
}
/// <summary>
/// 从指定偏移读取大端 ushort。
/// </summary>
/// <param name="bytes">来源字节数组。</param>
/// <param name="offset">字段起始偏移。</param>
/// <returns>解析出的 ushort 值。</returns>
private static ushort ReadUInt16(byte[] bytes, int offset)
{
return (ushort)((bytes[offset] << 8) | bytes[offset + 1]);
}
/// <summary>
/// 从指定偏移读取大端 ulong。
/// 报警位图占用 8 字节,因此使用该方法解析 Alarm 字段。
/// </summary>
/// <param name="bytes">来源字节数组。</param>
/// <param name="offset">字段起始偏移。</param>
/// <returns>解析出的 ulong 值。</returns>
private static ulong ReadUInt64(byte[] bytes, int offset)
{
var result = 0UL;
for (var i = 0; i < 8; i++)
{
result = (result << 8) | bytes[offset + i];
}
return result;
}
/// <summary>
/// 从指定偏移读取大端单精度浮点数。
/// 当前用于解析电流和电压字段。
/// </summary>
/// <param name="bytes">来源字节数组。</param>
/// <param name="offset">字段起始偏移。</param>
/// <returns>解析出的 float 值。</returns>
private static float ReadSingle(byte[] bytes, int offset)
{
var buffer = new byte[4];
Buffer.BlockCopy(bytes, offset, buffer, 0, 4);
if (BitConverter.IsLittleEndian)
{
Array.Reverse(buffer);
}
return BitConverter.ToSingle(buffer, 0);
}
}
}
}