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); } } } }