using LessokajiWeaverUtilities.Utilities;
using SimpleCore;
using SimpleCore.Compiler;
using SimpleCore.BasicProps;
using SimpleCore.Library;
using SimpleCore.PropType;
using SimpleCore.Traffic;
using SimpleLite.Props;
using SimpleLite.RCS;
using SimpleLite.RCS.CarTypes;
using SimpleLite.UI;
using StandardScene.Magnetic.Protocol;
using StandardScene.Magnetic.Tasking;
using StandardScene;
using System;
using System.Diagnostics;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.IO;
using System.Linq;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
namespace StandardScene.CarTypes
{
///
/// 基于 FASS 2.0 通用控制接口实现的磁导航小车类型。
/// 支持 PLC/TCP(主动查询)与 PCB/UDP(被动收状态 + 0x10 应答)两种通讯模式。
/// 路径段通过 0xB1 下发,业务逻辑对齐 FASS.Scheduler Fairyland 调度实现。
///
[TemplateTrackCoderSettings(
priority = 1,
templateString = "agv.MagFass2Go(${src.id},${dst.id},${track.Speed});",
trackFields = typeof(BasicTrackFields))]
[CarType(Name = "MagFass2Car")]
[I18N.DocumentTranslation(Name = "MagFass2Car", locale = "en")]
[EnvelopConfig(lengthX = 1200, lengthY = 800, centerX = 0, centerY = 0)]
public class MagFass2Car : GhostCar, IFass2UdpCar, IFass2LoopCar
{
private readonly object _syncRoot = new object();
private TcpClient _persistentClient;
private DateTime _lastPollTime = DateTime.MinValue;
private DateTime _lastUdpReportTime = DateTime.MinValue;
private bool _udpRegistered;
private ushort _registeredVehicleCode = ushort.MaxValue;
private long _nextTaskId = 1;
private long _nextActionId = 1;
private Fass2TaskStateMachine _taskStateMachine;
private bool _taskRestoreAttempted;
[FieldMember] public Fass2CommMode CommMode = Fass2CommMode.Udp;
[FieldMember] public int Port = 5000;
[FieldMember] public int ListenPort = 20103;
[FieldMember] public ushort VehicleCode = 0;
[FieldMember] public int ConnectTimeoutMs = 500;
[FieldMember] public int SendTimeoutMs = 500;
[FieldMember] public int ReceiveTimeoutMs = 500;
[FieldMember] public int PollIntervalMs = 1000;
[FieldMember] public int UdpOfflineTimeoutMs = 15000;
[FieldMember] public int MoveTimeoutSeconds = 120;
[FieldMember] public int LockCount = 4;
[FieldMember] public int TaskResendIntervalMs = 500;
[FieldMember] public int ActionRetryIntervalMs = 1000;
[FieldMember] public bool EnableTaskStateMachine = true;
[FieldMember] public bool EnableLoopTaskDrive = true;
[FieldMember] public bool StartBeforeMove = true;
[FieldMember] public bool UseTagValueAsNode = false;
[FieldMember] public bool UsePersistentConnection = true;
[FieldMember] public bool EnableDetailLog = true;
[FieldMember] public bool LogRawFrame = true;
[FieldMember] public bool EnableFileLog = true;
[FieldMember] public string LogDirectory = "logs";
[FieldMember] public float CarLength = 1200;
[FieldMember] public float CarWidth = 800;
public Fass2StateReport LastReport { get; private set; } = new Fass2StateReport();
public bool IsTaskIdle => _taskStateMachine?.IsIdle ?? true;
public Fass2TaskPhase TaskPhase => _taskStateMachine?.Context.Phase ?? Fass2TaskPhase.Idle;
public bool HasGoalSite => TryGetGoalSiteId(out _);
public int? GoalSiteId => TryGetGoalSiteId(out var goalSiteId) ? goalSiteId : null;
public event Action TaskCompleted;
string IFass2UdpCar.RemoteAddress => address;
int IFass2UdpCar.RemotePort => Port;
ushort IFass2UdpCar.VehicleCode => VehicleCode;
void IFass2UdpCar.OnUdpStateReceived(Fass2StateReport report)
{
lock (_syncRoot)
{
_lastUdpReportTime = DateTime.Now;
ApplyReport(report);
SyncUdpOnlineTags(true);
}
TickTaskStateMachine(report);
}
public new static async Task Create()
{
var car = new MagFass2Car
{
lstatus = "连接中",
address = "127.0.0.1",
name = "MagFass2Car",
haveCoordination = true,
speed = 1
};
car.EnsureTaskStateMachine();
MagFass2CarFileLogger.Configure(car.LogDirectory, car.EnableFileLog);
return car;
}
protected override void draw(Graphics eGraphics)
{
var halfLength = CarLength / 2;
var halfWidth = CarWidth / 2;
eGraphics.FillRectangle(Brushes.DimGray, -halfLength, -halfWidth, CarLength, CarWidth);
eGraphics.DrawRectangle(Pens.White, -halfLength, -halfWidth, CarLength, CarWidth);
using (var orientPen = new Pen(Color.Cyan, 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 = Fass2Protocol.StateText(report.State);
var alarm = report.Alarm == 0 ? "" : $"|alarm:0x{report.Alarm:X}";
return $"{name}({id})\n{state}|site:{siteID}|soc:{report.BatteryCharge}|node:{report.Node.Node}{alarm}";
}
catch
{
return $"{name}({id})\n状态未知";
}
}
public override void keepAlive()
{
if ((DateTime.Now - _lastPollTime).TotalMilliseconds < PollIntervalMs)
{
return;
}
_lastPollTime = DateTime.Now;
if (CommMode == Fass2CommMode.Udp)
{
try
{
EnsureUdpSession();
TryRestoreTaskFromTags();
if (_lastUdpReportTime != DateTime.MinValue &&
(DateTime.Now - _lastUdpReportTime).TotalMilliseconds <= UdpOfflineTimeoutMs)
{
haveCoordination = true;
SyncUdpOnlineTags(true);
TryLogLoopAssignmentToFile();
var report = LastReport;
DetailLog($"keepAlive udp ok, node={report.Node.Node}, state={Fass2Protocol.StateText(report.State)}, soc={report.BatteryCharge}, taskPhase={TaskPhase}");
}
else
{
lstatus = "离线";
SyncUdpOnlineTags(false);
DetailLog($"keepAlive udp waiting, listen={ListenPort}, remote={address}:{Port}");
}
}
catch (Exception ex)
{
lstatus = "离线";
SyncUdpOnlineTags(false);
DetailLog($"keepAlive udp failed: {ex.Message}");
}
return;
}
try
{
DetailLog($"keepAlive poll begin, endpoint={address}:{Port}");
var report = RefreshState();
haveCoordination = true;
DetailLog($"keepAlive ok, node={report.Node.Node}, state={Fass2Protocol.StateText(report.State)}, soc={report.BatteryCharge}");
}
catch (Exception ex)
{
lstatus = IsBenignSocketFault(ex) ? lstatus : "离线";
if (!IsBenignSocketFault(ex))
{
DetailLog($"keepAlive failed: {ex.Message}");
}
}
}
private bool _running;
private int _lastSyncedTrafficSiteId = -1;
private int _lastFileLoggedGoalSite = -1;
public override async Task actualSendScript(string script)
{
if (_running)
{
throw new InvalidOperationException($"MagFass2Car {id} already running script");
}
if (ShouldDriveLoopGoalAsTrackedTask(script))
{
_running = true;
try
{
DetailLog($"loop goal drive intercept, goal={GoalSiteId}, scriptLen={(script ?? string.Empty).Length}");
PrepareForLoopGoalDrive();
await ExecuteGoalTaskAsync(skipGoalLock: true);
DetailLog("loop goal drive completed");
}
catch (Exception ex)
{
DetailLog($"loop goal drive failed: {ExceptionFormatter.FormatEx(ex)}");
throw;
}
finally
{
_running = false;
}
return;
}
_running = true;
try
{
DetailLog($"script begin, length={(script ?? string.Empty).Length}");
var agv = new MagFass2CarInterface(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 2.0协议查询AGV状态")]
public void QueryState()
{
RefreshState();
}
[MethodMember(Name = "启动", Description = "发送启动指令(0x01)")]
public void StartMagFass2Car()
{
SendControl(Fass2Protocol.CmdStart, (ushort)Math.Round(th));
}
[MethodMember(Name = "停止", Description = "发送停止指令(0x02)")]
public void StopMagFass2Car()
{
SendControl(Fass2Protocol.CmdStop, 0);
}
[MethodMember(Name = "急停", Description = "发送急停指令(0x03)")]
public void EmergencyStopMagFass2Car()
{
SendControl(Fass2Protocol.CmdEmergencyStop, 0);
}
[MethodMember(Name = "进入小车远程", Description = "进入小车远程桌面")]
[I18N.DocumentTranslation(Name = "Open remote desktop", Description = "Open the car's remote desktop", locale = "en")]
public void Mstsc()
{
Process.Start(new ProcessStartInfo
{
FileName = "mstsc",
Arguments = $"/v:{address}",
UseShellExecute = false,
CreateNoWindow = true
});
}
public void EmergencyStop(string reason)
{
Diagnosis.Post($"car{name}:MagFass2Car EmergencyStop {reason}");
SendControl(Fass2Protocol.CmdEmergencyStop, 0);
}
public void EmergencyRelease()
{
Diagnosis.Post($"car{name}:MagFass2Car EmergencyRelease");
SendControl(Fass2Protocol.CmdStart, (ushort)Math.Round(th));
}
[MethodMember(Name = "重置UDP监听", Description = "重新注册 UDP 会话(PCB 模式)")]
public void ResetUdpSession()
{
lock (_syncRoot)
{
if (_udpRegistered)
{
Fass2UdpHub.Unregister(_registeredVehicleCode, this);
_udpRegistered = false;
_registeredVehicleCode = ushort.MaxValue;
}
_lastUdpReportTime = DateTime.MinValue;
}
EnsureUdpSession();
DetailLog($"UDP session reset, listen={ListenPort}, remote={address}:{Port}");
}
[MethodMember(Name = "重置TCP连接", Description = "关闭当前长连接,下次命令自动重连(PLC 模式)")]
public void ResetTcpConnection()
{
lock (_syncRoot)
{
ClosePersistentConnection("manual reset");
}
}
[MethodMember(Name = "重置环线联调", Description = "清空 Loop/FASS2 标签、交管锁和脚本错误,恢复可再分配")]
public void ResetLoopDriveState()
{
lock (_syncRoot)
{
_running = false;
_lastSyncedTrafficSiteId = -1;
_taskRestoreAttempted = false;
if (_taskStateMachine != null && !_taskStateMachine.IsIdle)
{
_taskStateMachine.Cancel("manual loop reset");
}
ClearTaskPersistence();
ClearLoopDriveTags();
_lastFileLoggedGoalSite = -1;
var site = ResolveSite(LastReport.Node.Node);
if (site == null && GetLastSite() > 0)
{
site = SimpleLib.GetSite(GetLastSite());
}
if (site == null && siteID > 0)
{
site = SimpleLib.GetSite(siteID);
}
if (site != null)
{
TrafficReset(site, true, strict: false);
siteID = site.id;
x = site.x;
y = site.y;
_lastSyncedTrafficSiteId = site.id;
haveCoordination = true;
}
status.programs.task = Task.CompletedTask;
SyncUdpOnlineTags(IsUdpSessionOnline());
DetailLog(
$"loop drive state reset, site={(site?.id.ToString() ?? "-")}, holding=[{string.Join(",", status.holdingLocks)}], pending=[{string.Join(",", status.pendingLocks)}]");
}
}
private void ClearLoopDriveTags()
{
if (tags == null)
{
return;
}
Commons.DeleteTag(tags, "goalSite");
Commons.DeleteTag(tags, "occupied");
Commons.DeleteTag(tags, "loopAssigned");
Commons.DeleteTag(tags, "dest");
Commons.DeleteTag(tags, "deliver");
Commons.DeleteTag(tags, "changePriority");
Commons.DeleteTag(tags, "priority");
Commons.DeleteTag(tags, "currentWorkStep");
Fass2TaskPersistence.Clear(tags);
}
private bool IsUdpSessionOnline()
{
return CommMode == Fass2CommMode.Udp &&
_lastUdpReportTime != DateTime.MinValue &&
(DateTime.Now - _lastUdpReportTime).TotalMilliseconds <= UdpOfflineTimeoutMs;
}
public Fass2StateReport RefreshState()
{
if (CommMode == Fass2CommMode.Udp)
{
EnsureUdpSession();
if (_lastUdpReportTime == DateTime.MinValue ||
(DateTime.Now - _lastUdpReportTime).TotalMilliseconds > UdpOfflineTimeoutMs)
{
throw new TimeoutException(
$"MagFass2Car {name}({id}) UDP state timeout ({UdpOfflineTimeoutMs}ms), listen={ListenPort}");
}
return LastReport;
}
var request = Fass2Protocol.BuildControl(Fass2Protocol.CmdQuery, VehicleCode, 0);
var response = SendRequest(request, Fass2Protocol.StateFrameLength, "Query(0x00)");
var report = Fass2Protocol.ParseState(response);
ApplyReport(report);
return report;
}
public void SendControl(byte command, ushort param)
{
var request = Fass2Protocol.BuildControl(command, VehicleCode, param);
SendRequest(request, Fass2Protocol.StateFrameLength, CommandName(command));
}
public void SendPathNodes(Fass2NodeMessage[] nodes)
{
var taskId = (ulong)Interlocked.Increment(ref _nextTaskId);
SendPathNodes(nodes, taskId);
}
public void SendPathNodes(Fass2NodeMessage[] nodes, ulong taskId)
{
var request = Fass2Protocol.BuildNodes(VehicleCode, taskId, nodes);
DetailLog($"send nodes 0xB1, task={taskId}, count={nodes.Length}");
SendRequest(request, Fass2Protocol.StateFrameLength, "Nodes(0xB1)");
}
public Fass2TaskPlan BuildTaskPlan(int startSiteId, int goalSiteId, double defaultSpeed = -1)
{
return Fass2TaskBuilder.BuildPath(startSiteId, goalSiteId, CreateTaskBuildOptions(defaultSpeed), ResolveNodeId);
}
public void SendTaskPlan(Fass2TaskPlan plan)
{
if (plan == null || plan.Nodes == null || plan.Nodes.Count == 0)
{
throw new ArgumentException("Fass2TaskPlan requires at least one node");
}
var taskId = (ulong)Interlocked.Increment(ref _nextTaskId);
DetailLog(
$"send task plan 0xB1, task={taskId}, sites={plan.SiteIds.Count}, nodes={plan.Nodes.Count}, batches={plan.Batches.Count}, sig={plan.FieldsSignature}");
foreach (var batch in plan.Batches)
{
SendPathNodes(batch, taskId);
}
}
public void BeginTrackedTask(int startSiteId, int goalSiteId, double defaultSpeed = -1)
{
EnsureTaskStateMachine();
_taskStateMachine.LockCount = LockCount;
_taskStateMachine.ResendIntervalMs = TaskResendIntervalMs;
_taskStateMachine.ActionRetryIntervalMs = ActionRetryIntervalMs;
_taskStateMachine.StartBeforeMove = StartBeforeMove;
_taskStateMachine.HeadingAngle = (ushort)Math.Round(th);
_taskStateMachine.Begin(startSiteId, goalSiteId, defaultSpeed);
var tick = _taskStateMachine.Tick(LastReport);
DetailLog($"tracked task bootstrap tick: {tick.Message}");
}
public async Task WaitTrackedTaskAsync(int timeoutMs)
{
EnsureTaskStateMachine();
var waitMs = timeoutMs <= 0 ? MoveTimeoutSeconds * 1000 : timeoutMs;
if (CommMode == Fass2CommMode.Udp)
{
await _taskStateMachine.WaitForCompletionAsync(waitMs);
return;
}
await _taskStateMachine.WaitAsync(
waitMs,
RefreshState,
Math.Max(50, Math.Min(PollIntervalMs, 500)));
}
async Task IFass2LoopCar.ExecuteGoalTaskAsync(int? goalSiteId, double defaultSpeed,
CancellationToken cancellationToken)
{
await ExecuteGoalTaskAsync(goalSiteId, defaultSpeed, cancellationToken, skipGoalLock: false);
}
public async Task ExecuteGoalTaskAsync(int? goalSiteId = null, double defaultSpeed = -1,
CancellationToken cancellationToken = default, bool skipGoalLock = false)
{
if (!EnableTaskStateMachine)
{
throw new InvalidOperationException("ExecuteGoalTaskAsync requires EnableTaskStateMachine=true");
}
var goalId = goalSiteId ?? GoalSiteId;
if (goalId == null || goalId.Value <= 0)
{
throw new InvalidOperationException("MagFass2Car goalSite is not set");
}
var startId = ResolvePhysicalSiteId();
if (startId <= 0)
{
throw new InvalidOperationException($"MagFass2Car {name}({id}) cannot resolve start site for loop task");
}
var lockedSite = status.holdingLocks.Length > 0 ? status.holdingLocks[0] : -1;
if (lockedSite > 0 && lockedSite != startId)
{
DetailLog($"ExecuteGoalTaskAsync normalize start, holding={lockedSite}, reportStart={startId}");
}
DetailLog($"ExecuteGoalTaskAsync start={startId}, goal={goalId.Value}, speed={defaultSpeed}, skipGoalLock={skipGoalLock}");
var goalLocked = false;
if (!skipGoalLock)
{
while (!TrafficControl.TryLock(this, goalId.Value))
{
cancellationToken.ThrowIfCancellationRequested();
await Task.Delay(50, cancellationToken);
}
goalLocked = true;
}
try
{
BeginTrackedTask(startId, goalId.Value, defaultSpeed);
await WaitTrackedTaskAsync(MoveTimeoutSeconds * 1000);
TaskCompleted?.Invoke(goalId.Value);
}
finally
{
if (goalLocked)
{
TrafficControl.Leave(this, goalId.Value);
}
}
}
///
/// Loop GoSite 在 Compile 前会通过 FindRoute 写入 seqScope/pendingLocks;
/// 全程任务改由 FASS2 状态机推进,需先清掉这段交管残留,避免 TryLock 与顺序锁冲突。
///
private void PrepareForLoopGoalDrive()
{
var startSiteId = ResolvePhysicalSiteId();
var site = startSiteId > 0 ? SimpleLib.GetSite(startSiteId) : null;
DetailLog(
$"loop goal prepare before reset, node={LastReport.Node.Node}, start={startSiteId}, holding=[{string.Join(",", status.holdingLocks)}], pending=[{string.Join(",", status.pendingLocks)}]");
if (site != null)
{
TrafficReset(site, true, strict: false);
siteID = site.id;
_lastSyncedTrafficSiteId = site.id;
}
DetailLog(
$"loop goal prepare after reset, start={startSiteId}, holding=[{string.Join(",", status.holdingLocks)}], pending=[{string.Join(",", status.pendingLocks)}]");
}
///
/// 环线联调以 UDP 上报节点映射的站点为准,避免 FindRoute/forecast 污染 holdingLocks 后 GetLastSite 变成目标站。
///
private int ResolvePhysicalSiteId()
{
if (LastReport.Node.Node != 0)
{
var fromReport = ResolveSite(LastReport.Node.Node);
if (fromReport != null)
{
return fromReport.id;
}
}
if (GetLastSite() > 0)
{
return GetLastSite();
}
return siteID > 0 ? siteID : -1;
}
private bool ShouldDriveLoopGoalAsTrackedTask(string script)
{
if (!EnableLoopTaskDrive || !EnableTaskStateMachine || !HasGoalSite)
{
return false;
}
return !string.IsNullOrEmpty(script) &&
script.Contains("MagFass2Go", StringComparison.Ordinal);
}
private bool TryGetGoalSiteId(out int goalSiteId)
{
goalSiteId = 0;
if (tags == null || !tags.TryGetValue("goalSite", out var goalText) ||
!int.TryParse(goalText, out goalSiteId))
{
return false;
}
return goalSiteId > 0;
}
private void EnsureTaskStateMachine()
{
if (_taskStateMachine != null)
{
return;
}
_taskStateMachine = new Fass2TaskStateMachine(new Fass2TaskCallbacks
{
BuildPlan = BuildTaskPlan,
SendNodes = (nodes, taskId) => SendPathNodes(nodes, taskId),
SendAction = SendAction,
SendControl = SendControl,
ResolveNodeId = ResolveNodeId,
ResolveSite = ResolveSite,
AllocateTaskId = () => (ulong)Interlocked.Increment(ref _nextTaskId),
AllocateActionId = () => (ulong)Interlocked.Increment(ref _nextActionId),
Persist = PersistTaskContext,
ClearPersisted = ClearTaskPersistence,
Log = message => DetailLog($"task-sm {message}")
});
}
private void TickTaskStateMachine(Fass2StateReport report)
{
if (!EnableTaskStateMachine || _taskStateMachine == null || !_taskStateMachine.IsRunning)
{
return;
}
var tick = _taskStateMachine.Tick(report);
if (!string.IsNullOrEmpty(tick.Message) &&
(tick.Dispatched || tick.Rebuilt || tick.ActionSent || tick.Advanced || tick.Completed || tick.Faulted))
{
DetailLog($"task-sm tick phase={tick.Phase}, {tick.Message}");
}
UpdateTaskStatusEnums();
}
private void TryRestoreTaskFromTags()
{
if (!EnableTaskStateMachine || _taskRestoreAttempted || tags == null)
{
return;
}
_taskRestoreAttempted = true;
if (!Fass2TaskPersistence.TryLoad(tags, out var saved))
{
return;
}
EnsureTaskStateMachine();
if (_taskStateMachine.TryRestore(saved))
{
DetailLog(
$"task restored from tags, phase={saved.Phase}, goal={saved.GoalSiteId}, index={saved.CurrentIndex}, task={saved.TaskId}");
UpdateTaskStatusEnums();
}
}
private void PersistTaskContext(Fass2TaskContext context)
{
if (tags == null || context == null)
{
return;
}
Fass2TaskPersistence.Save(tags, context);
UpdateTaskStatusEnums();
}
private void ClearTaskPersistence()
{
if (tags == null)
{
return;
}
Fass2TaskPersistence.Clear(tags);
UpdateTaskStatusEnums();
}
private void UpdateTaskStatusEnums()
{
if (_taskStateMachine == null)
{
status.enums["TaskPhase"] = Fass2TaskPhase.Idle.ToString();
return;
}
var context = _taskStateMachine.Context;
status.enums["TaskPhase"] = context.Phase.ToString();
status.enums["TaskGoal"] = context.GoalSiteId.ToString();
status.enums["TaskIndex"] = context.CurrentIndex.ToString();
status.enums["TaskFieldsSig"] = context.FieldsSignature ?? string.Empty;
if (!string.IsNullOrEmpty(context.FaultReason))
{
status.enums["TaskFault"] = context.FaultReason;
}
else
{
status.enums.Remove("TaskFault");
}
}
private Fass2TaskBuildOptions CreateTaskBuildOptions(double defaultSpeed = -1)
{
return new Fass2TaskBuildOptions
{
UseTagValueAsNode = UseTagValueAsNode,
DefaultSpeed = defaultSpeed > 0 ? defaultSpeed : 0.2,
CarSpeed = speed
};
}
public void SendAction(Fass2NodeMessage node, ulong actionId = 0)
{
var request = Fass2Protocol.BuildAction(VehicleCode, actionId, node);
DetailLog($"send action 0xA1, actionId={actionId}, node={node.Node}");
SendRequest(request, Fass2Protocol.ActionFrameLength, "Action(0xA1)");
}
private byte[] SendRequest(byte[] request, int responseLength, string commandName)
{
DetailLog($"TX {commandName}, len={request.Length}, mode={CommMode}");
if (LogRawFrame)
{
DetailLog($"TX raw {Fass2Protocol.ToHex(request)}");
}
if (CommMode == Fass2CommMode.Udp)
{
EnsureUdpSession();
Fass2UdpHub.SendToCar(this, request);
DetailLog($"UDP TX {commandName} done (async, no sync RX)");
return Array.Empty();
}
byte[] response;
lock (_syncRoot)
{
response = SendAndReceive(request, responseLength);
}
DetailLog($"RX {commandName}, bytes={response.Length}");
if (LogRawFrame)
{
DetailLog($"RX raw {Fass2Protocol.ToHex(response)}");
}
if (response.Length >= Fass2Protocol.StateFrameLength)
{
ApplyReport(Fass2Protocol.ParseState(response));
}
return response;
}
private void EnsureUdpSession()
{
if (string.IsNullOrWhiteSpace(address))
{
throw new InvalidOperationException("MagFass2Car address is empty for UDP mode");
}
if (!_udpRegistered || _registeredVehicleCode != VehicleCode)
{
if (_udpRegistered && _registeredVehicleCode != VehicleCode)
{
Fass2UdpHub.Unregister(_registeredVehicleCode, this);
_udpRegistered = false;
}
Fass2UdpHub.Register(this);
Fass2UdpHub.EnsureStarted(ListenPort);
_udpRegistered = true;
_registeredVehicleCode = VehicleCode;
DetailLog($"UDP registered, VehicleCode={VehicleCode}, listen={ListenPort}, remote={address}:{Port}");
}
}
private byte[] SendAndReceive(byte[] request, int responseLength)
{
return UsePersistentConnection
? SendAndReceivePersistent(request, responseLength)
: SendAndReceiveShort(request, responseLength);
}
private byte[] SendAndReceivePersistent(byte[] request, int responseLength)
{
try
{
EnsurePersistentConnection();
var stream = _persistentClient.GetStream();
stream.Write(request, 0, request.Length);
stream.Flush();
return ReadExact(stream, responseLength);
}
catch (Exception ex)
{
ClosePersistentConnection($"persistent send/read failed: {ex.Message}");
throw;
}
}
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;
}
}
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 { }
}
private byte[] SendAndReceiveShort(byte[] request, int responseLength)
{
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, responseLength);
}
}
}
private void ConnectWithTimeout(TcpClient client)
{
if (string.IsNullOrWhiteSpace(address))
{
throw new InvalidOperationException("MagFass2Car 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 ioEx)
{
var message = ioEx.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;
}
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 FASS2 response");
}
offset += read;
}
return buffer;
}
catch (Exception ex) when (IsBenignSocketFault(ex))
{
throw new IOException("remote closed before full FASS2 response", ex);
}
}
///
/// 按 FASS2 调度器 GetSendNodes 规则构建 src→dst 路径段(含中间站与站点/边 fields)。
///
internal Fass2NodeMessage[] BuildMoveNodes(int srcId, int dstId, double trackSpeed = -1)
{
return Fass2TaskBuilder.BuildSegment(srcId, dstId, CreateTaskBuildOptions(trackSpeed), ResolveNodeId);
}
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.Node == targetNode && report.State != 1)
{
DetailLog($"arrived node={targetNode}, state={Fass2Protocol.StateText(report.State)}");
return;
}
Thread.Sleep(Math.Max(50, Math.Min(PollIntervalMs, 500)));
}
throw new TimeoutException(
$"MagFass2Car {name}({id}) move timeout, target node={targetNode}, current={LastReport.Node.Node}, state={Fass2Protocol.StateText(LastReport.State)}");
}
private void ApplyReport(Fass2StateReport report)
{
LastReport = report;
lstatus = report.Alarm != 0 ? "报警" : report.State switch
{
0 => "未准备",
4 => "故障",
3 => "急停",
_ => "上线"
};
th = report.HeadingAngle;
status.enums["Protocol"] = CommMode == Fass2CommMode.Udp ? "FASS2.0/UDP" : "FASS2.0/TCP";
status.enums["CurrentNode"] = report.Node.Node.ToString();
status.enums["Alarm"] = $"0x{report.Alarm:X}";
status.enums["State"] = Fass2Protocol.StateText(report.State);
status.enums["Soc"] = report.BatteryCharge.ToString();
status.enums["BatteryHealth"] = report.BatteryHealth.ToString();
status.enums["ElectricCurrent"] = report.BatteryCurrent.ToString();
status.enums["Voltage"] = report.BatteryVoltage.ToString();
status.enums["HeadingAngle"] = report.HeadingAngle.ToString();
status.enums["Task"] = report.Task.ToString();
status.enums["StartStop"] = report.Node.StartStop.ToString();
status.enums["Lift"] = report.Node.Lift.ToString();
status.enums["Roll"] = report.Node.Roll.ToString();
UpdateTaskStatusEnums();
var site = ResolveSite(report.Node.Node);
if (site == null)
{
haveCoordination = false;
DetailLog($"report applied without mapped site, node={report.Node.Node}, state={Fass2Protocol.StateText(report.State)}");
return;
}
haveCoordination = true;
ApplyMapCoordinates(site, report);
SyncTrafficFromReport(site, report);
DetailLog(
$"report applied, node={report.Node.Node}, site={siteID}, x={x:0.###}, y={y:0.###}, dist={report.Node.Distance}, state={Fass2Protocol.StateText(report.State)}, soc={report.BatteryCharge}, task={report.Task}");
}
///
/// 路段内按 Distance 在起止站点间插值,避免地图只在 node 跳变时才移动。
///
private void ApplyMapCoordinates(Site site, Fass2StateReport report)
{
siteID = site.id;
x = site.x;
y = site.y;
if (report.Node.Distance <= 0 ||
!TryResolveMotionSegment(report.Node.Node, out var fromSite, out var toSite, out var segmentLenMm))
{
return;
}
var progress = Math.Min(1.0, report.Node.Distance / Math.Max(1.0, segmentLenMm));
var dx = toSite.x - fromSite.x;
var dy = toSite.y - fromSite.y;
var mapLen = Math.Sqrt(dx * dx + dy * dy);
if (mapLen >= 1.0)
{
x = (float)(fromSite.x + dx * progress);
y = (float)(fromSite.y + dy * progress);
return;
}
var track = FindTrackBetween(fromSite.id, toSite.id);
if (track != null)
{
var trackDx = toSite.x - fromSite.x;
var trackDy = toSite.y - fromSite.y;
if (Math.Abs(trackDx) + Math.Abs(trackDy) >= 1.0)
{
x = (float)(fromSite.x + trackDx * progress);
y = (float)(fromSite.y + trackDy * progress);
return;
}
}
// 地图起止站点坐标重合时,用协议距离沿 X 轴做最小可视化偏移。
x = (float)(fromSite.x + report.Node.Distance);
y = fromSite.y;
}
private bool TryResolveMotionSegment(ushort currentNode, out Site fromSite, out Site toSite, out double segmentLenMm)
{
fromSite = null;
toSite = null;
segmentLenMm = 0;
fromSite = ResolveSite(currentNode);
if (fromSite == null)
{
return false;
}
var plan = _taskStateMachine?.Context?.Plan;
if (plan?.Nodes == null || plan.Nodes.Count < 2)
{
return false;
}
for (var i = 0; i < plan.Nodes.Count - 1; i++)
{
if (plan.Nodes[i].Node != currentNode)
{
continue;
}
var targetNode = plan.Nodes[i + 1].Node;
toSite = ResolveSite(targetNode);
if (toSite == null)
{
return false;
}
segmentLenMm = plan.Nodes[i + 1].Distance;
if (segmentLenMm <= 0)
{
var dx = toSite.x - fromSite.x;
var dy = toSite.y - fromSite.y;
segmentLenMm = Math.Max(1.0, Math.Sqrt(dx * dx + dy * dy));
}
return true;
}
return false;
}
private static Track FindTrackBetween(int fromSiteId, int toSiteId)
{
foreach (var track in SimpleLib.GetAllTracks())
{
if (track.direction == 0)
{
if ((track.siteA == fromSiteId && track.siteB == toSiteId) ||
(track.siteB == fromSiteId && track.siteA == toSiteId))
{
return track;
}
continue;
}
if (track.direction == 1 && track.siteA == fromSiteId && track.siteB == toSiteId)
{
return track;
}
if (track.direction == 2 && track.siteB == fromSiteId && track.siteA == toSiteId)
{
return track;
}
}
return null;
}
private void SyncUdpOnlineTags(bool isOnline)
{
if (CommMode != Fass2CommMode.Udp)
{
return;
}
if (isOnline)
{
Commons.AddOrUpdateTag(tags, "Online", "true");
Commons.DeleteTag(tags, "agvOffline");
return;
}
Commons.DeleteTag(tags, "Online");
}
private void SyncTrafficFromReport(Site site, Fass2StateReport report)
{
if (site == null)
{
return;
}
var lockedSite = status.holdingLocks.Length > 0 ? status.holdingLocks[0] : -1;
if (_running)
{
if (lockedSite == site.id)
{
_lastSyncedTrafficSiteId = site.id;
return;
}
try
{
DetailLog(
$"traffic sync during task, lock={lockedSite}, targetSite={site.id}, node={report.Node.Node}");
TrafficReset(site, true, strict: false);
_lastSyncedTrafficSiteId = site.id;
}
catch (Exception ex)
{
DetailLog($"traffic sync during task failed: {ex.Message}");
}
return;
}
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.Node}");
TrafficReset(site, true, strict: false);
_lastSyncedTrafficSiteId = site.id;
}
catch (Exception ex)
{
DetailLog($"traffic sync failed: {ex.Message}");
}
}
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);
}
internal 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);
}
private static int Clamp(int value, int min, int max)
{
if (value < min) return min;
return value > max ? max : value;
}
private void DetailLog(string message)
{
WriteFileLog(message);
if (!EnableDetailLog)
{
return;
}
var text = $"[MagFass2Car:{name}({id})] {DateTime.Now:HH:mm:ss.fff} {message}";
AppendDebug(text);
Diagnosis.Post(text, "MagFass2Car", true);
}
private void WriteFileLog(string message)
{
if (!EnableFileLog)
{
return;
}
var logVehicleCode = ResolveLogVehicleCode();
if (logVehicleCode == 0)
{
return;
}
MagFass2CarFileLogger.Configure(LogDirectory, true);
MagFass2CarFileLogger.Write(logVehicleCode, message);
}
private ushort ResolveLogVehicleCode()
{
if (VehicleCode != 0)
{
return VehicleCode;
}
if (id <= 0 || id > ushort.MaxValue)
{
return 0;
}
return (ushort)id;
}
private void TryLogLoopAssignmentToFile()
{
if (!EnableFileLog || !TryGetGoalSiteId(out var goalSiteId))
{
_lastFileLoggedGoalSite = -1;
return;
}
if (_lastFileLoggedGoalSite == goalSiteId)
{
return;
}
_lastFileLoggedGoalSite = goalSiteId;
var startSite = GetLastSite() > 0 ? GetLastSite() : siteID;
WriteFileLog($"AutoLoop: 车辆 {name} 从站点 {startSite} 自动分配到目标站点 {goalSiteId}");
}
private static string CommandName(byte command)
{
switch (command)
{
case Fass2Protocol.CmdQuery: return "Query(0x00)";
case Fass2Protocol.CmdStart: return "Start(0x01)";
case Fass2Protocol.CmdStop: return "Stop(0x02)";
case Fass2Protocol.CmdEmergencyStop: return "EmergencyStop(0x03)";
case Fass2Protocol.CmdReset: return "Reset(0x04)";
case Fass2Protocol.CmdRest: return "Rest(0x05)";
case Fass2Protocol.CmdShutdown: return "Shutdown(0x06)";
default: return $"Unknown(0x{command:X2})";
}
}
internal sealed class MagFass2CarInterface : AGVInterface
{
private readonly MagFass2Car _car;
public MagFass2CarInterface(int id)
{
_car = (MagFass2Car)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 MagFass2Go(int srcId, int dstId, double speed = -1)
{
var promise = new TaskCompletionSource();
Queue(async () =>
{
while (!TryLock(dstId))
{
await Task.Delay(50);
}
var dstNode = _car.ResolveNodeId(dstId);
_car.DetailLog($"MagFass2Go src={srcId}, dst={dstId}, dstNode={dstNode}, speed={speed}");
if (_car.EnableTaskStateMachine)
{
_car.BeginTrackedTask(srcId, dstId, speed);
await _car.WaitTrackedTaskAsync(_car.MoveTimeoutSeconds * 1000);
}
else
{
if (_car.StartBeforeMove)
{
_car.SendControl(Fass2Protocol.CmdStart, (ushort)Math.Round(_car.th));
}
var plan = _car.BuildTaskPlan(srcId, dstId, speed);
_car.DetailLog(
$"MagFass2Go plan sites={plan.SiteIds.Count}, nodes={plan.Nodes.Count}, batches={plan.Batches.Count}");
_car.SendTaskPlan(plan);
_car.WaitUntilArrived(dstNode, _car.MoveTimeoutSeconds * 1000);
}
promise.SetResult(1);
}, async () =>
{
await promise.Task;
Leave(srcId);
});
}
}
}
}