diff --git a/StandardScene.Fass2Simulator/Fass2SimActionResolver.cs b/StandardScene.Fass2Simulator/Fass2SimActionResolver.cs
index a989e05..13ef58f 100644
--- a/StandardScene.Fass2Simulator/Fass2SimActionResolver.cs
+++ b/StandardScene.Fass2Simulator/Fass2SimActionResolver.cs
@@ -100,11 +100,6 @@ namespace StandardScene.Fass2Simulator
return true;
}
- if (expected.StartStop == StartStopPass)
- {
- return true;
- }
-
if (expected.StartStop == StartStopControlStop)
{
return false;
diff --git a/StandardScene.Fass2Simulator/Fass2SimFileLogger.cs b/StandardScene.Fass2Simulator/Fass2SimFileLogger.cs
index fb553d3..e8a227d 100644
--- a/StandardScene.Fass2Simulator/Fass2SimFileLogger.cs
+++ b/StandardScene.Fass2Simulator/Fass2SimFileLogger.cs
@@ -96,6 +96,11 @@ namespace StandardScene.Fass2Simulator
return;
}
+ if (string.IsNullOrEmpty(_logDirectory))
+ {
+ _logDirectory = ResolveLogDirectory(null);
+ }
+
Directory.CreateDirectory(_logDirectory);
var fileName = $"app_{DateTime.Now:yyyyMMdd}.log";
CurrentFilePath = Path.Combine(_logDirectory, fileName);
diff --git a/StandardScene.Magnetic.Tests/Protocol/Fass2UdpHubMultiCarTests.cs b/StandardScene.Magnetic.Tests/Protocol/Fass2UdpHubMultiCarTests.cs
index 662752f..2e0da5b 100644
--- a/StandardScene.Magnetic.Tests/Protocol/Fass2UdpHubMultiCarTests.cs
+++ b/StandardScene.Magnetic.Tests/Protocol/Fass2UdpHubMultiCarTests.cs
@@ -57,6 +57,20 @@ namespace StandardScene.Magnetic.Tests.Protocol
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]
public void Hub_IgnoresUnregisteredCarCode()
{
@@ -66,6 +80,31 @@ namespace StandardScene.Magnetic.Tests.Protocol
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()
{
Fass2UdpHub.Unregister(_car1);
diff --git a/StandardScene.Magnetic.Tests/StandardScene.Magnetic.Tests.csproj b/StandardScene.Magnetic.Tests/StandardScene.Magnetic.Tests.csproj
index 6b90582..45d236d 100644
--- a/StandardScene.Magnetic.Tests/StandardScene.Magnetic.Tests.csproj
+++ b/StandardScene.Magnetic.Tests/StandardScene.Magnetic.Tests.csproj
@@ -22,6 +22,9 @@
+
+ $(SimpleCoreDll)
+
diff --git a/StandardScene.Magnetic.Tests/Tasking/Fass2ActionResolverTests.cs b/StandardScene.Magnetic.Tests/Tasking/Fass2ActionResolverTests.cs
new file mode 100644
index 0000000..62eae92
--- /dev/null
+++ b/StandardScene.Magnetic.Tests/Tasking/Fass2ActionResolverTests.cs
@@ -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));
+ }
+ }
+}
diff --git a/StandardScene.Magnetic.Tests/Tasking/Fass2SimFileLoggerTests.cs b/StandardScene.Magnetic.Tests/Tasking/Fass2SimFileLoggerTests.cs
new file mode 100644
index 0000000..6df55fb
--- /dev/null
+++ b/StandardScene.Magnetic.Tests/Tasking/Fass2SimFileLoggerTests.cs
@@ -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);
+ }
+ }
+}
diff --git a/StandardScene.Magnetic.Tests/Tasking/Fass2TaskStateMachineMustFixTests.cs b/StandardScene.Magnetic.Tests/Tasking/Fass2TaskStateMachineMustFixTests.cs
new file mode 100644
index 0000000..c491bc5
--- /dev/null
+++ b/StandardScene.Magnetic.Tests/Tasking/Fass2TaskStateMachineMustFixTests.cs
@@ -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(
+ () => 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 { 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();
+ 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 { 10, 11, 12 },
+ Nodes = new List
+ {
+ 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 { 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(
+ () => machine.WaitForCompletionAsync(10_000));
+ Assert.True(machine.IsIdle);
+ }
+
+ private static Fass2TaskStateMachine CreateMachine(
+ Func buildPlan = null,
+ Func allocateTaskId = null,
+ Action, int> prepareTraffic = null,
+ Func, int, int, int> ensureWindow = null,
+ Action onSendNodes = null)
+ {
+ var plan = ThreeStationPlan(new List { 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 siteIds)
+ {
+ return new Fass2TaskPlan
+ {
+ StartSiteId = siteIds[0],
+ GoalSiteId = siteIds[siteIds.Count - 1],
+ SiteIds = siteIds,
+ Nodes = new List
+ {
+ 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
+ }
+ };
+ }
+ }
+}
diff --git a/StandardScene.Magnetic/CarTypes/Mag2Car.cs b/StandardScene.Magnetic/CarTypes/Mag2Car.cs
index 3186ced..d078fa3 100644
--- a/StandardScene.Magnetic/CarTypes/Mag2Car.cs
+++ b/StandardScene.Magnetic/CarTypes/Mag2Car.cs
@@ -44,6 +44,7 @@ namespace StandardScene.CarTypes
private DateTime _lastPollTime = DateTime.MinValue;
private DateTime _lastUdpReportTime = DateTime.MinValue;
private bool _udpRegistered;
+ private bool _udpZeroCodeWarned;
private ushort _registeredVehicleCode = ushort.MaxValue;
private long _nextTaskId = 1;
private long _nextActionId = 1;
@@ -71,6 +72,7 @@ namespace StandardScene.CarTypes
[FieldMember] public bool VerifyTrajectoryFieldsOnStation = false;
[FieldMember] public int TaskResendIntervalMs = 500;
[FieldMember] public int ActionRetryIntervalMs = 1000;
+ [FieldMember] public int TrafficWaitTimeoutMs = 180000;
[FieldMember] public bool EnableTaskStateMachine = true;
[FieldMember] public bool EnableLoopTaskDrive = true;
[FieldMember] public bool StartBeforeMove = true;
@@ -567,6 +569,7 @@ namespace StandardScene.CarTypes
_taskStateMachine.LockCount = LockCount;
_taskStateMachine.ResendIntervalMs = TaskResendIntervalMs;
_taskStateMachine.ActionRetryIntervalMs = ActionRetryIntervalMs;
+ _taskStateMachine.TrafficWaitTimeoutMs = TrafficWaitTimeoutMs;
_taskStateMachine.StartBeforeMove = StartBeforeMove;
Fass2ActionResolver.VerifyTrajectoryFields = VerifyTrajectoryFieldsOnStation;
_taskStateMachine.Begin(startSiteId, goalSiteId, defaultSpeed);
@@ -1147,6 +1150,21 @@ namespace StandardScene.CarTypes
throw new InvalidOperationException("Mag2Car address is empty for UDP mode");
}
+ if (VehicleCode == 0)
+ {
+ if (!_udpZeroCodeWarned)
+ {
+ _udpZeroCodeWarned = true;
+ Diagnosis.Post(
+ $"Mag2Car {name}({id}) VehicleCode=0,UDP 未注册。请为每台车配置唯一车号",
+ "Mag2Car",
+ true);
+ DetailLog("UDP register skipped: VehicleCode=0");
+ }
+
+ return;
+ }
+
if (!_udpRegistered || _registeredVehicleCode != VehicleCode)
{
if (_udpRegistered && _registeredVehicleCode != VehicleCode)
@@ -1155,11 +1173,28 @@ namespace StandardScene.CarTypes
_udpRegistered = false;
}
- Fass2UdpHub.Register(this);
- Fass2UdpHub.EnsureStarted(ListenPort);
+ if (!Fass2UdpHub.Register(this))
+ {
+ 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;
_registeredVehicleCode = VehicleCode;
- DetailLog($"UDP registered, VehicleCode={VehicleCode}, listen={ListenPort}, remote={address}:{Port}");
+ DetailLog($"UDP registered, VehicleCode={VehicleCode}, listen={Fass2UdpHub.ListenPort}, remote={address}:{Port}");
}
}
diff --git a/StandardScene.Magnetic/Protocol/Fass2UdpHub.cs b/StandardScene.Magnetic/Protocol/Fass2UdpHub.cs
index 639531b..ea14cf5 100644
--- a/StandardScene.Magnetic/Protocol/Fass2UdpHub.cs
+++ b/StandardScene.Magnetic/Protocol/Fass2UdpHub.cs
@@ -23,16 +23,37 @@ namespace StandardScene.Magnetic.Protocol
private static int _listenPort = 20103;
private static readonly Dictionary _lastUnknownCarLogUtcTicks = new Dictionary();
- 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)
{
- return;
+ return false;
+ }
+
+ if (car.VehicleCode == 0)
+ {
+ Diagnosis.Post("Fass2UdpHub 拒绝注册 VehicleCode=0,请为每台 Mag2Car 配置唯一车号", "Fass2UdpHub", true);
+ return false;
}
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;
+ return true;
}
}
@@ -64,9 +85,31 @@ namespace StandardScene.Magnetic.Protocol
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)
@@ -76,6 +119,7 @@ namespace StandardScene.Magnetic.Protocol
return;
}
+ _listenPort = listenPort;
_listener = new UdpClient(_listenPort);
_running = true;
_receiveThread = new Thread(ReceiveLoop)
@@ -209,6 +253,7 @@ namespace StandardScene.Magnetic.Protocol
private static void LogUnknownCar(ushort carCode)
{
var nowTicks = DateTime.UtcNow.Ticks;
+ string registered;
lock (SyncRoot)
{
if (_lastUnknownCarLogUtcTicks.TryGetValue(carCode, out var lastTicks)
@@ -218,12 +263,9 @@ namespace StandardScene.Magnetic.Protocol
}
_lastUnknownCarLogUtcTicks[carCode] = nowTicks;
- }
-
- var registered = string.Join(",", CarsByCode.Keys);
- if (string.IsNullOrEmpty(registered))
- {
- registered = "(无)";
+ registered = CarsByCode.Count == 0
+ ? "(无)"
+ : string.Join(",", CarsByCode.Keys);
}
Diagnosis.Post(
diff --git a/StandardScene.Magnetic/Tasking/Fass2ActionResolver.cs b/StandardScene.Magnetic/Tasking/Fass2ActionResolver.cs
index a719a79..4267517 100644
--- a/StandardScene.Magnetic/Tasking/Fass2ActionResolver.cs
+++ b/StandardScene.Magnetic/Tasking/Fass2ActionResolver.cs
@@ -106,11 +106,6 @@ namespace StandardScene.Magnetic.Tasking
return true;
}
- if (expected.StartStop == Fass2TaskBuilder.StartStopPass)
- {
- return true;
- }
-
if (expected.StartStop == Fass2TaskBuilder.StartStopControlStop)
{
return false;
diff --git a/StandardScene.Magnetic/Tasking/Fass2TaskStateMachine.cs b/StandardScene.Magnetic/Tasking/Fass2TaskStateMachine.cs
index 1079a49..05bd4e9 100644
--- a/StandardScene.Magnetic/Tasking/Fass2TaskStateMachine.cs
+++ b/StandardScene.Magnetic/Tasking/Fass2TaskStateMachine.cs
@@ -80,6 +80,9 @@ namespace StandardScene.Magnetic.Tasking
public int ActionRetryIntervalMs { get; set; } = 1000;
+ /// 交管等锁 / 管控等放行上限。0 表示不限制。
+ public int TrafficWaitTimeoutMs { get; set; } = 180_000;
+
public bool StartBeforeMove { get; set; } = true;
public ushort HeadingAngle { get; set; }
@@ -90,7 +93,8 @@ namespace StandardScene.Magnetic.Tasking
{
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 };
- if (report?.Alarm != 0)
- {
- return Fault($"alarm=0x{report.Alarm:X}", result);
- }
-
if (report?.State == 3)
{
return Fault("vehicle emergency stop", result);
@@ -432,14 +431,25 @@ namespace StandardScene.Magnetic.Tasking
public async Task WaitForCompletionAsync(int timeoutMs, CancellationToken cancellationToken = default)
{
TaskCompletionSource waiter;
+ string failReason;
lock (_syncRoot)
{
- if (Context.Phase is Fass2TaskPhase.Complete or Fass2TaskPhase.Cancelled or Fass2TaskPhase.Fault)
+ if (Context.Phase == Fass2TaskPhase.Complete)
{
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);
@@ -459,6 +469,7 @@ namespace StandardScene.Magnetic.Tasking
var remainingMs = timeoutMs <= 0 ? -1 : timeoutMs;
const int sliceMs = 500;
var lastPauseLog = DateTime.MinValue;
+ var pauseElapsedMs = 0;
while (true)
{
@@ -500,6 +511,17 @@ namespace StandardScene.Magnetic.Tasking
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)
{
lastPauseLog = DateTime.Now;
@@ -509,6 +531,8 @@ namespace StandardScene.Magnetic.Tasking
continue;
}
+ pauseElapsedMs = 0;
+
if (remainingMs < 0)
{
continue;
@@ -552,7 +576,13 @@ namespace StandardScene.Magnetic.Tasking
}
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)
@@ -884,7 +914,7 @@ namespace StandardScene.Magnetic.Tasking
}
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))
{
return false;
@@ -1005,7 +1035,12 @@ namespace StandardScene.Magnetic.Tasking
lockedCount = Fass2ControlAreaGate.LimitWindowCount(
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();
+ }
+
var window = new Fass2NodeMessage[lockedCount];
for (var i = 0; i < lockedCount; i++)
{
@@ -1039,8 +1074,12 @@ namespace StandardScene.Magnetic.Tasking
var catchTo = reportIndex;
for (var i = Context.CurrentIndex; i < reportIndex; i++)
{
- if (Fass2ControlAreaGate.IsControlStop(Context.Plan.Nodes[i].StartStop) &&
- Context.ControlReleasedIndex != i)
+ var node = Context.Plan.Nodes[i];
+ var unreleasedControl = Fass2ControlAreaGate.IsControlStop(node.StartStop) &&
+ Context.ControlReleasedIndex != i;
+ var actionStation = Fass2ActionResolver.RequiresActionWait(node) &&
+ node.StartStop != Fass2TaskBuilder.StartStopControlStart;
+ if (unreleasedControl || actionStation)
{
catchTo = i;
break;
diff --git a/StandardScene.Magnetic/Tasking/Fass2TrafficLocker.cs b/StandardScene.Magnetic/Tasking/Fass2TrafficLocker.cs
index bcd3e04..b1bb0ac 100644
--- a/StandardScene.Magnetic/Tasking/Fass2TrafficLocker.cs
+++ b/StandardScene.Magnetic/Tasking/Fass2TrafficLocker.cs
@@ -221,12 +221,6 @@ namespace StandardScene.Magnetic.Tasking
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)}]");
return lockedCount;
}
diff --git a/StandardScene.Magnetic/docs/Mag2Car使用指南.md b/StandardScene.Magnetic/docs/Mag2Car使用指南.md
index 972c6b4..dc323ef 100644
--- a/StandardScene.Magnetic/docs/Mag2Car使用指南.md
+++ b/StandardScene.Magnetic/docs/Mag2Car使用指南.md
@@ -65,9 +65,9 @@
| 参数 | 示例值 | 含义 |
|------|--------|------|
| `address` | `192.168.1.100` | 车体 IP |
-| `Port` | `5000` | 下发命令的目标端口 |
-| `ListenPort` | `20103` | 调度监听车体上报的端口 |
-| `VehicleCode` | `1` | 车体编号,须与上报帧一致 |
+| `Port` | `5001`(模拟器)/ `5000`(真机常见) | 下发命令的目标端口,须等于车体监听口 |
+| `ListenPort` | `20103` | 调度监听车体上报的端口;多车必须相同,不一致会告警并忽略 |
+| `VehicleCode` | `1` | 车体编号,须与上报帧一致;`0` 会拒绝 UDP 注册并告警 |
| `CommMode` | `Udp` | PCB/UDP 模式(推荐) |
### 步骤 3:确认在线
@@ -194,9 +194,9 @@ agv.Mag2Go(2, 5, 0.2);
|------|------|------|
| `CommMode` | `Udp` | `Udp`=PCB 被动收状态;`Tcp`=PLC 主动查询 |
| `address` | — | 车体 IP |
-| `Port` | `5000` | 命令下发端口 |
-| `ListenPort` | `20103` | 调度 UDP 监听端口 |
-| `VehicleCode` | `0` | 必须与车体上报的 `Car` 字段一致 |
+| `Port` | `5000` | 命令下发端口;联调模拟器时填 `5001`/`5002`(见 `appsettings*.json`) |
+| `ListenPort` | `20103` | 调度 UDP 监听端口;Hub 已启动后本车改端口不会重启监听 |
+| `VehicleCode` | `0` | 必须与车体上报的 `Car` 字段一致;保持 `0` 会告警且收不到上报 |
| `UdpOfflineTimeoutMs` | `15000` | 超过此时间未收到上报视为离线 |
### 5.2 任务与状态机
@@ -208,6 +208,7 @@ agv.Mag2Go(2, 5, 0.2);
| `LockCount` | `4` | 每次 `0xB1` 滑动窗口站数(≤10) |
| `TaskResendIntervalMs` | `500` | 周期重发 `0xB1` 间隔 |
| `ActionRetryIntervalMs` | `1000` | 动作未完成时 `0xA1` 重试间隔 |
+| `TrafficWaitTimeoutMs` | `180000` | 交管等锁 / 管控等待上限;超时取消任务 |
| `MoveTimeoutSeconds` | `120` | 单次移动超时(秒) |
| `StartBeforeMove` | `true` | 移动前发 `0x01` 启动 |