From e4a245e6442f26efc5371f41e2a64ef6b874305c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=85=86=E5=B0=89?= <228127304@qq.com> Date: Wed, 26 Aug 2026 23:04:58 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20FASS2=20=E7=8A=B6=E6=80=81=E6=9C=BA?= =?UTF-8?q?=E4=B8=8E=20UDP=20=E6=9E=A2=E7=BA=BD=E5=AE=A1=E6=9F=A5=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 等锁超时、全路径签名、无锁窗口不下发、Fault 终态可等待、Pass+机构不再短路、Alarm 不杀单、catch-up 停在未完成站;VehicleCode=0/重复车号拒绝注册,Hub 已运行时不抢监听口;FileLogger 空目录可写。 Co-authored-by: Cursor --- .../Fass2SimActionResolver.cs | 5 - .../Fass2SimFileLogger.cs | 5 + .../Protocol/Fass2UdpHubMultiCarTests.cs | 39 ++++ .../StandardScene.Magnetic.Tests.csproj | 3 + .../Tasking/Fass2ActionResolverTests.cs | 47 ++++ .../Tasking/Fass2SimFileLoggerTests.cs | 15 ++ .../Fass2TaskStateMachineMustFixTests.cs | 207 ++++++++++++++++++ StandardScene.Magnetic/CarTypes/Mag2Car.cs | 41 +++- .../Protocol/Fass2UdpHub.cs | 62 +++++- .../Tasking/Fass2ActionResolver.cs | 5 - .../Tasking/Fass2TaskStateMachine.cs | 65 ++++-- .../Tasking/Fass2TrafficLocker.cs | 6 - .../docs/Mag2Car使用指南.md | 13 +- 13 files changed, 465 insertions(+), 48 deletions(-) create mode 100644 StandardScene.Magnetic.Tests/Tasking/Fass2ActionResolverTests.cs create mode 100644 StandardScene.Magnetic.Tests/Tasking/Fass2SimFileLoggerTests.cs create mode 100644 StandardScene.Magnetic.Tests/Tasking/Fass2TaskStateMachineMustFixTests.cs 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` 启动 |