fix: FASS2 状态机与 UDP 枢纽审查项
等锁超时、全路径签名、无锁窗口不下发、Fault 终态可等待、Pass+机构不再短路、Alarm 不杀单、catch-up 停在未完成站;VehicleCode=0/重复车号拒绝注册,Hub 已运行时不抢监听口;FileLogger 空目录可写。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -100,11 +100,6 @@ namespace StandardScene.Fass2Simulator
|
||||
return true;
|
||||
}
|
||||
|
||||
if (expected.StartStop == StartStopPass)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (expected.StartStop == StartStopControlStop)
|
||||
{
|
||||
return false;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StandardScene.Magnetic\StandardScene.Magnetic.csproj" />
|
||||
<ProjectReference Include="..\StandardScene.Fass2Simulator\StandardScene.Fass2Simulator.csproj" />
|
||||
<Reference Include="SimpleCore">
|
||||
<HintPath>$(SimpleCoreDll)</HintPath>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
</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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,16 +23,37 @@ namespace StandardScene.Magnetic.Protocol
|
||||
private static int _listenPort = 20103;
|
||||
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)
|
||||
{
|
||||
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(
|
||||
|
||||
@@ -106,11 +106,6 @@ namespace StandardScene.Magnetic.Tasking
|
||||
return true;
|
||||
}
|
||||
|
||||
if (expected.StartStop == Fass2TaskBuilder.StartStopPass)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (expected.StartStop == Fass2TaskBuilder.StartStopControlStop)
|
||||
{
|
||||
return false;
|
||||
|
||||
@@ -80,6 +80,9 @@ namespace StandardScene.Magnetic.Tasking
|
||||
|
||||
public int ActionRetryIntervalMs { get; set; } = 1000;
|
||||
|
||||
/// <summary>交管等锁 / 管控等放行上限。0 表示不限制。</summary>
|
||||
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<int> 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<Fass2NodeMessage>();
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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` 启动 |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user