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;
|
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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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=0,UDP 未注册。请为每台车配置唯一车号",
|
||||||
|
"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}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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` 启动 |
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user