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
{
///
/// 基于 FASS 1.0 小车 TCP 协议实现的磁导航小车类型。
/// 该类型直接按 32 字节协议帧与现场 AGV 服务端通讯,通过 执行调度脚本。
/// 默认使用长连接发送命令和读取状态;现场服务端不支持长连接时,可关闭 回退到短连接兼容模式。
///
[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
{
///
/// FASS 1.0 协议帧起始字节,所有发送和接收帧第 0 字节均应为 0xBB。
///
private const byte Begin = 0xBB;
///
/// FASS 1.0 协议帧结束字节,所有发送和接收帧第 31 字节均应为 0xEE。
///
private const byte End = 0xEE;
///
/// 查询 AGV 当前状态的命令码,响应中会带回节点、报警、状态、电量、速度、角度等信息。
///
private const byte CmdQueryState = 0x00;
///
/// 启动或解除停止状态的命令码,数据区通常携带当前车头角度。
///
private const byte CmdStart = 0x01;
///
/// 停止命令码,数据区可携带停止保持秒数;当前实现通常传 0 表示立即停止。
///
private const byte CmdStop = 0x02;
///
/// 设置速度百分比的命令码,数据区第一个字节为 0-100 的速度百分比。
///
private const byte CmdSpeed = 0x03;
///
/// 设置车头角度的命令码,数据区携带角度值。
///
private const byte CmdAngle = 0x04;
///
/// 旧版节点下发命令码,只下发目标节点,用于兼容不支持 0xA1 任务命令的现场服务端。
///
private const byte CmdLegacyNode = 0x05;
///
/// FASS 1.0 任务下发命令码,数据区携带起点节点、终点节点和任务动作。
///
private const byte CmdTask = 0xA1;
///
/// TCP 发送、接收和长连接重连的同步锁。
/// 多线程调度、keepAlive 和人工方法可能同时触发通讯,统一加锁可避免多个线程同时写同一个 Socket。
///
private readonly object _syncRoot = new object();
///
/// 长连接模式下复用的 TCP 客户端。
/// 发送或读取失败时会关闭并置空,下一次命令会自动重新连接。
///
private TcpClient _persistentClient;
///
/// 最近一次 keepAlive 实际查询小车状态的时间。
/// 外部框架可能高频调用 keepAlive,本字段用于按 节流实际 TCP 查询频率。
///
private DateTime _lastPollTime = DateTime.MinValue;
///
/// AGV 服务端监听端口,默认 5000。
///
[FieldMember] public int Port = 5000;
///
/// 协议中的车辆编号,写入发送帧第 2-3 字节,使用大端字节序。
/// 多车共用同一服务端时用于区分车辆。
///
[FieldMember] public ushort VehicleCode = 0;
///
/// TCP 建连超时时间,单位毫秒。
/// 长连接首次连接或重连、短连接每次命令连接时都会使用该值。
///
[FieldMember] public int ConnectTimeoutMs = 500;
///
/// TCP 发送超时时间,单位毫秒。
///
[FieldMember] public int SendTimeoutMs = 500;
///
/// TCP 接收超时时间,单位毫秒。
/// 读取响应时会一直读满 32 字节,超过该时间仍未收到完整帧则抛出异常。
///
[FieldMember] public int ReceiveTimeoutMs = 500;
///
/// keepAlive 中实际查询 AGV 状态的最小间隔,单位毫秒。
/// 默认 1000,表示最多每秒向小车查询一次状态。
///
[FieldMember] public int PollIntervalMs = 1000;
///
/// 移动任务的预留超时时间,单位秒。
/// 当前 MagCar 不下发脚本,保留该字段用于后续任务调度或界面配置兼容。
///
[FieldMember] public int MoveTimeoutSeconds = 120;
///
/// 是否使用 0xA1 任务命令下发起点和终点。
/// 为 false 时改用 0x05 旧版目标节点命令,适配早期或定制 AGV 服务端。
///
[FieldMember] public bool UseTaskCommand = true;
///
/// 移动前是否先发送启动命令。
/// 当前文件保留该配置以兼容调度策略,具体移动流程可在后续路径执行逻辑中使用。
///
[FieldMember] public bool StartBeforeMove = true;
///
/// 是否按接口文档使用 ushort 大端字段写入角度、节点、停止秒数等数据。
/// 为 false 时只写入低 1 字节,用于兼容旧版 0x05 单字节节点协议。
///
[FieldMember] public bool UseDocumentUShortFields = true;
///
/// 是否使用站点字段 TagValue 作为 AGV 节点号。
/// 为 true 时,收到的 Node 会先匹配站点 fields["TagValue"];为 false 时直接用站点 id 匹配。
///
[FieldMember] public bool UseTagValueAsNode = false;
///
/// 是否启用 TCP 长连接。
/// 默认 true;若现场 AGV 服务端不支持同一连接多次收发,可设为 false 回退到每条命令新建一次短连接。
///
[FieldMember] public bool UsePersistentConnection = true;
///
/// 是否输出 MagCar 详细日志。
/// 打开后会记录连接、发送、接收、状态解析、站点映射和异常信息。
///
[FieldMember] public bool EnableDetailLog = true;
///
/// 是否在详细日志中输出原始 32 字节协议帧。
/// 调试协议字段时建议打开;现场日志量过大时可关闭。
///
[FieldMember] public bool LogRawFrame = true;
///
/// 小车绘制长度,单位与场景坐标一致,默认 1200。
/// 仅影响画面显示,不参与 AGV 协议通讯。
///
[FieldMember] public float CarLength = 1200;
///
/// 小车绘制宽度,单位与场景坐标一致,默认 800。
/// 仅影响画面显示,不参与 AGV 协议通讯。
///
[FieldMember] public float CarWidth = 800;
///
/// 最近一次从 AGV 返回帧中解析出的完整状态快照。
/// UI 显示、状态枚举和站点定位均以该对象为基础。
///
public MagCarReport LastReport { get; private set; } = new MagCarReport();
///
/// 创建 MagCar 默认实例,供 StandardScene 通过车型注册机制实例化。
/// 默认地址为 127.0.0.1,实际现场地址应在车属性中配置。
///
/// 初始化后的 MagCar 实例。
public new static async Task Create()
{
return new MagCar
{
lstatus = "连接中",
address = "127.0.0.1",
name = "MagCar",
haveCoordination = true,
speed = 1
};
}
///
/// 在场景画布中绘制小车外形和朝向箭头。
/// 绘制尺寸来自 和 。
///
/// 场景传入的绘图对象,坐标系已由基类转换到小车本地坐标。
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);
}
}
///
/// 生成场景中小车悬浮或列表展示的简要状态文本。
/// 内容包括车辆名称、站点、电量、速度和报警摘要。
///
/// 用于界面显示的多行状态字符串。
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状态未知";
}
}
///
/// StandardScene 周期调用的保活入口。
/// 方法内部按 节流,实际执行时发送 0x00 查询状态命令,
/// 并将返回的 Node、状态、电量、报警等字段同步到车对象和 status.enums。
///
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;
///
/// 最近一次已同步到交通管控层的站点 id。
/// keepAlive 仅更新 x/y 时,holdingLocks 不会自动跟随,需要显式同步。
///
private int _lastSyncedTrafficSiteId = -1;
///
/// 执行调度编译出的移动脚本。
/// 脚本中的 agv.MagCarGo 会通过 FASS 1.0 协议下发移动任务,并轮询状态直到到达目标节点。
///
/// 调度层编译出的脚本文本。
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();
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状态")]
///
/// 人工查询 AGV 状态的界面方法。
/// 调用后立即发送 0x00 查询命令,并用返回帧刷新 、站点位置和 status.enums。
///
public void QueryState()
{
RefreshState();
}
[MethodMember(Name = "启动", Description = "发送启动指令")]
///
/// 人工启动小车的界面方法。
/// 按当前车头角度发送 0x01 启动命令。
///
public void StartMagCar()
{
SendStart((ushort)Math.Round(th));
}
[MethodMember(Name = "停止", Description = "发送停止指令")]
///
/// 人工停止小车的界面方法。
/// 发送 0x02 停止命令,当前停止秒数传 0。
///
public void StopMagCar()
{
SendStop(0);
}
[MethodMember(Name = "进入小车远程", Description = "进入小车远程桌面")]
[I18N.DocumentTranslation(Name = "Open remote desktop", Description = "Open the car's remote desktop", locale = "en")]
///
/// 打开 Windows 远程桌面客户端并连接到当前小车地址。
/// 该方法只用于运维进入车载电脑,不参与 AGV 协议通讯。
///
public void Mstsc()
{
Process.Start(
new ProcessStartInfo
{
FileName = "mstsc",
Arguments = $"/v:{address}",
UseShellExecute = false,
CreateNoWindow = true
}
);
}
///
/// 调度系统触发的急停入口。
/// 记录急停原因后发送 0x02 停止命令。
///
/// 触发急停的上层原因,写入诊断日志便于追查。
public void EmergencyStop(string reason)
{
Diagnosis.Post($"car{name}:MagCar EmergencyStop {reason}");
SendStop(0);
}
///
/// 调度系统触发的急停释放入口。
/// 记录释放日志后按当前车头角度发送 0x01 启动命令。
///
public void EmergencyRelease()
{
Diagnosis.Post($"car{name}:MagCar EmergencyRelease");
SendStart((ushort)Math.Round(th));
}
[MethodMember(Name = "重置TCP连接", Description = "关闭当前长连接,下次命令自动重连")]
///
/// 人工重置长连接的界面方法。
/// 关闭当前 TCP 长连接;下一次 keepAlive 或命令发送会自动重新连接。
///
public void ResetTcpConnection()
{
lock (_syncRoot)
{
ClosePersistentConnection("manual reset");
}
}
///
/// 发送 0x01 启动命令。
///
/// 启动时同步给 AGV 的车头角度,按协议大端 ushort 写入数据区。
public void SendStart(ushort angle)
{
SendCommand(CmdStart, BuildUShortOrLegacyByte(angle));
}
///
/// 发送 0x02 停止命令。
///
/// 停止保持秒数;当前现场通常传 0 表示立即停止。
public void SendStop(ushort seconds)
{
SendCommand(CmdStop, BuildUShortOrLegacyByte(seconds));
}
///
/// 发送 0x03 速度设置命令。
///
/// 速度百分比,推荐范围 0-100。
public void SendSpeedPercent(byte percent)
{
SendCommand(CmdSpeed, percent);
}
///
/// 发送 0x04 角度设置命令。
///
/// 目标车头角度,单位度,按协议大端 ushort 写入数据区。
public void SendAngle(ushort angle)
{
SendCommand(CmdAngle, BuildUShortOrLegacyByte(angle));
}
///
/// 下发移动任务。
/// 默认使用 0xA1 命令同时发送起点和终点;当 为 false 时,
/// 退回 0x05 旧版目标节点命令。
///
/// 起点节点号,通常由当前站点或路径起点转换得到。
/// 终点节点号,通常由目标站点转换得到。
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));
}
///
/// 主动查询并刷新 AGV 状态。
/// 成功后会解析响应帧并调用 更新车辆运行状态。
///
/// 本次查询解析出的状态报告。
public MagCarReport RefreshState()
{
var response = SendCommand(CmdQueryState);
var report = MagCarReport.Parse(response);
ApplyReport(report);
return report;
}
///
/// 构建并发送一条 FASS 1.0 命令帧。
/// 该方法统一负责日志、线程加锁、发送接收、响应解析和状态同步。
///
/// 协议命令码。
/// 需要写入数据区的协议字段,最多拷贝 26 字节。
/// AGV 返回的完整 32 字节响应帧。
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;
}
///
/// 根据连接模式发送请求并读取响应。
/// 长连接和短连接都必须返回完整 32 字节响应帧。
///
/// 已构建好的 32 字节请求帧。
/// 完整 32 字节响应帧。
private byte[] SendAndReceive(byte[] request)
{
if (UsePersistentConnection)
{
return SendAndReceivePersistent(request);
}
return SendAndReceiveShort(request);
}
///
/// 使用长连接发送请求并读取响应。
/// 发送或读取过程中出现异常时会关闭长连接,确保下一次命令重新建连。
///
/// 已构建好的 32 字节请求帧。
/// 完整 32 字节响应帧。
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;
}
}
///
/// 确保长连接客户端已经连接。
/// 若当前连接为空或已断开,会先清理旧连接再按配置超时时间重新连接 AGV 服务端。
///
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;
}
}
///
/// 判断当前长连接对象是否存在且 TcpClient 仍处于 Connected 状态。
/// 该检查只作为发送前的快速判断,真正断链仍可能在写入或读取时暴露。
///
/// 当前长连接可复用时返回 true。
private bool IsPersistentConnected()
{
try
{
return _persistentClient != null && _persistentClient.Connected;
}
catch
{
return false;
}
}
///
/// 关闭并释放当前长连接对象。
/// 该方法不会抛出关闭异常,保证故障恢复路径不会被二次异常打断。
///
/// 关闭连接的原因,写入详细日志。
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
{
}
}
///
/// 使用短连接兼容模式发送请求。
/// 每条命令都会新建 TCP 连接、发送、读满响应后立即释放连接。
///
/// 已构建好的 32 字节请求帧。
/// 完整 32 字节响应帧。
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);
}
}
}
///
/// 使用 实现带超时的 TCP 连接。
/// 避免 BeginConnect 超时后直接 Close 触发“已中止 I/O 操作”的 SocketException。
///
/// 待连接的 TcpClient 实例。
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);
}
}
/// 超时或取消建连时安静关闭客户端,吞掉预期的套接字中止异常。
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
{
}
}
///
/// 判断是否为连接关闭/超时取消导致的预期套接字异常(无需打印完整堆栈)。
///
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;
}
///
/// 从网络流中精确读取指定长度的数据。
/// TCP 是字节流,不能假设一次 Read 就能拿到完整 32 字节协议包,因此这里循环读取直到读满。
///
/// 已连接的网络流。
/// 期望读取的字节数,MagCar 响应固定为 32。
/// 长度等于 的字节数组。
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);
}
}
///
/// 按 FASS 1.0 格式构造 32 字节发送帧。
/// 帧结构为:起始字节、命令码、车辆编号、数据区、保留字节和结束字节。
///
/// 协议命令码。
/// 数据区内容,最多写入第 4-29 字节。
/// 完整 32 字节请求帧。
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;
}
///
/// 根据兼容开关构造数值字段。
/// 标准文档模式返回 ushort 大端两字节;旧版兼容模式只返回低 1 字节。
///
/// 待写入协议数据区的数值。
/// 符合当前兼容模式的字节数组。
private byte[] BuildUShortOrLegacyByte(ushort value)
{
if (UseDocumentUShortFields)
{
return GetBigEndian(value);
}
return new[] { (byte)Clamp(value, 0, 255) };
}
///
/// 轮询 AGV 状态,直到车辆到达指定节点或超时。
///
/// 目标节点号。
/// 超时时间,单位毫秒。
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}");
}
///
/// 将 AGV 返回状态应用到车对象。
/// 该方法会更新 、在线状态、车头角度、status.enums,
/// 并把返回的 Node 映射为场景站点后同步 、坐标 x/y。
///
/// 已解析的 AGV 状态报告。
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}");
}
///
/// 将 AGV 上报的当前地标同步到交通锁(holdingLocks)。
/// GhostCar 调度依赖 holdingLocks/GetLastSite,仅改 x/y/siteID 会导致“画面在动、初始化占点不动”。
///
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}");
}
}
///
/// 将 AGV 返回的节点号解析为场景站点。
/// 根据 决定优先匹配站点 TagValue 还是直接匹配站点 id。
///
/// AGV 协议返回的当前节点号。
/// 匹配到的站点;未匹配或节点为 0 时返回 null。
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);
}
///
/// 将场景站点 id 转换为 AGV 节点号。
/// 当 为 true 且站点配置了可解析的 TagValue 时使用 TagValue,
/// 否则直接使用站点 id 并限制在 ushort 范围内。
///
/// 场景站点 id。
/// 下发给 AGV 的节点号。
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);
}
///
/// 将路径或车辆配置中的速度换算为协议需要的百分比。
/// 输入小于等于 1 时按比例值处理;大于 1 时按已经是百分比处理。
///
/// 路径速度配置;小于等于 0 时回退到车辆自身 speed 字段。
/// 0-100 范围内的速度百分比。
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);
}
///
/// 根据起点和终点坐标计算车头方向角。
/// 支持反向行驶、角度偏置和绝对角度覆盖。
///
/// 起点 X 坐标。
/// 起点 Y 坐标。
/// 终点 X 坐标。
/// 终点 Y 坐标。
/// 是否按反向行驶计算,true 时角度增加 180 度。
/// 额外角度偏置,单位度。
/// 是否直接使用绝对角度。
/// 启用绝对角度时使用的方向角。
/// 归一化到 0-359 的方向角。
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);
}
///
/// 将 ushort 转换为大端字节序。
/// FASS 1.0 协议中的车辆编号、节点号和角度字段均按大端写入。
///
/// 待转换的 ushort 值。
/// 长度为 2 的大端字节数组。
private static byte[] GetBigEndian(ushort value)
{
var bytes = BitConverter.GetBytes(value);
if (BitConverter.IsLittleEndian)
{
Array.Reverse(bytes);
}
return bytes;
}
///
/// 顺序拼接多个字节数组。
/// 主要用于组装 0xA1 任务命令的数据区。
///
/// 需要按顺序拼接的字节数组集合。
/// 拼接后的新字节数组。
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;
}
///
/// 将整数限制在指定闭区间内。
///
/// 原始值。
/// 允许的最小值。
/// 允许的最大值。
/// 限制后的值。
private static int Clamp(int value, int min, int max)
{
if (value < min)
{
return min;
}
return value > max ? max : value;
}
///
/// 将 ushort 值限制在指定闭区间内。
///
/// 原始 ushort 值。
/// 允许的最小值。
/// 允许的最大值。
/// 限制后的值。
private static int Clamp(ushort value, int min, int max)
{
return Clamp((int)value, min, max);
}
///
/// 将协议状态字节转换为便于日志和界面阅读的中文文本。
///
/// AGV 返回帧第 14 字节的状态码。
/// 状态文本;未知状态会带出原始状态码。
private static string StateText(byte state)
{
switch (state)
{
case 0x00:
return "未准备";
case 0x01:
return "运行中";
case 0x02:
return "停止中";
case 0x03:
return "充电中";
default:
return $"未知({state})";
}
}
///
/// 输出 MagCar 专用详细日志。
/// 日志同时写入车辆调试信息和 Diagnosis 通道,便于现场排查通讯问题。
///
/// 需要记录的日志正文。
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);
}
///
/// 将协议命令码转换为带十六进制值的命令名称。
///
/// 协议命令码。
/// 用于日志输出的命令名称。
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})";
}
}
///
/// 将字节数组格式化为空格分隔的十六进制字符串。
/// 用于输出原始协议帧日志。
///
/// 待格式化的字节数组。
/// 十六进制字符串;输入为 null 时返回空字符串。
private static string ToHex(byte[] bytes)
{
return bytes == null ? string.Empty : BitConverter.ToString(bytes).Replace("-", " ");
}
///
/// MagCar 调度脚本执行接口,将路径段转换为 FASS 1.0 移动任务。
///
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();
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);
});
}
}
///
/// AGV 返回的 32 字节状态帧解析结果。
/// 字段位置遵循 FASS 1.0 协议:Node 在第 4-5 字节,Alarm 在第 6-13 字节,
/// State 在第 14 字节,Charge 在第 15 字节,Current/Voltage 为大端 float。
///
public class MagCarReport
{
///
/// 响应帧中的命令码,通常与请求命令码一致,用于确认本次返回对应的命令类型。
///
public byte Command { get; private set; }
///
/// 响应帧中的车辆编号,来自第 2-3 字节,按大端 ushort 解析。
///
public ushort Code { get; private set; }
///
/// AGV 当前所在节点号,来自第 4-5 字节。
/// 会将该值映射到场景站点并更新车辆坐标。
///
public ushort Node { get; private set; }
///
/// AGV 报警位图,来自第 6-13 字节。
/// 值为 0 表示无报警,非 0 时车辆在线状态会显示为报警。
///
public ulong Alarm { get; private set; }
///
/// AGV 运行状态码,来自第 14 字节。
/// 当前按 0 未准备、1 运行中、2 停止中、3 充电中展示。
///
public byte State { get; private set; }
///
/// 电量百分比,来自第 15 字节,也会写入 status.enums["Soc"]。
///
public byte Charge { get; private set; }
///
/// 当前电流,来自第 16-19 字节,按大端 IEEE754 float 解析。
///
public float Current { get; private set; }
///
/// 当前电压,来自第 20-23 字节,按大端 IEEE754 float 解析。
///
public float Voltage { get; private set; }
///
/// AGV 当前速度百分比或协议返回速度值,来自第 24 字节。
///
public byte Speed { get; private set; }
///
/// AGV 当前车头角度,来自第 25-26 字节,按大端 ushort 解析。
/// 该值会同步到车辆 th 字段。
///
public ushort Angle { get; private set; }
///
/// 当前任务状态或任务编号,来自第 27 字节。
///
public byte Task { get; private set; }
///
/// 顶升机构状态,来自第 28 字节。
///
public byte Lift { get; private set; }
///
/// 滚筒机构状态,来自第 29 字节。
///
public byte Roll { get; private set; }
///
/// 将 AGV 返回的原始 32 字节帧解析为 。
/// 方法会校验帧长、起始字节和结束字节,字段按 FASS 1.0 大端格式读取。
///
/// AGV 返回的原始响应帧。
/// 解析后的状态报告。
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]
};
}
///
/// 从指定偏移读取大端 ushort。
///
/// 来源字节数组。
/// 字段起始偏移。
/// 解析出的 ushort 值。
private static ushort ReadUInt16(byte[] bytes, int offset)
{
return (ushort)((bytes[offset] << 8) | bytes[offset + 1]);
}
///
/// 从指定偏移读取大端 ulong。
/// 报警位图占用 8 字节,因此使用该方法解析 Alarm 字段。
///
/// 来源字节数组。
/// 字段起始偏移。
/// 解析出的 ulong 值。
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;
}
///
/// 从指定偏移读取大端单精度浮点数。
/// 当前用于解析电流和电压字段。
///
/// 来源字节数组。
/// 字段起始偏移。
/// 解析出的 float 值。
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);
}
}
}
}