fix: FASS2 状态机与 UDP 枢纽审查项

等锁超时、全路径签名、无锁窗口不下发、Fault 终态可等待、Pass+机构不再短路、Alarm 不杀单、catch-up 停在未完成站;VehicleCode=0/重复车号拒绝注册,Hub 已运行时不抢监听口;FileLogger 空目录可写。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
黄兆尉
2026-08-26 23:04:58 +08:00
co-authored by Cursor
parent 29a06f004f
commit e4a245e644
13 changed files with 465 additions and 48 deletions
@@ -100,11 +100,6 @@ namespace StandardScene.Fass2Simulator
return true; return true;
} }
if (expected.StartStop == StartStopPass)
{
return true;
}
if (expected.StartStop == StartStopControlStop) if (expected.StartStop == StartStopControlStop)
{ {
return false; return false;
@@ -96,6 +96,11 @@ namespace StandardScene.Fass2Simulator
return; return;
} }
if (string.IsNullOrEmpty(_logDirectory))
{
_logDirectory = ResolveLogDirectory(null);
}
Directory.CreateDirectory(_logDirectory); Directory.CreateDirectory(_logDirectory);
var fileName = $"app_{DateTime.Now:yyyyMMdd}.log"; var fileName = $"app_{DateTime.Now:yyyyMMdd}.log";
CurrentFilePath = Path.Combine(_logDirectory, fileName); CurrentFilePath = Path.Combine(_logDirectory, fileName);
@@ -57,6 +57,20 @@ namespace StandardScene.Magnetic.Tests.Protocol
Assert.Equal((ulong)202, _car2.Reports[0].Task); Assert.Equal((ulong)202, _car2.Reports[0].Task);
} }
[Fact]
public void EnsureStarted_NewPort_ReceivesOnNewPort()
{
var newPort = AllocateUdpPort();
Fass2UdpHub.EnsureStarted(newPort);
Thread.Sleep(150);
var frame = Fass2TestFrameBuilder.BuildStateFrame(1, 1, 88, 301);
using var sender = new UdpClient();
sender.Send(frame, frame.Length, new IPEndPoint(IPAddress.Loopback, newPort));
Assert.NotNull(_car1.WaitForAck());
Assert.Equal((ushort)88, _car1.Reports[_car1.Reports.Count - 1].Node.Node);
}
[Fact] [Fact]
public void Hub_IgnoresUnregisteredCarCode() public void Hub_IgnoresUnregisteredCarCode()
{ {
@@ -66,6 +80,31 @@ namespace StandardScene.Magnetic.Tests.Protocol
Assert.Empty(_car2.Reports); Assert.Empty(_car2.Reports);
} }
[Fact]
public void Register_VehicleCodeZero_DoesNotReceiveState()
{
var zeroPort = AllocateUdpPort();
using var zero = new TestUdpCar(0, zeroPort);
Assert.False(Fass2UdpHub.Register(zero));
SendState(0, 2, 40, 0);
Thread.Sleep(200);
Assert.Empty(zero.Reports);
}
[Fact]
public void Register_DuplicateVehicleCode_KeepsFirstCar()
{
var dupPort = AllocateUdpPort();
using var dup = new TestUdpCar(1, dupPort);
Assert.False(Fass2UdpHub.Register(dup));
SendState(1, 2, 41, 401);
Assert.NotNull(_car1.WaitForAck());
Assert.Equal((ushort)41, _car1.Reports[_car1.Reports.Count - 1].Node.Node);
Assert.Empty(dup.Reports);
}
public void Dispose() public void Dispose()
{ {
Fass2UdpHub.Unregister(_car1); Fass2UdpHub.Unregister(_car1);
@@ -22,6 +22,9 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\StandardScene.Magnetic\StandardScene.Magnetic.csproj" /> <ProjectReference Include="..\StandardScene.Magnetic\StandardScene.Magnetic.csproj" />
<ProjectReference Include="..\StandardScene.Fass2Simulator\StandardScene.Fass2Simulator.csproj" /> <ProjectReference Include="..\StandardScene.Fass2Simulator\StandardScene.Fass2Simulator.csproj" />
<Reference Include="SimpleCore">
<HintPath>$(SimpleCoreDll)</HintPath>
</Reference>
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -0,0 +1,47 @@
using StandardScene.Magnetic.Protocol;
using StandardScene.Magnetic.Tasking;
using Xunit;
namespace StandardScene.Magnetic.Tests.Tasking
{
public class Fass2ActionResolverTests
{
[Fact]
public void PassWithLift_IsNotCompleteUntilMechanismMatches()
{
var expected = new Fass2NodeMessage
{
Node = 3,
StartStop = Fass2TaskBuilder.StartStopPass,
Lift = 1
};
var actual = new Fass2NodeMessage
{
Node = 3,
StartStop = Fass2TaskBuilder.StartStopPass,
Lift = 0
};
Assert.True(Fass2ActionResolver.RequiresActionWait(expected));
Assert.False(Fass2ActionResolver.IsStationActionComplete(expected, actual, vehicleState: 2));
}
[Fact]
public void PlainPass_CompletesWithoutMechanism()
{
var expected = new Fass2NodeMessage
{
Node = 3,
StartStop = Fass2TaskBuilder.StartStopPass
};
var actual = new Fass2NodeMessage
{
Node = 3,
StartStop = Fass2TaskBuilder.StartStopPass
};
Assert.False(Fass2ActionResolver.RequiresActionWait(expected));
Assert.True(Fass2ActionResolver.IsStationActionComplete(expected, actual, vehicleState: 2));
}
}
}
@@ -0,0 +1,15 @@
using StandardScene.Fass2Simulator;
using Xunit;
namespace StandardScene.Magnetic.Tests.Tasking
{
public class Fass2SimFileLoggerTests
{
[Fact]
public void Write_WithoutConfigure_DoesNotThrow()
{
var ex = Record.Exception(() => Fass2SimFileLogger.Write("logger probe"));
Assert.Null(ex);
}
}
}
@@ -0,0 +1,207 @@
using SimpleCore;
using StandardScene.Magnetic.Protocol;
using StandardScene.Magnetic.Tasking;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Xunit;
namespace StandardScene.Magnetic.Tests.Tasking
{
public class Fass2TaskStateMachineMustFixTests
{
[Fact]
public async Task WaitForCompletion_AfterBootstrapFault_Throws_AndBeginIsAllowed()
{
var machine = CreateMachine();
machine.Begin(10, 12);
var tick = machine.Tick(Report(1, moving: true, startStop: 1, state: 4));
Assert.True(tick.Faulted);
Assert.Equal(Fass2TaskPhase.Fault, machine.Context.Phase);
Assert.True(machine.IsIdle);
await Assert.ThrowsAsync<InvalidOperationException>(
() => machine.WaitForCompletionAsync(1000));
var ex = Record.Exception(() => machine.Begin(10, 12));
Assert.Null(ex);
Assert.Equal(Fass2TaskPhase.Planning, machine.Context.Phase);
}
[Fact]
public void PassingFirstStation_DoesNotRebuildForUnchangedFields()
{
var siteIds = new List<int> { 10, 11, 12 };
var planCalls = 0;
var taskIds = 0UL;
var plan = ThreeStationPlan(siteIds);
var machine = CreateMachine(
() =>
{
planCalls++;
return plan;
},
() => ++taskIds);
machine.Begin(10, 12);
machine.Tick(Report(1, moving: true, startStop: 1));
machine.Tick(Report(1, moving: false, startStop: 1));
machine.Tick(Report(1, moving: false, startStop: 1));
machine.Tick(Report(1, moving: false, startStop: 1));
Assert.Equal(1, machine.Context.CurrentIndex);
Assert.Equal(1UL, machine.Context.TaskId);
Assert.Equal(1, planCalls);
var afterAdvance = machine.Tick(Report(2, moving: true, startStop: 1));
Assert.False(afterAdvance.Rebuilt);
Assert.Equal(1, planCalls);
Assert.Equal(1UL, machine.Context.TaskId);
Assert.Equal(1, machine.Context.CurrentIndex);
Assert.Equal(Fass2TaskPhase.Moving, machine.Context.Phase);
}
[Fact]
public void FirstPrepareTrafficFailure_WaitsInsteadOfFault()
{
var machine = CreateMachine(
prepareTraffic: (_, __) => throw new InvalidOperationException("already locks it"));
machine.Begin(10, 12);
var tick = machine.Tick(Report(1, moving: true, startStop: 1));
Assert.False(tick.Faulted);
Assert.NotEqual(Fass2TaskPhase.Fault, machine.Context.Phase);
Assert.Equal(Fass2TaskPhase.Moving, machine.Context.Phase);
Assert.True(machine.Context.WaitingForTraffic);
Assert.Contains("wait", tick.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void ZeroLockedWindow_DoesNotDispatch()
{
var sent = new List<Fass2NodeMessage[]>();
var machine = CreateMachine(
ensureWindow: (_, __, ___) => 0,
onSendNodes: window => sent.Add(window));
machine.LockCount = 1;
machine.Begin(10, 12);
machine.Tick(Report(1, moving: true, startStop: 1));
Assert.Empty(sent);
Assert.True(machine.Context.WaitingForTraffic);
Assert.NotEqual(Fass2TaskPhase.Fault, machine.Context.Phase);
}
[Fact]
public void AlarmBits_DoNotFaultTask()
{
var machine = CreateMachine();
machine.Begin(10, 12);
var tick = machine.Tick(Report(1, moving: true, startStop: 1, alarm: 1));
Assert.False(tick.Faulted);
Assert.NotEqual(Fass2TaskPhase.Fault, machine.Context.Phase);
}
[Fact]
public void CatchUp_StopsAtUnfinishedStopStation()
{
var plan = new Fass2TaskPlan
{
StartSiteId = 10,
GoalSiteId = 12,
SiteIds = new List<int> { 10, 11, 12 },
Nodes = new List<Fass2NodeMessage>
{
new Fass2NodeMessage { Node = 1, StartStop = Fass2TaskBuilder.StartStopPass, Distance = 100 },
new Fass2NodeMessage { Node = 2, StartStop = Fass2TaskBuilder.StartStopStop },
new Fass2NodeMessage { Node = 3, StartStop = Fass2TaskBuilder.StartStopPass }
},
FieldsSignature = Fass2SiteFieldReader.BuildFieldsSignature(
new List<int> { 10, 11, 12 }, 0, SimpleLib.GetSite)
};
var machine = CreateMachine(() => plan, () => 1UL);
machine.LockCount = 4;
machine.Begin(10, 12);
machine.Tick(Report(1, moving: true, startStop: 1));
machine.Tick(Report(3, moving: true, startStop: 1));
Assert.Equal(1, machine.Context.CurrentIndex);
}
[Fact]
public async Task TrafficWait_TimesOutInsteadOfWaitingForever()
{
var machine = CreateMachine(
prepareTraffic: (_, __) => throw new InvalidOperationException("already locks it"));
machine.TrafficWaitTimeoutMs = 200;
machine.Begin(10, 12);
machine.Tick(Report(1, moving: true, startStop: 1));
await Assert.ThrowsAsync<TimeoutException>(
() => machine.WaitForCompletionAsync(10_000));
Assert.True(machine.IsIdle);
}
private static Fass2TaskStateMachine CreateMachine(
Func<Fass2TaskPlan> buildPlan = null,
Func<ulong> allocateTaskId = null,
Action<IReadOnlyList<int>, int> prepareTraffic = null,
Func<IReadOnlyList<int>, int, int, int> ensureWindow = null,
Action<Fass2NodeMessage[]> onSendNodes = null)
{
var plan = ThreeStationPlan(new List<int> { 10, 11, 12 });
return new Fass2TaskStateMachine(new Fass2TaskCallbacks
{
BuildPlan = (_, __, ___) => buildPlan != null ? buildPlan() : plan,
SendNodes = (window, _) => onSendNodes?.Invoke(window),
SendAction = (_, __) => { },
SendControl = (_, __) => { },
ResolveNodeId = id => (ushort)id,
AllocateTaskId = () => allocateTaskId != null ? allocateTaskId() : 1UL,
AllocateActionId = () => 9UL,
PrepareTraffic = prepareTraffic,
EnsureTrafficWindow = ensureWindow
})
{
StartBeforeMove = false,
LockCount = 4,
ResendIntervalMs = 60_000,
ActionRetryIntervalMs = 0
};
}
private static Fass2TaskPlan ThreeStationPlan(List<int> siteIds)
{
return new Fass2TaskPlan
{
StartSiteId = siteIds[0],
GoalSiteId = siteIds[siteIds.Count - 1],
SiteIds = siteIds,
Nodes = new List<Fass2NodeMessage>
{
new Fass2NodeMessage { Node = 1, StartStop = Fass2TaskBuilder.StartStopPass, Distance = 100 },
new Fass2NodeMessage { Node = 2, StartStop = Fass2TaskBuilder.StartStopPass, Distance = 100 },
new Fass2NodeMessage { Node = 3, StartStop = Fass2TaskBuilder.StartStopStop }
},
FieldsSignature = Fass2SiteFieldReader.BuildFieldsSignature(siteIds, 0, SimpleLib.GetSite)
};
}
private static Fass2StateReport Report(ushort node, bool moving, byte startStop, byte alarm = 0, byte? state = null)
{
return new Fass2StateReport
{
State = state ?? (byte)(moving ? 1 : 2),
Alarm = alarm,
Node = new Fass2NodeMessage
{
Node = node,
StartStop = startStop
}
};
}
}
}
+38 -3
View File
@@ -44,6 +44,7 @@ namespace StandardScene.CarTypes
private DateTime _lastPollTime = DateTime.MinValue; private DateTime _lastPollTime = DateTime.MinValue;
private DateTime _lastUdpReportTime = DateTime.MinValue; private DateTime _lastUdpReportTime = DateTime.MinValue;
private bool _udpRegistered; private bool _udpRegistered;
private bool _udpZeroCodeWarned;
private ushort _registeredVehicleCode = ushort.MaxValue; private ushort _registeredVehicleCode = ushort.MaxValue;
private long _nextTaskId = 1; private long _nextTaskId = 1;
private long _nextActionId = 1; private long _nextActionId = 1;
@@ -71,6 +72,7 @@ namespace StandardScene.CarTypes
[FieldMember] public bool VerifyTrajectoryFieldsOnStation = false; [FieldMember] public bool VerifyTrajectoryFieldsOnStation = false;
[FieldMember] public int TaskResendIntervalMs = 500; [FieldMember] public int TaskResendIntervalMs = 500;
[FieldMember] public int ActionRetryIntervalMs = 1000; [FieldMember] public int ActionRetryIntervalMs = 1000;
[FieldMember] public int TrafficWaitTimeoutMs = 180000;
[FieldMember] public bool EnableTaskStateMachine = true; [FieldMember] public bool EnableTaskStateMachine = true;
[FieldMember] public bool EnableLoopTaskDrive = true; [FieldMember] public bool EnableLoopTaskDrive = true;
[FieldMember] public bool StartBeforeMove = true; [FieldMember] public bool StartBeforeMove = true;
@@ -567,6 +569,7 @@ namespace StandardScene.CarTypes
_taskStateMachine.LockCount = LockCount; _taskStateMachine.LockCount = LockCount;
_taskStateMachine.ResendIntervalMs = TaskResendIntervalMs; _taskStateMachine.ResendIntervalMs = TaskResendIntervalMs;
_taskStateMachine.ActionRetryIntervalMs = ActionRetryIntervalMs; _taskStateMachine.ActionRetryIntervalMs = ActionRetryIntervalMs;
_taskStateMachine.TrafficWaitTimeoutMs = TrafficWaitTimeoutMs;
_taskStateMachine.StartBeforeMove = StartBeforeMove; _taskStateMachine.StartBeforeMove = StartBeforeMove;
Fass2ActionResolver.VerifyTrajectoryFields = VerifyTrajectoryFieldsOnStation; Fass2ActionResolver.VerifyTrajectoryFields = VerifyTrajectoryFieldsOnStation;
_taskStateMachine.Begin(startSiteId, goalSiteId, defaultSpeed); _taskStateMachine.Begin(startSiteId, goalSiteId, defaultSpeed);
@@ -1147,6 +1150,21 @@ namespace StandardScene.CarTypes
throw new InvalidOperationException("Mag2Car address is empty for UDP mode"); throw new InvalidOperationException("Mag2Car address is empty for UDP mode");
} }
if (VehicleCode == 0)
{
if (!_udpZeroCodeWarned)
{
_udpZeroCodeWarned = true;
Diagnosis.Post(
$"Mag2Car {name}({id}) VehicleCode=0UDP 未注册。请为每台车配置唯一车号",
"Mag2Car",
true);
DetailLog("UDP register skipped: VehicleCode=0");
}
return;
}
if (!_udpRegistered || _registeredVehicleCode != VehicleCode) if (!_udpRegistered || _registeredVehicleCode != VehicleCode)
{ {
if (_udpRegistered && _registeredVehicleCode != VehicleCode) if (_udpRegistered && _registeredVehicleCode != VehicleCode)
@@ -1155,11 +1173,28 @@ namespace StandardScene.CarTypes
_udpRegistered = false; _udpRegistered = false;
} }
Fass2UdpHub.Register(this); if (!Fass2UdpHub.Register(this))
Fass2UdpHub.EnsureStarted(ListenPort); {
DetailLog($"UDP register rejected, VehicleCode={VehicleCode}");
return;
}
if (!Fass2UdpHub.IsRunning)
{
Fass2UdpHub.EnsureStarted(ListenPort);
}
else if (ListenPort > 0 && ListenPort != Fass2UdpHub.ListenPort)
{
var msg =
$"Mag2Car {name}({id}) ListenPort={ListenPort} 与已运行 Hub={Fass2UdpHub.ListenPort} 不一致,已忽略本车端口(配置错误)";
Diagnosis.Post(msg, "Mag2Car", true);
DetailLog(
$"UDP listen keep hub={Fass2UdpHub.ListenPort}, car ListenPort={ListenPort} ignored");
}
_udpRegistered = true; _udpRegistered = true;
_registeredVehicleCode = VehicleCode; _registeredVehicleCode = VehicleCode;
DetailLog($"UDP registered, VehicleCode={VehicleCode}, listen={ListenPort}, remote={address}:{Port}"); DetailLog($"UDP registered, VehicleCode={VehicleCode}, listen={Fass2UdpHub.ListenPort}, remote={address}:{Port}");
} }
} }
+52 -10
View File
@@ -23,16 +23,37 @@ namespace StandardScene.Magnetic.Protocol
private static int _listenPort = 20103; private static int _listenPort = 20103;
private static readonly Dictionary<ushort, long> _lastUnknownCarLogUtcTicks = new Dictionary<ushort, long>(); private static readonly Dictionary<ushort, long> _lastUnknownCarLogUtcTicks = new Dictionary<ushort, long>();
public static void Register(IFass2UdpCar car) internal static bool IsRunning => _running;
internal static int ListenPort => _listenPort;
public static bool Register(IFass2UdpCar car)
{ {
if (car == null) if (car == null)
{ {
return; return false;
}
if (car.VehicleCode == 0)
{
Diagnosis.Post("Fass2UdpHub 拒绝注册 VehicleCode=0,请为每台 Mag2Car 配置唯一车号", "Fass2UdpHub", true);
return false;
} }
lock (SyncRoot) lock (SyncRoot)
{ {
if (CarsByCode.TryGetValue(car.VehicleCode, out var existing) &&
!ReferenceEquals(existing, car))
{
Diagnosis.Post(
$"Fass2UdpHub 拒绝覆盖 VehicleCode={car.VehicleCode}(已被另一台车占用)",
"Fass2UdpHub",
true);
return false;
}
CarsByCode[car.VehicleCode] = car; CarsByCode[car.VehicleCode] = car;
return true;
} }
} }
@@ -64,9 +85,31 @@ namespace StandardScene.Magnetic.Protocol
public static void EnsureStarted(int listenPort) public static void EnsureStarted(int listenPort)
{ {
if (listenPort > 0) if (listenPort <= 0)
{ {
_listenPort = listenPort; listenPort = _listenPort;
}
var needRestart = false;
lock (SyncRoot)
{
if (_running && listenPort != _listenPort)
{
needRestart = true;
}
else if (_running)
{
return;
}
}
if (needRestart)
{
Diagnosis.Post(
$"Fass2UdpHub 监听口 {_listenPort} → {listenPort},重启枢纽",
"Fass2UdpHub",
true);
Stop();
} }
lock (SyncRoot) lock (SyncRoot)
@@ -76,6 +119,7 @@ namespace StandardScene.Magnetic.Protocol
return; return;
} }
_listenPort = listenPort;
_listener = new UdpClient(_listenPort); _listener = new UdpClient(_listenPort);
_running = true; _running = true;
_receiveThread = new Thread(ReceiveLoop) _receiveThread = new Thread(ReceiveLoop)
@@ -209,6 +253,7 @@ namespace StandardScene.Magnetic.Protocol
private static void LogUnknownCar(ushort carCode) private static void LogUnknownCar(ushort carCode)
{ {
var nowTicks = DateTime.UtcNow.Ticks; var nowTicks = DateTime.UtcNow.Ticks;
string registered;
lock (SyncRoot) lock (SyncRoot)
{ {
if (_lastUnknownCarLogUtcTicks.TryGetValue(carCode, out var lastTicks) if (_lastUnknownCarLogUtcTicks.TryGetValue(carCode, out var lastTicks)
@@ -218,12 +263,9 @@ namespace StandardScene.Magnetic.Protocol
} }
_lastUnknownCarLogUtcTicks[carCode] = nowTicks; _lastUnknownCarLogUtcTicks[carCode] = nowTicks;
} registered = CarsByCode.Count == 0
? "(无)"
var registered = string.Join(",", CarsByCode.Keys); : string.Join(",", CarsByCode.Keys);
if (string.IsNullOrEmpty(registered))
{
registered = "(无)";
} }
Diagnosis.Post( Diagnosis.Post(
@@ -106,11 +106,6 @@ namespace StandardScene.Magnetic.Tasking
return true; return true;
} }
if (expected.StartStop == Fass2TaskBuilder.StartStopPass)
{
return true;
}
if (expected.StartStop == Fass2TaskBuilder.StartStopControlStop) if (expected.StartStop == Fass2TaskBuilder.StartStopControlStop)
{ {
return false; return false;
@@ -80,6 +80,9 @@ namespace StandardScene.Magnetic.Tasking
public int ActionRetryIntervalMs { get; set; } = 1000; public int ActionRetryIntervalMs { get; set; } = 1000;
/// <summary>交管等锁 / 管控等放行上限。0 表示不限制。</summary>
public int TrafficWaitTimeoutMs { get; set; } = 180_000;
public bool StartBeforeMove { get; set; } = true; public bool StartBeforeMove { get; set; } = true;
public ushort HeadingAngle { get; set; } public ushort HeadingAngle { get; set; }
@@ -90,7 +93,8 @@ namespace StandardScene.Magnetic.Tasking
{ {
lock (_syncRoot) lock (_syncRoot)
{ {
return Context.Phase is Fass2TaskPhase.Idle or Fass2TaskPhase.Complete or Fass2TaskPhase.Cancelled; return Context.Phase is Fass2TaskPhase.Idle or Fass2TaskPhase.Complete
or Fass2TaskPhase.Cancelled or Fass2TaskPhase.Fault;
} }
} }
} }
@@ -327,11 +331,6 @@ namespace StandardScene.Magnetic.Tasking
var result = new Fass2TaskTickResult { Phase = Context.Phase }; var result = new Fass2TaskTickResult { Phase = Context.Phase };
if (report?.Alarm != 0)
{
return Fault($"alarm=0x{report.Alarm:X}", result);
}
if (report?.State == 3) if (report?.State == 3)
{ {
return Fault("vehicle emergency stop", result); return Fault("vehicle emergency stop", result);
@@ -432,14 +431,25 @@ namespace StandardScene.Magnetic.Tasking
public async Task WaitForCompletionAsync(int timeoutMs, CancellationToken cancellationToken = default) public async Task WaitForCompletionAsync(int timeoutMs, CancellationToken cancellationToken = default)
{ {
TaskCompletionSource<int> waiter; TaskCompletionSource<int> waiter;
string failReason;
lock (_syncRoot) lock (_syncRoot)
{ {
if (Context.Phase is Fass2TaskPhase.Complete or Fass2TaskPhase.Cancelled or Fass2TaskPhase.Fault) if (Context.Phase == Fass2TaskPhase.Complete)
{ {
return; return;
} }
waiter = _completionSource ?? ResetCompletionSource(); waiter = _completionSource;
failReason = Context.FaultReason;
if (Context.Phase is not Fass2TaskPhase.Cancelled and not Fass2TaskPhase.Fault)
{
waiter = waiter ?? ResetCompletionSource();
}
}
if (waiter == null)
{
throw new InvalidOperationException(failReason ?? "task ended without completion source");
} }
await WaitWithTrafficPauseAsync(waiter, timeoutMs, cancellationToken); await WaitWithTrafficPauseAsync(waiter, timeoutMs, cancellationToken);
@@ -459,6 +469,7 @@ namespace StandardScene.Magnetic.Tasking
var remainingMs = timeoutMs <= 0 ? -1 : timeoutMs; var remainingMs = timeoutMs <= 0 ? -1 : timeoutMs;
const int sliceMs = 500; const int sliceMs = 500;
var lastPauseLog = DateTime.MinValue; var lastPauseLog = DateTime.MinValue;
var pauseElapsedMs = 0;
while (true) while (true)
{ {
@@ -500,6 +511,17 @@ namespace StandardScene.Magnetic.Tasking
if (waitingPaused) if (waitingPaused)
{ {
if (TrafficWaitTimeoutMs > 0)
{
pauseElapsedMs += delayMs;
if (pauseElapsedMs >= TrafficWaitTimeoutMs)
{
Cancel($"{pauseKind} timeout after {TrafficWaitTimeoutMs}ms");
throw new TimeoutException(
$"FASS2 {pauseKind} timeout after {TrafficWaitTimeoutMs}ms, phase={Context.Phase}, index={Context.CurrentIndex}, goal={Context.GoalSiteId}");
}
}
if ((DateTime.Now - lastPauseLog).TotalSeconds >= 5) if ((DateTime.Now - lastPauseLog).TotalSeconds >= 5)
{ {
lastPauseLog = DateTime.Now; lastPauseLog = DateTime.Now;
@@ -509,6 +531,8 @@ namespace StandardScene.Magnetic.Tasking
continue; continue;
} }
pauseElapsedMs = 0;
if (remainingMs < 0) if (remainingMs < 0)
{ {
continue; continue;
@@ -552,7 +576,13 @@ namespace StandardScene.Magnetic.Tasking
} }
catch (Exception ex) catch (Exception ex)
{ {
return Fault($"traffic prepare failed: {ex.Message}", result); Context.Phase = Fass2TaskPhase.Moving;
Context.WaitingForTraffic = true;
result.Phase = Context.Phase;
result.Message = $"traffic prepare wait: {ex.Message}";
Log(result.Message);
Persist();
return result;
} }
if (StartBeforeMove && Context.CurrentIndex == 0) if (StartBeforeMove && Context.CurrentIndex == 0)
@@ -884,7 +914,7 @@ namespace StandardScene.Magnetic.Tasking
} }
var newSignature = Fass2SiteFieldReader.BuildFieldsSignature( var newSignature = Fass2SiteFieldReader.BuildFieldsSignature(
Context.Plan.SiteIds, Context.CurrentIndex, SimpleLib.GetSite); Context.Plan.SiteIds, 0, SimpleLib.GetSite);
if (string.Equals(newSignature, Context.FieldsSignature, StringComparison.Ordinal)) if (string.Equals(newSignature, Context.FieldsSignature, StringComparison.Ordinal))
{ {
return false; return false;
@@ -1005,7 +1035,12 @@ namespace StandardScene.Magnetic.Tasking
lockedCount = Fass2ControlAreaGate.LimitWindowCount( lockedCount = Fass2ControlAreaGate.LimitWindowCount(
nodes, start, lockedCount, Context.ControlReleasedIndex); nodes, start, lockedCount, Context.ControlReleasedIndex);
lockedCount = Math.Max(1, Math.Min(lockedCount, wantWindow)); lockedCount = Math.Max(0, Math.Min(lockedCount, wantWindow));
if (lockedCount <= 0)
{
return Array.Empty<Fass2NodeMessage>();
}
var window = new Fass2NodeMessage[lockedCount]; var window = new Fass2NodeMessage[lockedCount];
for (var i = 0; i < lockedCount; i++) for (var i = 0; i < lockedCount; i++)
{ {
@@ -1039,8 +1074,12 @@ namespace StandardScene.Magnetic.Tasking
var catchTo = reportIndex; var catchTo = reportIndex;
for (var i = Context.CurrentIndex; i < reportIndex; i++) for (var i = Context.CurrentIndex; i < reportIndex; i++)
{ {
if (Fass2ControlAreaGate.IsControlStop(Context.Plan.Nodes[i].StartStop) && var node = Context.Plan.Nodes[i];
Context.ControlReleasedIndex != i) var unreleasedControl = Fass2ControlAreaGate.IsControlStop(node.StartStop) &&
Context.ControlReleasedIndex != i;
var actionStation = Fass2ActionResolver.RequiresActionWait(node) &&
node.StartStop != Fass2TaskBuilder.StartStopControlStart;
if (unreleasedControl || actionStation)
{ {
catchTo = i; catchTo = i;
break; break;
@@ -221,12 +221,6 @@ namespace StandardScene.Magnetic.Tasking
lockedCount++; lockedCount++;
} }
if (lockedCount == 0)
{
lockedCount = 1;
_log($"window warn: no holding overlap index={fromIndex}, site={siteIds[fromIndex]}, holding=[{FormatInts(_car.status.holdingLocks)}]");
}
_log($"window locked={lockedCount}/{windowSize}, fromIndex={fromIndex}, holding=[{FormatInts(_car.status.holdingLocks)}], pending=[{FormatInts(_car.status.pendingLocks)}]"); _log($"window locked={lockedCount}/{windowSize}, fromIndex={fromIndex}, holding=[{FormatInts(_car.status.holdingLocks)}], pending=[{FormatInts(_car.status.pendingLocks)}]");
return lockedCount; return lockedCount;
} }
@@ -65,9 +65,9 @@
| 参数 | 示例值 | 含义 | | 参数 | 示例值 | 含义 |
|------|--------|------| |------|--------|------|
| `address` | `192.168.1.100` | 车体 IP | | `address` | `192.168.1.100` | 车体 IP |
| `Port` | `5000` | 下发命令的目标端口 | | `Port` | `5001`(模拟器)/ `5000`(真机常见) | 下发命令的目标端口,须等于车体监听口 |
| `ListenPort` | `20103` | 调度监听车体上报的端口 | | `ListenPort` | `20103` | 调度监听车体上报的端口;多车必须相同,不一致会告警并忽略 |
| `VehicleCode` | `1` | 车体编号,须与上报帧一致 | | `VehicleCode` | `1` | 车体编号,须与上报帧一致`0` 会拒绝 UDP 注册并告警 |
| `CommMode` | `Udp` | PCB/UDP 模式(推荐) | | `CommMode` | `Udp` | PCB/UDP 模式(推荐) |
### 步骤 3:确认在线 ### 步骤 3:确认在线
@@ -194,9 +194,9 @@ agv.Mag2Go(2, 5, 0.2);
|------|------|------| |------|------|------|
| `CommMode` | `Udp` | `Udp`=PCB 被动收状态;`Tcp`=PLC 主动查询 | | `CommMode` | `Udp` | `Udp`=PCB 被动收状态;`Tcp`=PLC 主动查询 |
| `address` | — | 车体 IP | | `address` | — | 车体 IP |
| `Port` | `5000` | 命令下发端口 | | `Port` | `5000` | 命令下发端口;联调模拟器时填 `5001`/`5002`(见 `appsettings*.json` |
| `ListenPort` | `20103` | 调度 UDP 监听端口 | | `ListenPort` | `20103` | 调度 UDP 监听端口;Hub 已启动后本车改端口不会重启监听 |
| `VehicleCode` | `0` | 必须与车体上报的 `Car` 字段一致 | | `VehicleCode` | `0` | 必须与车体上报的 `Car` 字段一致;保持 `0` 会告警且收不到上报 |
| `UdpOfflineTimeoutMs` | `15000` | 超过此时间未收到上报视为离线 | | `UdpOfflineTimeoutMs` | `15000` | 超过此时间未收到上报视为离线 |
### 5.2 任务与状态机 ### 5.2 任务与状态机
@@ -208,6 +208,7 @@ agv.Mag2Go(2, 5, 0.2);
| `LockCount` | `4` | 每次 `0xB1` 滑动窗口站数(≤10 | | `LockCount` | `4` | 每次 `0xB1` 滑动窗口站数(≤10 |
| `TaskResendIntervalMs` | `500` | 周期重发 `0xB1` 间隔 | | `TaskResendIntervalMs` | `500` | 周期重发 `0xB1` 间隔 |
| `ActionRetryIntervalMs` | `1000` | 动作未完成时 `0xA1` 重试间隔 | | `ActionRetryIntervalMs` | `1000` | 动作未完成时 `0xA1` 重试间隔 |
| `TrafficWaitTimeoutMs` | `180000` | 交管等锁 / 管控等待上限;超时取消任务 |
| `MoveTimeoutSeconds` | `120` | 单次移动超时(秒) | | `MoveTimeoutSeconds` | `120` | 单次移动超时(秒) |
| `StartBeforeMove` | `true` | 移动前发 `0x01` 启动 | | `StartBeforeMove` | `true` | 移动前发 `0x01` 启动 |