新增1.0和2.0两种协议车型车型

This commit is contained in:
ykkokluo
2026-07-17 13:36:01 +08:00
parent a0dc1e6cd0
commit a28bc68e23
62 changed files with 12638 additions and 203 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,20 @@
using Newtonsoft.Json;
using SimpleLite.Props;
using SimpleLite.RCS;
using StandardScene.Chained;
namespace StandardScene
{
/// <summary>
/// 磁导航 FASS 2.0 环线任务进程。
/// <para>任务分配/触发/流量逻辑完全继承 Core <see cref="LoopMission"/>,不修改 Core。</para>
/// <para>导航执行由 <see cref="MagFass2Car"/> 拦截 Loop 编译脚本(含 <c>MagFass2Go</c> + <c>goalSite</c>),
/// 改为单次全程 <c>0xB1</c> 状态机任务。</para>
/// <para>场景内可继续使用原 <see cref="LoopMission"/>;本类型仅作磁导航 FASS2 场景标识。</para>
/// </summary>
[MissionType(Name = "磁导航FASS2环线", editor = typeof(MagFass2LoopMission))]
public class MagFass2LoopMission : LoopMission
{
[JsonIgnore] public override MissionStatus status { get; set; } = new LoopMissionStatus();
}
}
@@ -0,0 +1,351 @@
[
{
"TriggerEnable": true,
"TriggerSource": "Car",
"TriggerEvent": "TimerTick",
"TriggerCarCode": null,
"TriggerCarPrevNodeCode": null,
"TriggerCarCurrentNodeCode": "3",
"TriggerCarNextNodeCode": null,
"TriggerCarState": "Stopping",
"TriggerCarAngle": null,
"ConditionEvents": [
{
"ConditionEnable": true,
"ConditionType": "Method",
"ConditionAssemblyFile": null,
"ConditionTypeName": null,
"ConditionTypeArgs": null,
"ConditionMethodName": "NodeAllUnlockedNotCar",
"ConditionMethodArgs": "3,53,21"
}
],
"TaskEnable": true,
"TaskType": "Start"
},
{
"TriggerEnable": true,
"TriggerSource": "Car",
"TriggerEvent": "TimerTick",
"TriggerCarCode": null,
"TriggerCarPrevNodeCode": null,
"TriggerCarCurrentNodeCode": "16",
"TriggerCarNextNodeCode": null,
"TriggerCarState": "WaitPass",
"TriggerCarAngle": null,
"ConditionEvents": [
{
"ConditionEnable": true,
"ConditionType": "Method",
"ConditionAssemblyFile": null,
"ConditionTypeName": null,
"ConditionTypeArgs": null,
"ConditionMethodName": "NodeAllUnlockedNotCar",
"ConditionMethodArgs": "16,3"
}
],
"TaskEnable": true,
"TaskType": "Start"
},
{
"TriggerEnable": true,
"TriggerSource": "Car",
"TriggerEvent": "TimerTick",
"TriggerCarCode": null,
"TriggerCarPrevNodeCode": null,
"TriggerCarCurrentNodeCode": "21",
"TriggerCarNextNodeCode": null,
"TriggerCarState": "Stopping",
"TriggerCarAngle": null,
"ConditionEvents": [
{
"ConditionEnable": true,
"ConditionType": "Method",
"ConditionAssemblyFile": null,
"ConditionTypeName": null,
"ConditionTypeArgs": null,
"ConditionMethodName": "NodeAllUnlockedNotCar",
"ConditionMethodArgs": "21,1,4"
},
{
"ConditionEnable": true,
"ConditionType": "Method",
"ConditionAssemblyFile": null,
"ConditionTypeName": null,
"ConditionTypeArgs": null,
"ConditionMethodName": "requestStaion",
"ConditionMethodArgs": "DB5524.0.0"
}
],
"TaskEnable": true,
"TaskType": "Start"
},
{
"TriggerEnable": true,
"TriggerSource": "Car",
"TriggerEvent": "TimerTick",
"TriggerCarCode": null,
"TriggerCarPrevNodeCode": null,
"TriggerCarCurrentNodeCode": "1",
"TriggerCarNextNodeCode": null,
"TriggerCarState": "Stopping",
"TriggerCarAngle": null,
"ConditionEvents": [
{
"ConditionEnable": true,
"ConditionType": "Method",
"ConditionAssemblyFile": null,
"ConditionTypeName": null,
"ConditionTypeArgs": null,
"ConditionMethodName": "InplaceStaion",
"ConditionMethodArgs": "DB5524.2.0,DB5524.0.1"
}
],
"TaskEnable": true,
"TaskType": "Start"
},
{
"TriggerEnable": true,
"TriggerSource": "Car",
"TriggerEvent": "TimerTick",
"TriggerCarCode": null,
"TriggerCarPrevNodeCode": null,
"TriggerCarCurrentNodeCode": "2",
"TriggerCarNextNodeCode": null,
"TriggerCarState": "Stopping",
"TriggerCarAngle": null,
"ConditionEvents": [
{
"ConditionEnable": true,
"ConditionType": "Method",
"ConditionAssemblyFile": null,
"ConditionTypeName": null,
"ConditionTypeArgs": null,
"ConditionMethodName": "clearStaion",
"ConditionMethodArgs": "DB5524.2.0"
}
],
"TaskEnable": true,
"TaskType": "Start"
},
{
"TriggerEnable": true,
"TriggerSource": "Car",
"TriggerEvent": "TimerTick",
"TriggerCarCode": null,
"TriggerCarPrevNodeCode": null,
"TriggerCarCurrentNodeCode": "53",
"TriggerCarNextNodeCode": null,
"TriggerCarState": "Running",
"TriggerCarAngle": null,
"ConditionEvents": [
{
"ConditionEnable": true,
"ConditionType": "Method",
"ConditionAssemblyFile": null,
"ConditionTypeName": null,
"ConditionTypeArgs": null,
"ConditionMethodName": "EmegerStop",
"ConditionMethodArgs": "DB5524.0.2"
}
],
"TaskEnable": true,
"TaskType": "Stop"
},
{
"TriggerEnable": true,
"TriggerSource": "Car",
"TriggerEvent": "TimerTick",
"TriggerCarCode": null,
"TriggerCarPrevNodeCode": null,
"TriggerCarCurrentNodeCode": "53",
"TriggerCarNextNodeCode": null,
"TriggerCarState": "Stopping",
"TriggerCarAngle": null,
"ConditionEvents": [
{
"ConditionEnable": true,
"ConditionType": "Method",
"ConditionAssemblyFile": null,
"ConditionTypeName": null,
"ConditionTypeArgs": null,
"ConditionMethodName": "EmegerStart",
"ConditionMethodArgs": "DB5524.0.2"
}
],
"TaskEnable": true,
"TaskType": "Start"
},
{
"TriggerEnable": true,
"TriggerSource": "Car",
"TriggerEvent": "TimerTick",
"TriggerCarCode": null,
"TriggerCarPrevNodeCode": null,
"TriggerCarCurrentNodeCode": "5",
"TriggerCarNextNodeCode": null,
"TriggerCarState": "Stopping,Charging",
"TriggerCarAngle": null,
"ConditionEvents": [
{
"ConditionEnable": true,
"ConditionType": "Method",
"ConditionAssemblyFile": null,
"ConditionTypeName": null,
"ConditionTypeArgs": null,
"ConditionMethodName": "NodeAllUnlockedNotCar",
"ConditionMethodArgs": "5,54"
}
],
"TaskEnable": true,
"TaskType": "Start"
},
{
"TriggerEnable": true,
"TriggerSource": "Car",
"TriggerEvent": "TimerTick",
"TriggerCarCode": null,
"TriggerCarPrevNodeCode": null,
"TriggerCarCurrentNodeCode": "7",
"TriggerCarNextNodeCode": null,
"TriggerCarState": "Running",
"TriggerCarAngle": null,
"ConditionEvents": [
{
"ConditionEnable": true,
"ConditionType": "Method",
"ConditionAssemblyFile": null,
"ConditionTypeName": null,
"ConditionTypeArgs": null,
"ConditionMethodName": "NodeAllUnlockedNotCar",
"ConditionMethodArgs": "7,8"
}
],
"TaskEnable": false,
"TaskType": "Start"
},
{
"TriggerEnable": true,
"TriggerSource": "Car",
"TriggerEvent": "TimerTick",
"TriggerCarCode": null,
"TriggerCarPrevNodeCode": null,
"TriggerCarCurrentNodeCode": "9",
"TriggerCarNextNodeCode": null,
"TriggerCarState": "Running",
"TriggerCarAngle": null,
"ConditionEvents": [
{
"ConditionEnable": true,
"ConditionType": "Method",
"ConditionAssemblyFile": null,
"ConditionTypeName": null,
"ConditionTypeArgs": null,
"ConditionMethodName": "NodeAllUnlockedNotCar",
"ConditionMethodArgs": "9"
}
],
"TaskEnable": false,
"TaskType": "Start"
},
{
"TriggerEnable": true,
"TriggerSource": "Car",
"TriggerEvent": "TimerTick",
"TriggerCarCode": null,
"TriggerCarPrevNodeCode": null,
"TriggerCarCurrentNodeCode": "11",
"TriggerCarNextNodeCode": null,
"TriggerCarState": "Running",
"TriggerCarAngle": null,
"ConditionEvents": [
{
"ConditionEnable": true,
"ConditionType": "Method",
"ConditionAssemblyFile": null,
"ConditionTypeName": null,
"ConditionTypeArgs": null,
"ConditionMethodName": "NodeAllUnlockedNotCar",
"ConditionMethodArgs": "11"
}
],
"TaskEnable": false,
"TaskType": "Start"
},
{
"TriggerEnable": true,
"TriggerSource": "Car",
"TriggerEvent": "TimerTick",
"TriggerCarCode": null,
"TriggerCarPrevNodeCode": null,
"TriggerCarCurrentNodeCode": "14",
"TriggerCarNextNodeCode": null,
"TriggerCarState": "Running",
"TriggerCarAngle": null,
"ConditionEvents": [
{
"ConditionEnable": true,
"ConditionType": "Method",
"ConditionAssemblyFile": null,
"ConditionTypeName": null,
"ConditionTypeArgs": null,
"ConditionMethodName": "NodeAllUnlockedNotCar",
"ConditionMethodArgs": "14"
}
],
"TaskEnable": false,
"TaskType": "Start"
},
{
"TriggerEnable": true,
"TriggerSource": "Car",
"TriggerEvent": "TimerTick",
"TriggerCarCode": null,
"TriggerCarPrevNodeCode": null,
"TriggerCarCurrentNodeCode": "15",
"TriggerCarNextNodeCode": null,
"TriggerCarState": "Running",
"TriggerCarAngle": null,
"ConditionEvents": [
{
"ConditionEnable": true,
"ConditionType": "Method",
"ConditionAssemblyFile": null,
"ConditionTypeName": null,
"ConditionTypeArgs": null,
"ConditionMethodName": "NodeAllUnlockedNotCar",
"ConditionMethodArgs": "15"
}
],
"TaskEnable": false,
"TaskType": "Start"
},
{
"TriggerEnable": true,
"TriggerSource": "Car",
"TriggerEvent": "TimerTick",
"TriggerCarCode": null,
"TriggerCarPrevNodeCode": null,
"TriggerCarCurrentNodeCode": "27",
"TriggerCarNextNodeCode": null,
"TriggerCarState": "Stopping,Charging",
"TriggerCarAngle": null,
"ConditionEvents": [
{
"ConditionEnable": true,
"ConditionType": "Method",
"ConditionAssemblyFile": null,
"ConditionTypeName": null,
"ConditionTypeArgs": null,
"ConditionMethodName": "ChargeStart",
"ConditionMethodArgs": "95"
}
],
"TaskEnable": true,
"TaskType": "Start"
}
]
@@ -7,8 +7,9 @@ namespace StandardScene.Magnetic
{
/// <summary>
/// scene.mag 平台画像:磁导航场景插件。
/// <para>车型为磁导航专用 <see cref="MagCar"/>与下位机通过 UDP 协议交互);路径由
/// MagneticTrackCoder 生成 agv.MagGo / agv.NaiveMagGo 脚本(按 track.Magnet 触发)。</para>
/// <para>车型<see cref="MagCar"/>FASS 1.0 TCP)、<see cref="MagFass2Car"/>FASS 2.0 UDP/任务状态机)。
/// Loop 导航:继续使用 Core <c>LoopMission</c> 或本插件 <c>MagFass2LoopMission</c> 分配 <c>goalSite</c>
/// <see cref="MagFass2Car"/> 拦截编译脚本后一次下发全程 <c>0xB1</c>。</para>
/// 宿主(SimpleLite)加载本 dll 后反射实例化并 OnActivate / 注册。
/// </summary>
public sealed class MagneticSceneProfile : NavigationProfileBase
@@ -22,11 +23,12 @@ namespace StandardScene.Magnetic
public override IReadOnlyList<Type> CarTypes => new[]
{
typeof(MagCar),
typeof(MagFass2Car),
};
public override void OnActivate(ISceneContext context)
{
context.Log($"{DisplayName} 已激活(车型:磁导航车 MagCar");
context.Log($"{DisplayName} 已激活(车型:MagCar / MagFass2Car");
}
}
}
@@ -0,0 +1,14 @@
namespace StandardScene.Magnetic.Protocol
{
/// <summary>
/// FASS 2.0 通讯模式:PLC/TCP 主动查询,或 PCB/UDP 被动接收状态上报。
/// </summary>
public enum Fass2CommMode
{
/// <summary>PLCTCP 同步收发,调度主动发 0x00 查询。</summary>
Tcp = 0,
/// <summary>PCBUDP 监听 AGV 上报,回 0x10,命令单向下发。</summary>
Udp = 1
}
}
@@ -0,0 +1,336 @@
using System;
using System.Linq;
namespace StandardScene.Magnetic.Protocol
{
/// <summary>
/// FASS 2.0 通用控制接口(PLC)报文编解码,对齐
/// <c>FASS.Extend.Car.Fairyland.Plc</c> 与调度器 <c>CarRequestService</c> 逻辑。
/// </summary>
internal static class Fass2Protocol
{
public const byte Begin = 0xBB;
public const byte End = 0xEE;
public const byte CmdQuery = 0x00;
public const byte CmdStart = 0x01;
public const byte CmdStop = 0x02;
public const byte CmdEmergencyStop = 0x03;
public const byte CmdReset = 0x04;
public const byte CmdRest = 0x05;
public const byte CmdShutdown = 0x06;
public const byte CmdAction = 0xA1;
public const byte CmdNodes = 0xB1;
public const byte CmdStateResponse = 0x10;
public const int ControlFrameLength = 50;
public const int StateFrameLength = 100;
public const int ActionFrameLength = 100;
public const int NodesFrameLength = 300;
public static byte Xor(byte[] data, int start, int length)
{
byte xor = 0;
for (var i = 0; i < length; i++)
{
xor ^= data[start + i];
}
return xor;
}
public static byte[] BuildControl(byte command, ushort car, ushort param)
{
var frame = new byte[ControlFrameLength];
frame[0] = Begin;
frame[1] = command;
WriteUInt16(frame, 2, car);
WriteUInt16(frame, 4, param);
frame[48] = Xor(frame, 1, 47);
frame[49] = End;
return frame;
}
public static byte[] BuildNodes(ushort car, ulong taskId, Fass2NodeMessage[] nodes)
{
if (nodes == null || nodes.Length == 0)
{
throw new ArgumentException("nodes required");
}
if (nodes.Length > 10)
{
throw new ArgumentException("FASS2 nodes count must be <= 10");
}
var frame = new byte[NodesFrameLength];
frame[0] = Begin;
frame[1] = CmdNodes;
WriteUInt16(frame, 2, car);
WriteUInt64(frame, 4, taskId);
WriteUInt16(frame, 12, (ushort)nodes.Length);
for (var i = 0; i < nodes.Length; i++)
{
Buffer.BlockCopy(nodes[i].ToBytes(), 0, frame, 14 + i * 25, 25);
}
frame[298] = Xor(frame, 1, 297);
frame[299] = End;
return frame;
}
public static byte[] BuildAction(ushort car, ulong actionId, Fass2NodeMessage node)
{
if (node == null)
{
throw new ArgumentException("node required");
}
var frame = new byte[ActionFrameLength];
frame[0] = Begin;
frame[1] = CmdAction;
WriteUInt16(frame, 2, car);
WriteUInt64(frame, 4, actionId);
Buffer.BlockCopy(node.ToBytes(), 0, frame, 14, 25);
frame[98] = Xor(frame, 1, 97);
frame[99] = End;
return frame;
}
/// <summary>
/// PCB/UDP 模式:收到 AGV 主动上报的 100B 状态后,回 0x10(50B)时间戳应答。
/// </summary>
public static byte[] BuildStateResponse(ushort car, ulong timestampMs)
{
var frame = new byte[ControlFrameLength];
frame[0] = Begin;
frame[1] = CmdStateResponse;
WriteUInt16(frame, 2, car);
WriteUInt64(frame, 4, timestampMs);
var epoch = new DateTime(1970, 1, 1, 8, 0, 0, DateTimeKind.Unspecified);
var date = epoch.AddMilliseconds(timestampMs > long.MaxValue ? long.MaxValue : (long)timestampMs);
frame[12] = (byte)(date.Year - 1970);
frame[13] = (byte)date.Month;
frame[14] = (byte)date.Day;
frame[15] = (byte)date.Hour;
frame[16] = (byte)date.Minute;
frame[17] = (byte)date.Second;
WriteUInt16(frame, 18, (ushort)date.Millisecond);
frame[48] = Xor(frame, 1, 47);
frame[49] = End;
return frame;
}
public static ulong BeijingUnixTimeMs()
{
var epoch = new DateTime(1970, 1, 1, 8, 0, 0, DateTimeKind.Unspecified);
return (ulong)(DateTime.Now - epoch).TotalMilliseconds;
}
public static bool TryExtractStateFrame(byte[] buffer, int length, out byte[] frame)
{
frame = null;
if (buffer == null || length < StateFrameLength)
{
return false;
}
for (var i = 0; i <= length - StateFrameLength; i++)
{
if (buffer[i] != Begin || buffer[i + StateFrameLength - 1] != End)
{
continue;
}
frame = new byte[StateFrameLength];
Buffer.BlockCopy(buffer, i, frame, 0, StateFrameLength);
return true;
}
if (length == StateFrameLength && buffer[0] == Begin && buffer[StateFrameLength - 1] == End)
{
frame = new byte[StateFrameLength];
Buffer.BlockCopy(buffer, 0, frame, 0, StateFrameLength);
return true;
}
return false;
}
public static Fass2StateReport ParseState(byte[] bytes)
{
if (bytes == null || bytes.Length < StateFrameLength)
{
throw new ArgumentException("FASS2 state frame must be 100 bytes");
}
if (bytes[0] != Begin || bytes[99] != End)
{
throw new ArgumentException($"invalid FASS2 state frame: begin=0x{bytes[0]:X2}, end=0x{bytes[99]:X2}");
}
var nodeBytes = new byte[25];
Buffer.BlockCopy(bytes, 53, nodeBytes, 0, 25);
return new Fass2StateReport
{
Command = bytes[1],
Car = ReadUInt16(bytes, 2),
Length = ReadUInt16(bytes, 4),
Width = ReadUInt16(bytes, 6),
BatteryCharge = bytes[28],
BatteryHealth = bytes[29],
BatteryCurrent = ReadUInt16(bytes, 30),
BatteryVoltage = ReadUInt16(bytes, 32),
HeadingAngle = ReadUInt16(bytes, 34),
State = bytes[36],
Alarm = ReadUInt64(bytes, 37),
Task = ReadUInt64(bytes, 45),
Node = Fass2NodeMessage.FromBytes(nodeBytes)
};
}
public static string ToHex(byte[] bytes)
{
return bytes == null ? string.Empty : string.Join(" ", bytes.Select(b => b.ToString("X2")));
}
public static string StateText(byte state)
{
switch (state)
{
case 0: return "未准备";
case 1: return "运行中";
case 2: return "停止中";
case 3: return "急停中";
case 4: return "故障中";
case 5: return "任务中";
case 6: return "休眠中";
case 7: return "关机中";
case 8: return "充电中";
default: return $"未知({state})";
}
}
private static void WriteUInt16(byte[] buffer, int offset, ushort value)
{
var bytes = BitConverter.GetBytes(value);
buffer[offset] = bytes[0];
buffer[offset + 1] = bytes[1];
}
private static void WriteUInt64(byte[] buffer, int offset, ulong value)
{
var bytes = BitConverter.GetBytes(value);
Buffer.BlockCopy(bytes, 0, buffer, offset, 8);
}
private static ushort ReadUInt16(byte[] bytes, int offset)
{
return BitConverter.ToUInt16(bytes, offset);
}
private static ulong ReadUInt64(byte[] bytes, int offset)
{
return BitConverter.ToUInt64(bytes, offset);
}
}
public sealed class Fass2NodeMessage
{
public ushort Node { get; set; }
public ushort Distance { get; set; }
public byte StartStop { get; set; }
public byte Direction { get; set; }
public byte Orientation { get; set; }
public byte Byroad { get; set; }
public ushort Speed { get; set; }
public byte Obstacle { get; set; }
public byte Audio { get; set; }
public byte Light { get; set; }
public byte Charge { get; set; }
public byte Rest { get; set; }
public byte Lift { get; set; }
public byte Clamp { get; set; }
public byte Tray { get; set; }
public byte Roll { get; set; }
public byte Shutdown { get; set; }
public byte[] ToBytes()
{
var bytes = new byte[25];
WriteUInt16(bytes, 0, Node);
WriteUInt16(bytes, 2, Distance);
bytes[4] = StartStop;
bytes[5] = Direction;
bytes[6] = Orientation;
bytes[7] = Byroad;
WriteUInt16(bytes, 8, Speed);
bytes[10] = Obstacle;
bytes[11] = Audio;
bytes[12] = Light;
bytes[13] = Charge;
bytes[14] = Rest;
bytes[15] = Lift;
bytes[16] = Clamp;
bytes[17] = Tray;
bytes[18] = Roll;
bytes[19] = Shutdown;
return bytes;
}
public static Fass2NodeMessage FromBytes(byte[] bytes)
{
if (bytes == null || bytes.Length < 25)
{
throw new ArgumentException("node frame must be 25 bytes");
}
return new Fass2NodeMessage
{
Node = ReadUInt16(bytes, 0),
Distance = ReadUInt16(bytes, 2),
StartStop = bytes[4],
Direction = bytes[5],
Orientation = bytes[6],
Byroad = bytes[7],
Speed = ReadUInt16(bytes, 8),
Obstacle = bytes[10],
Audio = bytes[11],
Light = bytes[12],
Charge = bytes[13],
Rest = bytes[14],
Lift = bytes[15],
Clamp = bytes[16],
Tray = bytes[17],
Roll = bytes[18],
Shutdown = bytes[19]
};
}
private static void WriteUInt16(byte[] buffer, int offset, ushort value)
{
var bytes = BitConverter.GetBytes(value);
buffer[offset] = bytes[0];
buffer[offset + 1] = bytes[1];
}
private static ushort ReadUInt16(byte[] bytes, int offset)
{
return BitConverter.ToUInt16(bytes, offset);
}
}
public sealed class Fass2StateReport
{
public byte Command { get; set; }
public ushort Car { get; set; }
public ushort Length { get; set; }
public ushort Width { get; set; }
public byte BatteryCharge { get; set; }
public byte BatteryHealth { get; set; }
public ushort BatteryCurrent { get; set; }
public ushort BatteryVoltage { get; set; }
public ushort HeadingAngle { get; set; }
public byte State { get; set; }
public ulong Alarm { get; set; }
public ulong Task { get; set; }
public Fass2NodeMessage Node { get; set; } = new Fass2NodeMessage();
}
}
@@ -0,0 +1,59 @@
using System;
using System.Text;
using StandardScene.Magnetic.Protocol;
namespace StandardScene.Magnetic.Protocol
{
/// <summary>
/// 100B 状态帧编码,供模拟器或测试工具复用,字段布局与 <see cref="Fass2Protocol.ParseState"/> 一致。
/// </summary>
public static class Fass2StateCodec
{
public static byte[] BuildState(Fass2StateReport report, string carType = "MagFass2")
{
if (report == null)
{
throw new ArgumentNullException(nameof(report));
}
var frame = new byte[Fass2Protocol.StateFrameLength];
frame[0] = Fass2Protocol.Begin;
frame[1] = report.Command;
WriteUInt16(frame, 2, report.Car);
WriteUInt16(frame, 4, report.Length);
WriteUInt16(frame, 6, report.Width);
var typeBytes = Encoding.ASCII.GetBytes((carType ?? string.Empty).PadRight(16, '\0'));
Buffer.BlockCopy(typeBytes, 0, frame, 12, Math.Min(16, typeBytes.Length));
frame[28] = report.BatteryCharge;
frame[29] = report.BatteryHealth;
WriteUInt16(frame, 30, report.BatteryCurrent);
WriteUInt16(frame, 32, report.BatteryVoltage);
WriteUInt16(frame, 34, report.HeadingAngle);
frame[36] = report.State;
WriteUInt64(frame, 37, report.Alarm);
WriteUInt64(frame, 45, report.Task);
var node = report.Node ?? new Fass2NodeMessage();
Buffer.BlockCopy(node.ToBytes(), 0, frame, 53, 25);
frame[98] = Fass2Protocol.Xor(frame, 1, 97);
frame[99] = Fass2Protocol.End;
return frame;
}
private static void WriteUInt16(byte[] buffer, int offset, ushort value)
{
var bytes = BitConverter.GetBytes(value);
buffer[offset] = bytes[0];
buffer[offset + 1] = bytes[1];
}
private static void WriteUInt64(byte[] buffer, int offset, ulong value)
{
var bytes = BitConverter.GetBytes(value);
Buffer.BlockCopy(bytes, 0, buffer, offset, 8);
}
}
}
@@ -0,0 +1,235 @@
using SimpleCore.Library;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Threading;
namespace StandardScene.Magnetic.Protocol
{
/// <summary>
/// FASS 2.0 PCB/UDP 共享监听枢纽。
/// AGV 主动上报 100B 状态 → 解析并分发给对应车辆 → 回 0x10 时间戳应答到车辆 address:Port。
/// 下发 0xB1/0xA1/0x01 等命令亦通过本枢纽单播到车辆 address:Port。
/// </summary>
internal static class Fass2UdpHub
{
private static readonly object SyncRoot = new object();
private static readonly Dictionary<ushort, IFass2UdpCar> CarsByCode = new Dictionary<ushort, IFass2UdpCar>();
private static UdpClient _listener;
private static Thread _receiveThread;
private static volatile bool _running;
private static int _listenPort = 20103;
private static readonly Dictionary<ushort, long> _lastUnknownCarLogUtcTicks = new Dictionary<ushort, long>();
public static void Register(IFass2UdpCar car)
{
if (car == null)
{
return;
}
lock (SyncRoot)
{
CarsByCode[car.VehicleCode] = car;
}
}
public static void Unregister(IFass2UdpCar car)
{
if (car == null)
{
return;
}
Unregister(car.VehicleCode, car);
}
public static void Unregister(ushort vehicleCode, IFass2UdpCar car)
{
if (car == null)
{
return;
}
lock (SyncRoot)
{
if (CarsByCode.TryGetValue(vehicleCode, out var existing) && ReferenceEquals(existing, car))
{
CarsByCode.Remove(vehicleCode);
}
}
}
public static void EnsureStarted(int listenPort)
{
if (listenPort > 0)
{
_listenPort = listenPort;
}
lock (SyncRoot)
{
if (_running)
{
return;
}
_listener = new UdpClient(_listenPort);
_running = true;
_receiveThread = new Thread(ReceiveLoop)
{
IsBackground = true,
Name = $"Fass2UdpHub:{_listenPort}"
};
_receiveThread.Start();
Diagnosis.Post($"Fass2UdpHub 已启动,监听 UDP {_listenPort}", "Fass2UdpHub", true);
}
}
public static void Stop()
{
lock (SyncRoot)
{
_running = false;
try
{
_listener?.Close();
}
catch
{
}
_listener = null;
}
try
{
_receiveThread?.Join(1000);
}
catch
{
}
_receiveThread = null;
}
public static void SendToCar(IFass2UdpCar car, byte[] payload)
{
if (car == null || payload == null || payload.Length == 0)
{
return;
}
if (string.IsNullOrWhiteSpace(car.RemoteAddress) || car.RemotePort <= 0)
{
throw new InvalidOperationException(
$"Fass2UdpHub send failed: car VehicleCode={car.VehicleCode} remote endpoint not configured");
}
using var sender = new UdpClient();
var remote = new IPEndPoint(IPAddress.Parse(car.RemoteAddress), car.RemotePort);
sender.Send(payload, payload.Length, remote);
}
private static void ReceiveLoop()
{
while (_running)
{
try
{
var remote = new IPEndPoint(IPAddress.Any, 0);
var packet = _listener.Receive(ref remote);
ProcessPacket(packet, remote);
}
catch (SocketException) when (!_running)
{
break;
}
catch (ObjectDisposedException) when (!_running)
{
break;
}
catch (Exception ex)
{
Diagnosis.Post($"Fass2UdpHub receive error: {ex.Message}", "Fass2UdpHub", true);
}
}
}
private static void ProcessPacket(byte[] packet, IPEndPoint remote)
{
if (!Fass2Protocol.TryExtractStateFrame(packet, packet.Length, out var frame))
{
return;
}
Fass2StateReport report;
try
{
report = Fass2Protocol.ParseState(frame);
}
catch (Exception ex)
{
Diagnosis.Post($"Fass2UdpHub parse error from {remote}: {ex.Message}", "Fass2UdpHub", true);
return;
}
IFass2UdpCar car;
lock (SyncRoot)
{
if (!CarsByCode.TryGetValue(report.Car, out car))
{
LogUnknownCar(report.Car);
return;
}
}
try
{
car.OnUdpStateReceived(report);
}
catch (Exception ex)
{
Diagnosis.Post($"Fass2UdpHub dispatch error car={report.Car}: {ex.Message}", "Fass2UdpHub", true);
}
try
{
var ack = Fass2Protocol.BuildStateResponse(report.Car, Fass2Protocol.BeijingUnixTimeMs());
SendToCar(car, ack);
}
catch (Exception ex)
{
Diagnosis.Post($"Fass2UdpHub 0x10 reply failed car={report.Car}: {ex.Message}", "Fass2UdpHub", true);
}
}
private static void LogUnknownCar(ushort carCode)
{
var nowTicks = DateTime.UtcNow.Ticks;
lock (SyncRoot)
{
if (_lastUnknownCarLogUtcTicks.TryGetValue(carCode, out var lastTicks)
&& nowTicks - lastTicks < TimeSpan.FromSeconds(5).Ticks)
{
return;
}
_lastUnknownCarLogUtcTicks[carCode] = nowTicks;
}
var registered = string.Join(",", CarsByCode.Keys);
if (string.IsNullOrEmpty(registered))
{
registered = "(无)";
}
Diagnosis.Post(
$"Fass2UdpHub 收到 Car={carCode} 的状态,但未注册该编号(已注册: {registered})。请检查 MagFass2Car.VehicleCode 与模拟器车号是否一致,并重新启动场景。",
"Fass2UdpHub",
true);
}
}
}
@@ -0,0 +1,16 @@
namespace StandardScene.Magnetic.Protocol
{
/// <summary>
/// 由 <see cref="Fass2UdpHub"/> 回调的 UDP/PCB 车型处理接口。
/// </summary>
internal interface IFass2UdpCar
{
ushort VehicleCode { get; }
string RemoteAddress { get; }
int RemotePort { get; }
void OnUdpStateReceived(Fass2StateReport report);
}
}
@@ -0,0 +1,251 @@
using LessokajiWeaverUtilities.Utilities;
using Newtonsoft.Json;
using SimpleLite.RCS;
using SimpleLite.Props;
using SimpleCore;
using SimpleCore.Library;
using SimpleCore.PropType;
using StandardScene;
using StandardScene.Scheduler.FassEvent;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using SimpleLite.RCS.CarTypes;
namespace StandardScene.Scheduler
{
/// <summary>
/// FASS 车辆事件调度进程。
/// <para>
/// 移植自 <c>fass/FASS.Scheduler/Services/Events/EventCarService.cs</c>
/// 按 JSON 配置定时轮询全场车辆,匹配触发条件后执行交管/PLC/电量等条件判断,
/// 最终对 MagCar 下发 Start(放行)或 Stop(停车)。
/// </para>
/// <para>
/// 使用方式:在场景中添加本 Mission →「启动进程」→ 默认加载 <c>Configs\EventCar\FG2512073.json</c>。
/// </para>
/// </summary>
[MissionType(Name = "FASS车辆事件进程", editor = typeof(EventCarMission))]
[I18N.DocumentTranslation(Name = "FASS Event Car Mission", locale = "en")]
public class EventCarMission : Mission
{
/// <summary>EventCar JSON 配置文件路径(相对程序目录或绝对路径)。</summary>
[FieldMember] public string EventConfigPath = "Config\\EventCar\\traffic.json";
/// <summary>主循环轮询间隔(毫秒),对应 FASS TimerTick 频率,默认 500ms。</summary>
[FieldMember] public int TickIntervalMs = 500;
/// <summary>与下料站/安全信号交互用的西门子 PLC IP。</summary>
[FieldMember] public string PlcIpAddress = "127.0.0.1";
/// <summary>PLC 端口,S7 默认 102。</summary>
[FieldMember] public int PlcPort = 102;
/// <summary>PLC 型号字符串,如 S7_1500、S7_300,传给 IoTClient SiemensClient。</summary>
[FieldMember] public string PlcVersion = "S7_1500";
/// <summary>是否启用 ME11 下料站 PLC 信号交互(false 时 requestStaion/InplaceStaion 等直接返回 true)。</summary>
[FieldMember] public bool IsEnableMEll = true;
/// <summary>充电完成放行默认电量阈值(%),可被事件内 ChargeStart 参数覆盖。</summary>
[FieldMember] public double ChargeStartThreshold = 93;
[JsonIgnore] private bool _started;
[JsonIgnore] private Thread _workerThread;
[JsonIgnore] private FassEventCarHandler _handler;
/// <summary>当前生效的车辆事件配置列表(TriggerSource=Car 且 TriggerEnable=true)。</summary>
[JsonIgnore] private List<FassEventCarConfig> _eventCars = new List<FassEventCarConfig>();
/// <summary>记录每辆车上一周期的 CurrentNode,用于 PrevNode 触发与地标变化时清除任务防重复标记。</summary>
[JsonIgnore] private readonly Dictionary<int, string> _prevNodeByCarId = new Dictionary<int, string>();
/// <summary>进程运行状态扩展字段。</summary>
public class EventCarMissionStatus : MissionStatus
{
/// <summary>主循环已执行次数。</summary>
public int TickCount { get; set; }
/// <summary>已加载的事件规则条数。</summary>
public int LoadedEventCount { get; set; }
/// <summary>实际加载的配置文件绝对/解析路径。</summary>
public string ConfigPath { get; set; }
}
public override MissionStatus status { get; set; } = new EventCarMissionStatus();
public static Mission Create()
{
return new EventCarMission();
}
/// <summary>
/// 启动后台轮询线程:加载配置 → 创建 Handler → 周期性 ProcessAllCars。
/// </summary>
[MethodMember(Name = "启动进程", Description = "加载 FASS EventCar 配置并开始轮询车辆事件")]
public override void Execute()
{
if (_started)
{
status.status = "已在运行";
return;
}
try
{
ReloadConfig();
}
catch (Exception ex)
{
status.status = "配置加载失败";
Diagnosis.Post($"FASS车辆事件进程配置加载失败:{ExceptionFormatter.FormatEx(ex)}", "FassEventCar", true);
throw;
}
_handler = new FassEventCarHandler(this);
_started = true;
status.status = "运行中";
_workerThread = new Thread(WorkerLoop)
{
IsBackground = true,
Name = "EventCarMission"
};
_workerThread.Start();
Diagnosis.Post($"FASS车辆事件进程已启动,配置数={_eventCars.Count}", "FassEventCar", true);
}
/// <summary>热加载 JSON 配置,运行中也可调用。</summary>
[MethodMember(Name = "重新加载配置", Description = "重新读取 EventCar JSON 配置")]
public void ReloadConfig()
{
var path = ResolveConfigPath();
_eventCars = FassEventConfigLoader.Load(path)
.Where(item => item.TriggerEnable && string.Equals(item.TriggerSource, "Car", StringComparison.OrdinalIgnoreCase))
.ToList();
var missionStatus = (EventCarMissionStatus)status;
missionStatus.LoadedEventCount = _eventCars.Count;
missionStatus.ConfigPath = path;
status.status = _started ? "运行中" : "配置已加载";
Diagnosis.Post($"FASS EventCar 配置已加载:{path},事件数={_eventCars.Count}", "FassEventCar", true);
}
/// <summary>停止轮询并等待工作线程结束(最多 2 秒)。</summary>
[MethodMember(Name = "停止进程", Description = "停止 FASS 车辆事件轮询")]
public void Stop()
{
_started = false;
try
{
_workerThread?.Join(2000);
}
catch
{
}
status.status = "已停止";
Diagnosis.Post("FASS车辆事件进程已停止", "FassEventCar", true);
}
/// <summary>
/// 后台主循环:每 TickIntervalMs 扫描一次全场车辆。
/// 单轮异常不会终止进程,仅写诊断日志。
/// </summary>
private void WorkerLoop()
{
while (_started)
{
try
{
ProcessAllCars();
var missionStatus = (EventCarMissionStatus)status;
missionStatus.TickCount++;
status.status = $"运行中:{missionStatus.TickCount}";
}
catch (Exception ex)
{
Diagnosis.Post($"FASS车辆事件进程循环异常:{ExceptionFormatter.FormatEx(ex)}", "FassEventCar", true);
}
Thread.Sleep(Math.Max(100, TickIntervalMs));
}
}
/// <summary>
/// 遍历所有 Car,构建快照并异步调用 Handler(与 FASS Task.Run 行为一致)。
/// <para>
/// 跳过 status 为空或 usage.refreshing=false 的车辆(未参与调度刷新的车不处理)。
/// 当地标变化时清除 <see cref="FassEventCarHandler.ActiveTaskTag"/>,允许在新地标重新触发事件。
/// </para>
/// </summary>
private void ProcessAllCars()
{
foreach (var car in SimpleLib.GetAllCars().OfType<Car>())
{
if (car.status == null)
{
continue;
}
var usage = car.status.usage?.Get();
if (usage != null && usage.refreshing == false)
{
continue;
}
var currentNode = FassEventCarSnapshotFactory.GetCurrentNodeCode(car);
_prevNodeByCarId.TryGetValue(car.id, out var prevNode);
var snapshot = FassEventCarSnapshotFactory.Create(car, prevNode, FassEventCarSnapshotFactory.GetNextNodeCode(car));
Task.Run(() => _handler.HandleCarTimerTick(car, snapshot, _eventCars));
if (!string.Equals(prevNode, currentNode, StringComparison.OrdinalIgnoreCase))
{
_prevNodeByCarId[car.id] = currentNode;
ClearTaskTags(car);
}
}
}
/// <summary>车辆换地标后删除防重复执行标签,使同一规则可在新站点再次触发。</summary>
private static void ClearTaskTags(Car car)
{
if (car.tags == null)
{
return;
}
Commons.DeleteTag(car.tags, FassEventCarHandler.ActiveTaskTag);
}
/// <summary>
/// 解析配置文件路径:绝对路径直接用;相对路径依次尝试 BaseDirectory、CurrentDirectory、上级目录。
/// </summary>
private string ResolveConfigPath()
{
if (Path.IsPathRooted(EventConfigPath))
{
return EventConfigPath;
}
var candidates = new[]
{
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, EventConfigPath),
Path.Combine(Directory.GetCurrentDirectory(), EventConfigPath),
Path.GetFullPath(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", EventConfigPath))
};
return candidates.FirstOrDefault(File.Exists) ?? candidates[0];
}
}
}
@@ -0,0 +1,577 @@
using IoTClient.Clients.PLC;
using IoTClient.Common.Enums;
using LessokajiWeaverUtilities.Utilities;
using SimpleLite.RCS;
using SimpleCore.Library;
using SimpleCore.PropType;
using StandardScene;
using StandardScene.CarTypes;
using StandardScene.Scheduler;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using SimpleLite.RCS.CarTypes;
namespace StandardScene.Scheduler.FassEvent
{
/// <summary>
/// FASS 车辆事件核心处理器,逻辑对齐 <c>EventCarService</c>。
/// <para>处理流程:TriggerHandler(触发匹配)→ ConditionHandler(条件 AND)→ ExecuteTaskStart/Stop)。</para>
/// </summary>
internal sealed class FassEventCarHandler
{
/// <summary>
/// 防重复执行任务的车辆标签名。同一地标周期内只执行一次,换地标后由 EventCarMission 清除。
/// 标签值为 <see cref="BuildTaskKey"/> 生成的任务指纹。
/// </summary>
internal const string ActiveTaskTag = "FassEvent:Active";
private readonly EventCarMission _mission;
/// <summary>PLC 读写串行锁,避免多车并发条件中同时 Open/Close 同一连接。</summary>
private readonly object _plcLock = new object();
/// <summary>JSON ConditionMethodName → 内置条件方法 映射表。</summary>
private readonly Dictionary<string, Func<FassEventCarSnapshot, FassConditionEventConfig, bool>> _eventMethods;
public FassEventCarHandler(EventCarMission mission)
{
_mission = mission;
_eventMethods = BuildEventMethods();
}
/// <summary>
/// 单次 TimerTick 对一辆车的处理入口(通常在 Task.Run 中调用)。
/// 仅处理 TriggerEvent=TimerTick 的配置项。
/// </summary>
public void HandleCarTimerTick(Car car, FassEventCarSnapshot snapshot, IReadOnlyList<FassEventCarConfig> eventCars)
{
foreach (var eventConfig in eventCars.Where(item => string.Equals(item.TriggerEvent, "TimerTick", StringComparison.OrdinalIgnoreCase)))
{
if (!TriggerHandler(snapshot, eventConfig))
{
continue;
}
if (!ConditionHandler(car, snapshot, eventConfig))
{
continue;
}
if (!eventConfig.TaskEnable)
{
continue;
}
ExecuteTask(car, snapshot, eventConfig);
}
}
/// <summary>注册与 FASS EventCarService.InitEventMethod 一致的条件方法名(保留历史拼写)。</summary>
private Dictionary<string, Func<FassEventCarSnapshot, FassConditionEventConfig, bool>> BuildEventMethods()
{
return new Dictionary<string, Func<FassEventCarSnapshot, FassConditionEventConfig, bool>>(StringComparer.OrdinalIgnoreCase)
{
["NodeAllUnlockedNotCar"] = NodeAllUnlockedNotCar,
["NodeAllUnlocked"] = NodeAllUnlocked,
["NodeAnyLockedNotCar"] = NodeAnyLockedNotCar,
["NodeAnyLocked"] = NodeAnyLocked,
["NodeAllUnlockedNotCarNotEndCarState"] = NodeAllUnlockedNotCarNotEndCarState,
["requestStaion"] = RequestStation,
["clearStaion"] = ClearStation,
["EmegerStop"] = EmergencyStop,
["EmegerStart"] = EmergencyStart,
["IsExistRunningStateCar"] = IsExistRunningStateCar,
["ChargeStart"] = ChargeStart
};
}
/// <summary>
/// 触发器匹配:车辆编号、前后地标、状态、角度等 CSV 过滤。
/// 配置项为空表示“不限制”;车辆对应字段为空则无法匹配非空配置。
/// </summary>
private bool TriggerHandler(FassEventCarSnapshot snapshot, FassEventCarConfig config)
{
if (!MatchesCsvFilter(config.TriggerCarCode, snapshot.Code))
{
return false;
}
if (!MatchesCsvFilter(config.TriggerCarPrevNodeCode, snapshot.PrevNodeCode))
{
return false;
}
if (!MatchesCsvFilter(config.TriggerCarCurrentNodeCode, snapshot.CurrentNodeCode))
{
return false;
}
if (!MatchesCsvFilter(config.TriggerCarNextNodeCode, snapshot.NextNodeCode))
{
return false;
}
if (!MatchesCsvFilter(config.TriggerCarState, snapshot.State))
{
return false;
}
if (!MatchesCsvFilter(config.TriggerCarAngle, snapshot.Angle.ToString(CultureInfo.InvariantCulture)))
{
return false;
}
return true;
}
/// <summary>所有 ConditionEvents 必须全部通过(逻辑与)。</summary>
private bool ConditionHandler(Car car, FassEventCarSnapshot snapshot, FassEventCarConfig config)
{
if (config.ConditionEvents == null || config.ConditionEvents.Count == 0)
{
return true;
}
foreach (var condition in config.ConditionEvents)
{
if (!SingleConditionHandler(car, snapshot, condition))
{
return false;
}
}
return true;
}
/// <summary>单条条件分发:Method / Assembly / Plugin。</summary>
private bool SingleConditionHandler(Car car, FassEventCarSnapshot snapshot, FassConditionEventConfig condition)
{
if (!condition.ConditionEnable)
{
return true;
}
var conditionType = condition.ConditionType ?? "Method";
if (string.Equals(conditionType, "Method", StringComparison.OrdinalIgnoreCase))
{
return MethodInvoke(snapshot, condition);
}
if (string.Equals(conditionType, "Assembly", StringComparison.OrdinalIgnoreCase) ||
string.Equals(conditionType, "Plugin", StringComparison.OrdinalIgnoreCase))
{
LogSignal($"暂不支持的 ConditionType={conditionType}, Method={condition.ConditionMethodName}");
return false;
}
return false;
}
/// <summary>按方法名调用内置条件实现。</summary>
private bool MethodInvoke(FassEventCarSnapshot snapshot, FassConditionEventConfig condition)
{
if (string.IsNullOrWhiteSpace(condition.ConditionMethodName))
{
return false;
}
if (_eventMethods.TryGetValue(condition.ConditionMethodName, out var handler))
{
return handler(snapshot, condition);
}
LogSignal($"未注册的条件方法:{condition.ConditionMethodName}");
return false;
}
/// <summary>
/// 执行任务:MagCar 调用 SendStart/SendStop;其他车型仅打日志。
/// 通过 ActiveTaskTag 防止同一地标周期内重复下发。
/// </summary>
private void ExecuteTask(Car car, FassEventCarSnapshot snapshot, FassEventCarConfig config)
{
if (car.tags != null && car.tags.Contains(ActiveTaskTag))
{
return;
}
var taskType = config.TaskType ?? "Start";
if (car is MagCar magCar)
{
switch (taskType)
{
case "Start":
magCar.SendStart((ushort)Math.Round(magCar.th));
break;
case "Stop":
magCar.SendStop(0);
break;
default:
LogTraffic($"车辆[{snapshot.Code}] 收到任务类型[{taskType}],当前仅显式处理 Start/Stop");
break;
}
}
else
{
LogTraffic($"车辆[{snapshot.Code}] 非 MagCar,任务类型[{taskType}] 仅记录标签");
}
var taskKey = BuildTaskKey(snapshot, config);
Commons.AddOrUpdateTag(car.tags, ActiveTaskTag, taskKey);
LogTraffic($"车辆[{snapshot.Code}] 触发事件任务:{taskType} @ node={snapshot.CurrentNodeCode}, state={snapshot.State}");
}
/// <summary>生成任务指纹,写入 ActiveTaskTag 的值,便于日志追溯。</summary>
private static string BuildTaskKey(FassEventCarSnapshot snapshot, FassEventCarConfig config)
{
return $"FassEvent:{snapshot.CurrentNodeCode}:{config.TriggerCarState}:{config.TaskType}:{string.Join("|", config.ConditionEvents?.Select(item => item.ConditionMethodName) ?? Array.Empty<string>())}";
}
#region
/// <summary>指定节点全部未被其他车辆占用(排除自身)。参数:逗号分隔节点号。</summary>
private bool NodeAllUnlockedNotCar(FassEventCarSnapshot snapshot, FassConditionEventConfig condition)
{
return FassEventNodeHelper.NodeAllUnlockedNotCar(snapshot.Code, SplitArgs(condition.ConditionMethodArgs));
}
/// <summary>指定节点全部空闲(含自身锁也计入)。</summary>
private bool NodeAllUnlocked(FassEventCarSnapshot snapshot, FassConditionEventConfig condition)
{
return FassEventNodeHelper.NodeAllUnlocked(SplitArgs(condition.ConditionMethodArgs));
}
/// <summary>指定节点中存在被其他车占用的点。</summary>
private bool NodeAnyLockedNotCar(FassEventCarSnapshot snapshot, FassConditionEventConfig condition)
{
return FassEventNodeHelper.NodeAnyLockedNotCar(snapshot.Code, SplitArgs(condition.ConditionMethodArgs));
}
/// <summary>
/// 指定节点存在占用时先 SendStart 再判断(与 FASS NodeAnyLocked 行为一致)。
/// </summary>
private bool NodeAnyLocked(FassEventCarSnapshot snapshot, FassConditionEventConfig condition)
{
var car = SimpleCore.SimpleLib.GetCar(snapshot.CarId) as MagCar;
car?.SendStart((ushort)Math.Round(car.th));
return FassEventNodeHelper.NodeAnyLocked(SplitArgs(condition.ConditionMethodArgs));
}
/// <summary>节点全解锁且路径上无其他运行中车辆。</summary>
private bool NodeAllUnlockedNotCarNotEndCarState(FassEventCarSnapshot snapshot, FassConditionEventConfig condition)
{
return FassEventNodeHelper.NodeAllUnlockedNotCarNotEndCarState(snapshot.Code, "Runing", SplitArgs(condition.ConditionMethodArgs));
}
/// <summary>判断指定节点范围是否不存在其他“运行中”车辆(返回 true 表示可以放行)。</summary>
private bool IsExistRunningStateCar(FassEventCarSnapshot snapshot, FassConditionEventConfig condition)
{
var param = SplitArgs(condition.ConditionMethodArgs);
LogTraffic($"IsExistRunningStateCar 参数:{condition.ConditionMethodArgs}");
return !FassEventNodeHelper.ExistsRunningCarOnNodes(snapshot.Code, "Runing", param);
}
#endregion
#region
/// <summary>
/// 电量达到阈值方可放行。阈值优先取 ConditionMethodArgs 首参,否则用 Mission.ChargeStartThreshold。
/// </summary>
private bool ChargeStart(FassEventCarSnapshot snapshot, FassConditionEventConfig condition)
{
var threshold = _mission.ChargeStartThreshold;
var configured = SplitArgs(condition.ConditionMethodArgs).FirstOrDefault();
if (!string.IsNullOrWhiteSpace(configured) &&
double.TryParse(configured, NumberStyles.Float, CultureInfo.InvariantCulture, out var eventThreshold))
{
threshold = eventThreshold;
}
var result = snapshot.Charge >= threshold;
if (result)
{
LogSignal($"ChargeStart 车辆[{snapshot.Code}] 电量[{snapshot.Charge}] >= 阈值[{threshold}]");
}
return result;
}
/// <summary>
/// 设备安全急停:读 PLC 布尔位,false 表示故障需停车(条件返回 true 以触发 Stop 任务)。
/// 参数 param[0]PLC 地址。
/// </summary>
private bool EmergencyStop(FassEventCarSnapshot snapshot, FassConditionEventConfig condition)
{
return ExecutePlcCondition(snapshot, condition, 1, (client, param) =>
{
var allowIn = client.ReadBoolean(param[0]).Value;
if (!allowIn)
{
LogSignal($"设备安全故障急停:{allowIn}");
return true;
}
return false;
});
}
/// <summary>
/// 设备安全恢复:PLC 布尔位为 true 时允许 Start。
/// </summary>
private bool EmergencyStart(FassEventCarSnapshot snapshot, FassConditionEventConfig condition)
{
return ExecutePlcCondition(snapshot, condition, 1, (client, param) =>
{
var allowIn = client.ReadBoolean(param[0]).Value;
if (allowIn)
{
LogSignal($"设备安全恢复放行:{allowIn}");
return true;
}
return false;
});
}
#endregion
#region ME11 PLC
/// <summary>
/// 清除站点到位与离开中信号。
/// 参数:param[0] 到位信号地址,param[1] 离开中信号地址。
/// </summary>
private bool ClearStation(FassEventCarSnapshot snapshot, FassConditionEventConfig condition)
{
var context = BuildStationLogContext(nameof(ClearStation), snapshot, condition);
LogSignal($"{context} 开始清除站点到位/离开中信号");
if (!_mission.IsEnableMEll)
{
LogSignal($"车辆[{snapshot.Code}] ME11 未启用,直接返回 true");
return true;
}
return ExecutePlcCondition(snapshot, condition, 2, (client, param) =>
{
var afterClear = WriteAndRead(client, param[0], false, "清除到位信号", context);
WriteStationSignal(client, param[1], false, "清除离开中信号", context);
var leavingClear = client.ReadBoolean(param[1]).Value;
var result = !afterClear && !leavingClear;
LogSignal($"{context} 清除站点信号结束,返回[{result}]");
return result;
});
}
/// <summary>
/// 请求进入工位:读“请求进入”,为 true 时写“正在进入中”,并以进入中信号作为放行条件结果。
/// 参数:param[0] 请求进入,param[1] 正在进入中。
/// </summary>
private bool RequestStation(FassEventCarSnapshot snapshot, FassConditionEventConfig condition)
{
var context = BuildStationLogContext(nameof(RequestStation), snapshot, condition);
LogSignal($"{context} 开始请求进入信号判断");
if (!_mission.IsEnableMEll)
{
LogSignal($"车辆[{snapshot.Code}] ME11 未启用,直接返回 true");
return true;
}
return ExecutePlcCondition(snapshot, condition, 2, (client, param) =>
{
var requestIn = ReadStationSignal(client, param[0], "请求进入", context);
if (requestIn)
{
WriteStationSignal(client, param[1], true, "正在进入中", context);
}
var result = ReadStationSignal(client, param[1], "正在进入中", context);
LogSignal($"{context} 请求进入判断结束,返回[{result}]");
return result;
});
}
/// <summary>
/// 到位与允许离开交互(ME11 下料核心逻辑)。
/// <para>
/// 参数:param[0] 到位,param[1] 允许离开,param[2] 正在进入中,param[3] 正在离开中。
/// ME11 车型:气缸伸出(Read3&amp;Read5)后写到位;读到允许离开后切换为放行车型。
/// 放行车型:气缸收回(Read4&amp;Read6)后写离开中、清除到位。
/// </para>
/// </summary>
/// <summary>将车辆 fields["CarModel"] 切换为放行车型,供后续触发器匹配。</summary>
private void SaveCarModel(int carId, string carModel, string reason)
{
var car = SimpleCore.SimpleLib.GetCar(carId) as Car;
if (car == null)
{
return;
}
car.fields ??= new Dictionary<string, string>();
if (string.Equals(FassEventCarSnapshotFactory.GetCarModel(car), carModel, StringComparison.OrdinalIgnoreCase))
{
return;
}
car.fields["CarModel"] = carModel;
LogSignal($"{reason}:车辆[{FassEventCarSnapshotFactory.GetCarCode(car)}] 车型切换为 [{carModel}]");
}
#endregion
#region PLC
/// <summary>在 PLC 锁内打开连接、执行读写、关闭连接。</summary>
private bool ExecutePlcCondition(
FassEventCarSnapshot snapshot,
FassConditionEventConfig condition,
int minArgs,
Func<SiemensClient, string[], bool> action)
{
if (!TryGetConditionArgs(condition, minArgs, out var param))
{
return false;
}
lock (_plcLock)
{
SiemensClient client = null;
try
{
client = CreatePlcClient();
client.Open();
return action(client, param);
}
catch (Exception ex)
{
LogSignalError(ex, "交互站点报错");
return false;
}
finally
{
client?.Close();
}
}
}
/// <summary>根据 Mission 配置创建西门子 PLC 客户端。</summary>
private SiemensClient CreatePlcClient()
{
var version = ParsePlcVersion(_mission.PlcVersion);
return new SiemensClient(version, _mission.PlcIpAddress, _mission.PlcPort, 0);
}
private static SiemensVersion ParsePlcVersion(string version)
{
if (string.IsNullOrWhiteSpace(version))
{
return SiemensVersion.S7_1500;
}
return Enum.TryParse(version, true, out SiemensVersion parsed) ? parsed : SiemensVersion.S7_1500;
}
/// <summary>拼接站点交互日志上下文,便于现场对照 PLC 地址排查。</summary>
private string BuildStationLogContext(string methodName, FassEventCarSnapshot snapshot, FassConditionEventConfig condition)
{
return $"{methodName} 车辆[{snapshot.Code}] 状态[{snapshot.State}] 当前地标[{snapshot.CurrentNodeCode}] 下一地标[{snapshot.NextNodeCode}] 车型[{snapshot.CarModel}] 参数[{condition.ConditionMethodArgs}]";
}
private bool ReadStationSignal(SiemensClient client, string address, string signalName, string context)
{
var value = client.ReadBoolean(address).Value;
LogSignal($"{context} 读取信号[{signalName}] 地址[{address}] 值[{value}]");
return value;
}
private void WriteStationSignal(SiemensClient client, string address, bool value, string signalName, string context)
{
LogSignal($"{context} 写入信号[{signalName}] 地址[{address}] 值[{value}]");
client.Write(address, value);
var readBackValue = client.ReadBoolean(address).Value;
LogSignal($"{context} 回读信号[{signalName}] 地址[{address}] 值[{readBackValue}]");
}
private bool WriteAndRead(SiemensClient client, string address, bool value, string signalName, string context)
{
WriteStationSignal(client, address, value, signalName, context);
return client.ReadBoolean(address).Value;
}
/// <summary>校验条件参数个数是否满足 PLC 方法要求。</summary>
private static bool TryGetConditionArgs(FassConditionEventConfig condition, int minLength, out string[] args)
{
args = SplitArgs(condition.ConditionMethodArgs);
return args.Length >= minLength;
}
/// <summary>将逗号分隔的配置参数字符串拆分为数组。</summary>
private static string[] SplitArgs(string args)
{
if (string.IsNullOrWhiteSpace(args))
{
return Array.Empty<string>();
}
return args.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)
.Select(item => item.Trim())
.ToArray();
}
/// <summary>
/// CSV 过滤器:filter 为空则通过;否则 value 必须等于 filter 中某项(忽略大小写)。
/// 用于 TriggerCarState 等多值匹配,如 "Stopping,Charging"。
/// </summary>
private static bool MatchesCsvFilter(string filter, string value)
{
if (string.IsNullOrWhiteSpace(filter))
{
return true;
}
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
return filter.Split(',').Select(item => item.Trim()).Any(item => string.Equals(item, value, StringComparison.OrdinalIgnoreCase));
}
private void LogTraffic(string message) => Diagnosis.Post(message, "Fass交管", true);
private void LogSignal(string message) => Diagnosis.Post(message, "Fass信号交互", true);
private void LogSignalError(Exception ex, string message) =>
Diagnosis.Post($"{message}: {ExceptionFormatter.FormatEx(ex)}", "Fass信号交互", true);
#endregion
}
/// <summary>从磁盘加载 EventCar JSON 配置数组。</summary>
internal static class FassEventConfigLoader
{
/// <summary>
/// 读取并反序列化配置文件。
/// </summary>
/// <param name="configPath">JSON 文件路径。</param>
/// <returns>事件配置列表;文件内容为空数组时返回空列表。</returns>
/// <exception cref="FileNotFoundException">文件不存在时抛出。</exception>
public static List<FassEventCarConfig> Load(string configPath)
{
if (string.IsNullOrWhiteSpace(configPath) || !File.Exists(configPath))
{
throw new FileNotFoundException($"未找到 FASS 车辆事件配置文件:{configPath}");
}
var json = File.ReadAllText(configPath);
return Newtonsoft.Json.JsonConvert.DeserializeObject<List<FassEventCarConfig>>(json)
?? new List<FassEventCarConfig>();
}
}
}
@@ -0,0 +1,165 @@
using SimpleLite.RCS;
using SimpleCore.PropType;
using StandardScene.CarTypes;
using System;
using System.Globalization;
using SimpleLite.RCS.CarTypes;
namespace StandardScene.Scheduler.FassEvent
{
/// <summary>
/// 将 StandardScene 车辆对象转换为 FASS 事件系统可识别的快照字段。
/// 负责编号、地标、状态、电量等与 <c>EventCarService.TriggerHandler</c> 对齐的映射。
/// </summary>
internal static class FassEventCarSnapshotFactory
{
/// <summary>
/// 获取车辆“下一地标”编码。
/// 优先级:status.enums["NextNode"] → tags["NextNode"] → pendingLocks 首站(TagValue 或站点 ID)。
/// </summary>
public static string GetNextNodeCode(Car car)
{
if (car.status?.enums != null && car.status.enums.TryGetValue("NextNode", out var node) && !string.IsNullOrWhiteSpace(node))
{
return node;
}
if (car.tags != null && car.tags.TryGetValue("NextNode", out var tagNode) && !string.IsNullOrWhiteSpace(tagNode))
{
return tagNode;
}
// 调度 pendingLocks 通常表示车辆即将前往的站点,可近似为 FASS NextNodeCode。
if (car.status?.pendingLocks != null && car.status.pendingLocks.Length > 0)
{
var pendingSiteId = car.status.pendingLocks[0];
var site = SimpleCore.SimpleLib.GetSite(pendingSiteId);
if (site?.fields != null && site.fields.TryGetValue("TagValue", out var tagValue) && !string.IsNullOrWhiteSpace(tagValue))
{
return tagValue;
}
return pendingSiteId.ToString(CultureInfo.InvariantCulture);
}
return string.Empty;
}
/// <summary>
/// 根据车辆与上一周期地标构建完整快照。
/// </summary>
/// <param name="car">场景车辆。</param>
/// <param name="prevNodeCode">上一周期 CurrentNode,由 EventCarMission 维护。</param>
/// <param name="nextNodeCode">下一地标;为空时自动调用 <see cref="GetNextNodeCode"/>。</param>
public static FassEventCarSnapshot Create(Car car, string prevNodeCode, string nextNodeCode)
{
return new FassEventCarSnapshot
{
CarId = car.id,
Code = GetCarCode(car),
CarModel = GetCarModel(car),
PrevNodeCode = prevNodeCode,
CurrentNodeCode = GetCurrentNodeCode(car),
NextNodeCode = string.IsNullOrWhiteSpace(nextNodeCode) ? GetNextNodeCode(car) : nextNodeCode,
State = GetFassState(car),
Angle = (int)Math.Round(car.th),
Charge = GetCharge(car)
};
}
/// <summary>
/// 获取 FASS 车辆编号。MagCar 优先使用 VehicleCode,否则 fields["Code"],最后回退 name。
/// </summary>
public static string GetCarCode(Car car)
{
if (car is MagCar magCar && magCar.VehicleCode > 0)
{
return magCar.VehicleCode.ToString(CultureInfo.InvariantCulture);
}
if (car.fields != null && car.fields.TryGetValue("Code", out var code) && !string.IsNullOrWhiteSpace(code))
{
return code;
}
return car.name;
}
/// <summary>获取车型。下料站 InplaceStaion 根据 CarModel 在 ME11 / 放行车型间切换。</summary>
public static string GetCarModel(Car car)
{
if (car.fields != null && car.fields.TryGetValue("CarModel", out var model) && !string.IsNullOrWhiteSpace(model))
{
return model;
}
return car.GetType().Name;
}
/// <summary>
/// 获取当前地标号。MagCar 上报后写入 status.enums["CurrentNode"]
/// 其他车型可回退到 siteID。
/// </summary>
public static string GetCurrentNodeCode(Car car)
{
if (car.status?.enums != null && car.status.enums.TryGetValue("CurrentNode", out var node) && !string.IsNullOrWhiteSpace(node))
{
return node;
}
return car.siteID > 0 ? car.siteID.ToString(CultureInfo.InvariantCulture) : string.Empty;
}
/// <summary>
/// 将 StandardScene 车辆状态映射为 FASS 英文字符串。
/// <para>
/// 可手动覆盖:tags["FassState"] 或 enums["FassState"]。
/// WaitPass 需在停止中且 tags 含 WaitPass(FASS 中“等待放行”态)。
/// </para>
/// </summary>
public static string GetFassState(Car car)
{
if (car.tags != null && car.tags.TryGetValue("FassState", out var tagState) && !string.IsNullOrWhiteSpace(tagState))
{
return tagState;
}
if (car.status?.enums != null && car.status.enums.TryGetValue("FassState", out var enumState) && !string.IsNullOrWhiteSpace(enumState))
{
return enumState;
}
if (car.status?.enums != null && car.status.enums.TryGetValue("State", out var state))
{
switch (state)
{
case "运行中":
return "Running";
case "停止中":
return car.tags != null && car.tags.ContainsKey("WaitPass") ? "WaitPass" : "Stopping";
case "未准备":
return "NotReady";
case "充电中":
return "Charging";
default:
return state;
}
}
return string.Empty;
}
/// <summary>从 status.enums["Soc"] 解析电量百分比。</summary>
private static double GetCharge(Car car)
{
if (car.status?.enums != null &&
car.status.enums.TryGetValue("Soc", out var soc) &&
double.TryParse(soc, NumberStyles.Float, CultureInfo.InvariantCulture, out var value))
{
return value;
}
return 0;
}
}
}
@@ -0,0 +1,132 @@
using System.Collections.Generic;
namespace StandardScene.Scheduler.FassEvent
{
/// <summary>
/// FASS 车辆事件配置项,对应 <c>Configs/EventCar/*.json</c> 数组中的单个元素。
/// 配置格式与 FASS.Scheduler 的 EventCar 模型保持一致,便于直接复用现场 JSON。
/// </summary>
public class FassEventCarConfig
{
/// <summary>是否启用本条事件规则。</summary>
public bool TriggerEnable { get; set; } = true;
/// <summary>
/// 事件来源。StandardScene 进程仅处理值为 <c>Car</c> 的项(车辆定时轮询触发)。
/// </summary>
public string TriggerSource { get; set; } = "Car";
/// <summary>
/// 触发时机。当前实现仅支持 <c>TimerTick</c>(与 FASS <c>EventCarService.CarTimerTick</c> 等价)。
/// </summary>
public string TriggerEvent { get; set; } = "TimerTick";
/// <summary>触发条件:车辆编号(逗号分隔,空表示不限制)。</summary>
public string TriggerCarCode { get; set; }
/// <summary>触发条件:上一地标号(逗号分隔)。</summary>
public string TriggerCarPrevNodeCode { get; set; }
/// <summary>触发条件:当前地标号(逗号分隔)。</summary>
public string TriggerCarCurrentNodeCode { get; set; }
/// <summary>触发条件:下一地标号(逗号分隔)。</summary>
public string TriggerCarNextNodeCode { get; set; }
/// <summary>
/// 触发条件:车辆 FASS 状态(逗号分隔)。
/// 常见值:Running、Stopping、WaitPass、Charging、Stopping,Charging 等。
/// </summary>
public string TriggerCarState { get; set; }
/// <summary>触发条件:车头角度(逗号分隔的整数字符串)。</summary>
public string TriggerCarAngle { get; set; }
/// <summary>
/// 条件事件列表。全部满足后才执行任务;
/// 对应 FASS 中 <c>ConditionHandler</c> 的逐项 AND 逻辑。
/// </summary>
public List<FassConditionEventConfig> ConditionEvents { get; set; } = new List<FassConditionEventConfig>();
/// <summary>条件满足后是否执行任务(false 时仅做条件判断,不下发 Start/Stop)。</summary>
public bool TaskEnable { get; set; } = true;
/// <summary>
/// 任务类型。MagCar 当前支持 <c>Start</c>(放行)与 <c>Stop</c>(急停)。
/// </summary>
public string TaskType { get; set; }
}
/// <summary>
/// 单条条件事件配置,描述一个条件判断(交管、PLC 信号、电量等)。
/// </summary>
public class FassConditionEventConfig
{
/// <summary>是否启用本条件;false 时跳过(视为通过)。</summary>
public bool ConditionEnable { get; set; } = true;
/// <summary>
/// 条件类型:<c>Method</c>(内置方法)、<c>Assembly</c>、<c>Plugin</c>。
/// StandardScene 当前仅实现 Method;后两者会记录日志并返回 false。
/// </summary>
public string ConditionType { get; set; } = "Method";
/// <summary>Assembly 条件:程序集文件路径(未实现)。</summary>
public string ConditionAssemblyFile { get; set; }
/// <summary>Assembly/Plugin 条件:类型名(未实现)。</summary>
public string ConditionTypeName { get; set; }
/// <summary>Assembly/Plugin 条件:构造参数(未实现)。</summary>
public string ConditionTypeArgs { get; set; }
/// <summary>
/// Method 条件:方法名,如 NodeAllUnlockedNotCar、requestStaion、EmegerStop 等。
/// 名称与 FASS <c>EventCarService.InitEventMethod</c> 注册表一致(含历史拼写)。
/// </summary>
public string ConditionMethodName { get; set; }
/// <summary>
/// Method 条件:方法参数,一般为逗号分隔的节点号或 PLC 地址。
/// 例如交管:<c>3,53,21</c>PLC<c>DB5524.0.0,DB5524.0.1</c>。
/// </summary>
public string ConditionMethodArgs { get; set; }
}
/// <summary>
/// 车辆事件处理用的只读快照,从 StandardScene <see cref="SimpleLite.RCS.Car"/> 提取 FASS 语义字段。
/// 每次 TimerTick 为每辆车构建一份,供触发器与条件方法使用。
/// </summary>
public class FassEventCarSnapshot
{
/// <summary>场景内车辆 IDSimpleLib)。</summary>
public int CarId { get; set; }
/// <summary>FASS 车辆编号(MagCar.VehicleCode 或 fields["Code"])。</summary>
public string Code { get; set; }
/// <summary>车型标识,用于 ME11 下料站逻辑(fields["CarModel"])。</summary>
public string CarModel { get; set; }
/// <summary>上一周期记录的地标号,用于 TriggerCarPrevNodeCode 匹配。</summary>
public string PrevNodeCode { get; set; }
/// <summary>当前地标号(status.enums["CurrentNode"] 或 siteID)。</summary>
public string CurrentNodeCode { get; set; }
/// <summary>下一地标号(pendingLocks / tags / enums 推断)。</summary>
public string NextNodeCode { get; set; }
/// <summary>
/// FASS 状态字符串:Running、Stopping、WaitPass、Charging 等。
/// 由 <see cref="FassEventCarSnapshotFactory.GetFassState"/> 从中文状态映射。
/// </summary>
public string State { get; set; }
/// <summary>车头角度(度),对应 FASS Car.Angle。</summary>
public int Angle { get; set; }
/// <summary>电量百分比(Soc)。</summary>
public double Charge { get; set; }
}
}
@@ -0,0 +1,196 @@
using SimpleLite.RCS;
using SimpleCore;
using SimpleCore.PropType;
using System;
using System.Linq;
using SimpleLite.RCS.CarTypes;
namespace StandardScene.Scheduler.FassEvent
{
/// <summary>
/// 节点(地标)交管判断辅助类,替代 FASS <c>AlgorithmService</c> 中与 EventCar 相关的方法。
/// <para>
/// “锁定”判定依据 StandardScene 交通锁:holdingLocks、pendingLocks、aquiringLock
/// 以及站点 tags 中的 unavailable。
/// </para>
/// </summary>
internal static class FassEventNodeHelper
{
/// <summary>指定节点列表是否全部未被任何车辆占用。</summary>
public static bool NodeAllUnlocked(string[] nodeCodes)
{
return nodeCodes == null || nodeCodes.All(node => !IsNodeLocked(node, -1));
}
/// <summary>指定节点列表是否全部未被占用(排除当前车辆自身锁)。</summary>
public static bool NodeAllUnlockedNotCar(string carCode, string[] nodeCodes)
{
var excludeCarId = ResolveCarId(carCode);
return nodeCodes == null || nodeCodes.All(node => !IsNodeLocked(node, excludeCarId));
}
/// <summary>指定节点列表中是否存在任意一个被占用的节点。</summary>
public static bool NodeAnyLocked(string[] nodeCodes)
{
return nodeCodes != null && nodeCodes.Any(node => IsNodeLocked(node, -1));
}
/// <summary>指定节点列表中是否存在被其他车辆占用的节点(排除自身)。</summary>
public static bool NodeAnyLockedNotCar(string carCode, string[] nodeCodes)
{
var excludeCarId = ResolveCarId(carCode);
return nodeCodes != null && nodeCodes.Any(node => IsNodeLocked(node, excludeCarId));
}
/// <summary>
/// 节点全部解锁(排除自身)且指定节点范围内不存在处于运行态的其他车辆。
/// 对应 FASS <c>NodeAllUnlockedNotCarNotEndCarState</c>。
/// </summary>
public static bool NodeAllUnlockedNotCarNotEndCarState(string carCode, string runningState, string[] nodeCodes)
{
if (!NodeAllUnlockedNotCar(carCode, nodeCodes))
{
return false;
}
return !ExistsRunningCarOnNodes(carCode, runningState, nodeCodes);
}
/// <summary>
/// 判断除指定车辆外,是否有车辆在给定节点列表上处于“运行中”状态。
/// 逻辑对齐 FASS <c>IsExistRunningStateCar</c>:支持首参 Contains 匹配及 Skip(1) 尾节点匹配。
/// </summary>
public static bool ExistsRunningCarOnNodes(string excludeCarCode, string runningState, string[] nodeCodes)
{
if (nodeCodes == null || nodeCodes.Length == 0)
{
return false;
}
var excludeCarId = ResolveCarId(excludeCarCode);
foreach (var car in SimpleLib.GetAllCars().OfType<Car>())
{
if (car.id == excludeCarId)
{
continue;
}
var currentNode = FassEventCarSnapshotFactory.GetCurrentNodeCode(car);
if (string.IsNullOrWhiteSpace(currentNode))
{
continue;
}
var state = FassEventCarSnapshotFactory.GetFassState(car);
if (!IsRunningState(state, runningState))
{
continue;
}
// 与 FASS 一致:当前地标包含参数首项,或等于后续任一项。
if (nodeCodes.Any(node => string.Equals(node, currentNode, StringComparison.OrdinalIgnoreCase)
|| currentNode.Contains(node)))
{
return true;
}
if (nodeCodes.Length > 1)
{
var tailNodes = nodeCodes.Skip(1).ToArray();
if (tailNodes.Any(node => string.Equals(node, currentNode, StringComparison.OrdinalIgnoreCase)))
{
return true;
}
}
}
return false;
}
/// <summary>
/// 判断单个地标是否被锁定。
/// 节点号可解析为站点 ID,或通过 fields["TagValue"] 匹配地标标签。
/// </summary>
private static bool IsNodeLocked(string nodeCode, int excludeCarId)
{
if (!TryResolveSiteId(nodeCode, out var siteId))
{
return false;
}
var site = SimpleLib.GetSite(siteId);
if (site?.tags?.Contains("unavailable") == true)
{
return true;
}
return SimpleLib.GetAllCars().Any(car =>
car.id != excludeCarId &&
(car.status.holdingLocks?.Contains(siteId) == true ||
car.status.pendingLocks?.Contains(siteId) == true ||
car.status.aquiringLock == siteId));
}
/// <summary>根据 FASS 车辆编号解析场景内 car.id。</summary>
private static int ResolveCarId(string carCode)
{
if (string.IsNullOrWhiteSpace(carCode))
{
return -1;
}
var car = SimpleLib.GetAllCars()
.OfType<Car>()
.FirstOrDefault(item => string.Equals(FassEventCarSnapshotFactory.GetCarCode(item), carCode, StringComparison.OrdinalIgnoreCase));
return car?.id ?? -1;
}
/// <summary>
/// 将地标编码解析为站点 ID:纯数字直接查站;否则按 TagValue 字段匹配。
/// </summary>
private static bool TryResolveSiteId(string nodeCode, out int siteId)
{
siteId = 0;
if (string.IsNullOrWhiteSpace(nodeCode))
{
return false;
}
if (int.TryParse(nodeCode, out siteId))
{
return SimpleLib.GetSite(siteId) != null;
}
var site = SimpleLib.GetAllSites().FirstOrDefault(item =>
item.fields.TryGetValue("TagValue", out var tag) &&
string.Equals(tag, nodeCode, StringComparison.OrdinalIgnoreCase));
if (site == null)
{
return false;
}
siteId = site.id;
return true;
}
/// <summary>
/// 判断状态是否视为“运行中”。兼容 FASS 历史拼写 Runing 与 Running。
/// </summary>
private static bool IsRunningState(string state, string runningState)
{
if (string.IsNullOrWhiteSpace(state))
{
return false;
}
if (string.Equals(state, runningState, StringComparison.OrdinalIgnoreCase))
{
return true;
}
return string.Equals(state, "Running", StringComparison.OrdinalIgnoreCase) &&
(string.Equals(runningState, "Runing", StringComparison.OrdinalIgnoreCase) ||
string.Equals(runningState, "Running", StringComparison.OrdinalIgnoreCase));
}
}
}
@@ -21,6 +21,7 @@
<!-- 插件清单:随 dll 输出,供 SimpleLite plugins 选择性加载(约定 <dll>.scene.json -->
<ItemGroup>
<None Update="StandardScene.Magnetic.scene.json" CopyToOutputDirectory="PreserveNewest" />
<None Update="Configs\EventCar\FG2512073.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<!-- 基座:车型基类字段袋 / 通用 coder / 任务调度等留 Core;本 dll 为 scene.mag 平台(磁导航车型 + 磁循迹 coder) -->
@@ -29,24 +30,25 @@
</ItemGroup>
<ItemGroup>
<Reference Include="SimpleLite">
<HintPath>E:\Work\Core\Simple-FR\Simple\SimpleLite\bin\Debug\SimpleLite.dll</HintPath>
</Reference>
<Reference Include="SimpleCore">
<HintPath>E:\Work\Core\Simple-FR\Simple\SimpleCore\bin\Debug\netstandard2.0\SimpleCore.dll</HintPath>
</Reference>
<Reference Include="CommonUsage">
<HintPath>D:\MDCS\Dependencies\Commons\CommonUsage.dll</HintPath>
</Reference>
<Reference Include="Topaz">
<HintPath>E:\Work\Core\Simple-FR\Simple\tools\Topaz.dll</HintPath>
<HintPath>$(StandRefDir)CommonUsage.dll</HintPath>
</Reference>
<Reference Include="LessokajiWeaverUtilities">
<HintPath>E:\Work\Core\Simple-FR\Simple\SimpleLite\bin\Debug\LessokajiWeaverUtilities.dll</HintPath>
<HintPath>$(StandRefDir)LessokajiWeaverUtilities.dll</HintPath>
</Reference>
<Reference Include="SimpleCore">
<HintPath>$(StandRefDir)SimpleCore.dll</HintPath>
</Reference>
<Reference Include="SimpleLite">
<HintPath>$(StandSimpleLiteDir)SimpleLite.dll</HintPath>
</Reference>
<Reference Include="Topaz">
<HintPath>$(StandRefDir)Topaz.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<PackageReference Include="IoTClient" Version="1.0.40" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
</ItemGroup>
@@ -6,7 +6,7 @@
"coreVersion": ">=1.0.0",
"requiresCore": "StandardScene.dll",
"provides": {
"carTypes": [ "MagCar" ],
"missionTypes": []
"carTypes": [ "MagCar", "MagFass2Car" ],
"missionTypes": [ "EventCarMission", "MagFass2LoopMission" ]
}
}
@@ -0,0 +1,192 @@
using StandardScene.Magnetic.Protocol;
using System;
using System.Collections.Generic;
using System.Text;
namespace StandardScene.Magnetic.Tasking
{
/// <summary>
/// 比对期望节点与上报节点,判定到站与动作是否完成(对齐 backend <c>CarResponseService</c>)。
/// </summary>
public static class Fass2ActionResolver
{
private static readonly (string Name, Func<Fass2NodeMessage, byte> Get, Action<Fass2NodeMessage, byte> Set)[] ActionFields =
{
("StartStop", n => n.StartStop, (n, v) => n.StartStop = v),
("Direction", n => n.Direction, (n, v) => n.Direction = v),
("Orientation", n => n.Orientation, (n, v) => n.Orientation = v),
("Byroad", n => n.Byroad, (n, v) => n.Byroad = v),
("Obstacle", n => n.Obstacle, (n, v) => n.Obstacle = v),
("Audio", n => n.Audio, (n, v) => n.Audio = v),
("Light", n => n.Light, (n, v) => n.Light = v),
("Charge", n => n.Charge, (n, v) => n.Charge = v),
("Rest", n => n.Rest, (n, v) => n.Rest = v),
("Lift", n => n.Lift, (n, v) => n.Lift = v),
("Clamp", n => n.Clamp, (n, v) => n.Clamp = v),
("Tray", n => n.Tray, (n, v) => n.Tray = v),
("Roll", n => n.Roll, (n, v) => n.Roll = v),
("Shutdown", n => n.Shutdown, (n, v) => n.Shutdown = v)
};
public static bool IsAtNode(Fass2StateReport report, ushort targetNode)
{
return report?.Node != null && report.Node.Node == targetNode;
}
public static bool IsVehicleMoving(byte vehicleState)
{
return vehicleState == 1;
}
public static bool RequiresActionWait(Fass2NodeMessage expected)
{
if (expected == null)
{
return false;
}
if (expected.StartStop is Fass2TaskBuilder.StartStopStop or Fass2TaskBuilder.StartStopPrecision)
{
return true;
}
foreach (var field in ActionFields)
{
if (field.Get(expected) != 0)
{
return true;
}
}
return expected.Speed != 0;
}
public static bool IsStationActionComplete(Fass2NodeMessage expected, Fass2NodeMessage actual, byte vehicleState)
{
if (expected == null || actual == null)
{
return false;
}
if (actual.Node != expected.Node)
{
return false;
}
if (IsVehicleMoving(vehicleState))
{
return false;
}
if (!RequiresActionWait(expected))
{
return true;
}
// 过站(StartStop=1) 只要求到点且非运行态,不要求 Orientation 等轨迹字段到位。
if (expected.StartStop == Fass2TaskBuilder.StartStopPass)
{
return true;
}
return ListPendingFields(expected, actual).Count == 0;
}
public static IReadOnlyList<string> ListPendingFields(Fass2NodeMessage expected, Fass2NodeMessage actual)
{
var pending = new List<string>();
if (expected == null || actual == null)
{
return pending;
}
if (expected.StartStop != 0 && actual.StartStop != expected.StartStop)
{
pending.Add("StartStop");
}
if (expected.Speed != 0 && actual.Speed != expected.Speed)
{
pending.Add("Speed");
}
foreach (var field in ActionFields)
{
if (field.Name == "StartStop")
{
continue;
}
var expectedValue = field.Get(expected);
if (expectedValue != 0 && field.Get(actual) != expectedValue)
{
pending.Add(field.Name);
}
}
return pending;
}
public static Fass2NodeMessage BuildActionPatch(Fass2NodeMessage expected, Fass2NodeMessage actual)
{
if (expected == null)
{
return null;
}
var patch = new Fass2NodeMessage { Node = expected.Node };
var hasPatch = false;
if (expected.StartStop != 0 && (actual == null || actual.StartStop != expected.StartStop))
{
patch.StartStop = expected.StartStop;
hasPatch = true;
}
if (expected.Speed != 0 && (actual == null || actual.Speed != expected.Speed))
{
patch.Speed = expected.Speed;
hasPatch = true;
}
foreach (var field in ActionFields)
{
if (field.Name == "StartStop")
{
continue;
}
var expectedValue = field.Get(expected);
if (expectedValue != 0 && (actual == null || field.Get(actual) != expectedValue))
{
field.Set(patch, expectedValue);
hasPatch = true;
}
}
return hasPatch ? patch : null;
}
public static string DescribePending(Fass2NodeMessage expected, Fass2NodeMessage actual)
{
var pending = ListPendingFields(expected, actual);
if (pending.Count == 0)
{
return string.Empty;
}
var builder = new StringBuilder();
for (var i = 0; i < pending.Count; i++)
{
if (i > 0)
{
builder.Append(',');
}
builder.Append(pending[i]);
}
return builder.ToString();
}
}
}
@@ -0,0 +1,90 @@
using SimpleCore;
using System.Collections.Generic;
namespace StandardScene.Magnetic.Tasking
{
internal static class Fass2PathFinder
{
public static List<int> GetSitesBetween(int startSiteId, int endSiteId)
{
if (startSiteId == endSiteId)
{
return new List<int> { startSiteId };
}
var adjacency = BuildSiteAdjacency();
var visited = new HashSet<int> { startSiteId };
var queue = new Queue<List<int>>();
queue.Enqueue(new List<int> { startSiteId });
while (queue.Count > 0)
{
var path = queue.Dequeue();
var current = path[path.Count - 1];
if (current == endSiteId)
{
return path;
}
if (!adjacency.TryGetValue(current, out var neighbors))
{
continue;
}
foreach (var neighbor in neighbors)
{
if (visited.Contains(neighbor))
{
continue;
}
visited.Add(neighbor);
var nextPath = new List<int>(path) { neighbor };
queue.Enqueue(nextPath);
}
}
return new List<int>();
}
private static Dictionary<int, HashSet<int>> BuildSiteAdjacency()
{
var adjacency = new Dictionary<int, HashSet<int>>();
foreach (var track in SimpleLib.GetAllTracks())
{
var siteA = track.siteA;
var siteB = track.siteB;
switch (track.direction)
{
case 0:
AddAdjacency(adjacency, siteA, siteB);
AddAdjacency(adjacency, siteB, siteA);
break;
case 1:
AddAdjacency(adjacency, siteA, siteB);
break;
case 2:
AddAdjacency(adjacency, siteB, siteA);
break;
default:
AddAdjacency(adjacency, siteA, siteB);
AddAdjacency(adjacency, siteB, siteA);
break;
}
}
return adjacency;
}
private static void AddAdjacency(Dictionary<int, HashSet<int>> adjacency, int from, int to)
{
if (!adjacency.TryGetValue(from, out var set))
{
set = new HashSet<int>();
adjacency[from] = set;
}
set.Add(to);
}
}
}
@@ -0,0 +1,103 @@
using SimpleCore.PropType;
using System;
using System.Collections.Generic;
using System.Globalization;
namespace StandardScene.Magnetic.Tasking
{
internal static class Fass2SiteFieldReader
{
public static Fass2SiteActionData Read(Site site)
{
var data = new Fass2SiteActionData();
if (site?.fields == null)
{
return data;
}
data.StartStop = TryReadByte(site.fields, Fass2SiteFields.StartStop);
data.Byroad = TryReadByte(site.fields, Fass2SiteFields.Byroad);
data.Direction = TryReadByte(site.fields, Fass2SiteFields.Direction);
data.Orientation = TryReadByte(site.fields, Fass2SiteFields.Orientation);
data.Lift = TryReadByte(site.fields, Fass2SiteFields.Lift);
data.Roll = TryReadByte(site.fields, Fass2SiteFields.Roll);
data.Charge = TryReadByte(site.fields, Fass2SiteFields.Charge);
data.Obstacle = TryReadByte(site.fields, Fass2SiteFields.Obstacle);
data.Audio = TryReadByte(site.fields, Fass2SiteFields.Audio);
data.Light = TryReadByte(site.fields, Fass2SiteFields.Light);
data.Rest = TryReadByte(site.fields, Fass2SiteFields.Rest);
data.Clamp = TryReadByte(site.fields, Fass2SiteFields.Clamp);
data.Tray = TryReadByte(site.fields, Fass2SiteFields.Tray);
data.Shutdown = TryReadByte(site.fields, Fass2SiteFields.Shutdown);
data.PrecisionStop = TryReadBool(site.fields, Fass2SiteFields.PrecisionStop);
data.WaitMode = TryReadString(site.fields, Fass2SiteFields.WaitMode);
return data;
}
public static string BuildFieldsSignature(IReadOnlyList<int> siteIds, int fromIndex, Func<int, Site> getSite)
{
if (siteIds == null || siteIds.Count == 0)
{
return string.Empty;
}
var parts = new List<string>(siteIds.Count - fromIndex);
for (var i = Math.Max(0, fromIndex); i < siteIds.Count; i++)
{
var site = getSite(siteIds[i]);
if (site?.fields == null)
{
parts.Add($"{siteIds[i]}:");
continue;
}
var keys = new List<string>();
foreach (var pair in site.fields)
{
if (pair.Key.StartsWith("Fass2_", StringComparison.OrdinalIgnoreCase) ||
pair.Key.Equals(Fass2SiteFields.TagValue, StringComparison.OrdinalIgnoreCase))
{
keys.Add($"{pair.Key}={pair.Value}");
}
}
keys.Sort(StringComparer.OrdinalIgnoreCase);
parts.Add($"{siteIds[i]}:{string.Join("|", keys)}");
}
return string.Join(";", parts);
}
private static byte? TryReadByte(Dictionary<string, string> fields, string key)
{
if (!fields.TryGetValue(key, out var text) || string.IsNullOrWhiteSpace(text))
{
return null;
}
if (byte.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value))
{
return value;
}
return null;
}
private static bool TryReadBool(Dictionary<string, string> fields, string key)
{
if (!fields.TryGetValue(key, out var text) || string.IsNullOrWhiteSpace(text))
{
return false;
}
return bool.TryParse(text, out var value) && value
|| text == "1"
|| string.Equals(text, "true", StringComparison.OrdinalIgnoreCase);
}
private static string TryReadString(Dictionary<string, string> fields, string key)
{
return fields.TryGetValue(key, out var text) ? text : null;
}
}
}
@@ -0,0 +1,50 @@
namespace StandardScene.Magnetic.Tasking
{
/// <summary>
/// FASS 2.0 站点 <c>fields</c> 键名常量,用于地图编辑器配置站点动作。
/// </summary>
public static class Fass2SiteFields
{
public const string TagValue = "TagValue";
public const string StartStop = "Fass2_StartStop";
public const string Byroad = "Fass2_Byroad";
public const string Direction = "Fass2_Direction";
public const string Orientation = "Fass2_Orientation";
public const string Lift = "Fass2_Lift";
public const string Roll = "Fass2_Roll";
public const string Charge = "Fass2_Charge";
public const string Obstacle = "Fass2_Obstacle";
public const string Audio = "Fass2_Audio";
public const string Light = "Fass2_Light";
public const string Rest = "Fass2_Rest";
public const string Clamp = "Fass2_Clamp";
public const string Tray = "Fass2_Tray";
public const string Shutdown = "Fass2_Shutdown";
public const string PrecisionStop = "Fass2_PrecisionStop";
public const string WaitMode = "Fass2_WaitMode";
}
/// <summary>
/// 从站点 fields 解析出的动作参数(映射到 25B 节点块)。
/// </summary>
public sealed class Fass2SiteActionData
{
public byte? StartStop { get; set; }
public byte? Byroad { get; set; }
public byte? Direction { get; set; }
public byte? Orientation { get; set; }
public byte? Lift { get; set; }
public byte? Roll { get; set; }
public byte? Charge { get; set; }
public byte? Obstacle { get; set; }
public byte? Audio { get; set; }
public byte? Light { get; set; }
public byte? Rest { get; set; }
public byte? Clamp { get; set; }
public byte? Tray { get; set; }
public byte? Shutdown { get; set; }
public bool PrecisionStop { get; set; }
public string WaitMode { get; set; }
}
}
@@ -0,0 +1,226 @@
using SimpleCore;
using SimpleCore.PropType;
using StandardScene.Magnetic.Protocol;
using System;
using System.Collections.Generic;
using System.Linq;
namespace StandardScene.Magnetic.Tasking
{
/// <summary>
/// 将站点路径与站点/边 fields 合并为 FASS 2.0 <c>0xB1</c> 节点序列。
/// 对齐 backend <c>CarRequestService.GetSendNodes</c> 的组包规则。
/// </summary>
public static class Fass2TaskBuilder
{
public const byte StartStopPass = 1;
public const byte StartStopStop = 2;
public const byte StartStopPrecision = 22;
public static Fass2TaskPlan BuildPath(int startSiteId, int goalSiteId, Fass2TaskBuildOptions options,
Func<int, ushort> resolveNodeId)
{
if (resolveNodeId == null)
{
throw new ArgumentNullException(nameof(resolveNodeId));
}
options ??= new Fass2TaskBuildOptions();
var siteIds = Fass2PathFinder.GetSitesBetween(startSiteId, goalSiteId);
if (siteIds.Count == 0)
{
throw new InvalidOperationException($"no path from site {startSiteId} to {goalSiteId}");
}
var nodes = BuildNodesForSites(siteIds, options, resolveNodeId);
return new Fass2TaskPlan
{
StartSiteId = startSiteId,
GoalSiteId = goalSiteId,
SiteIds = siteIds,
Nodes = nodes,
Batches = SplitBatches(nodes, options.MaxNodesPerFrame),
FieldsSignature = Fass2SiteFieldReader.BuildFieldsSignature(siteIds, 0, SimpleLib.GetSite)
};
}
public static Fass2NodeMessage[] BuildSegment(int srcSiteId, int dstSiteId, Fass2TaskBuildOptions options,
Func<int, ushort> resolveNodeId)
{
return BuildPath(srcSiteId, dstSiteId, options, resolveNodeId).Nodes.ToArray();
}
public static IReadOnlyList<Fass2NodeMessage[]> SplitBatches(IReadOnlyList<Fass2NodeMessage> nodes, int maxPerFrame = 10)
{
if (nodes == null || nodes.Count == 0)
{
return Array.Empty<Fass2NodeMessage[]>();
}
if (maxPerFrame <= 0 || maxPerFrame > 10)
{
maxPerFrame = 10;
}
var batches = new List<Fass2NodeMessage[]>();
for (var offset = 0; offset < nodes.Count; offset += maxPerFrame)
{
var count = Math.Min(maxPerFrame, nodes.Count - offset);
var batch = new Fass2NodeMessage[count];
for (var i = 0; i < count; i++)
{
batch[i] = nodes[offset + i];
}
batches.Add(batch);
}
return batches;
}
private static List<Fass2NodeMessage> BuildNodesForSites(IReadOnlyList<int> siteIds, Fass2TaskBuildOptions options,
Func<int, ushort> resolveNodeId)
{
var nodes = new List<Fass2NodeMessage>(siteIds.Count);
for (var i = 0; i < siteIds.Count; i++)
{
var siteId = siteIds[i];
var site = SimpleLib.GetSite(siteId);
var siteAction = Fass2SiteFieldReader.Read(site);
var isLast = i == siteIds.Count - 1;
var prevSiteId = i > 0 ? siteIds[i - 1] : siteId;
Fass2TrackMotionData trackMotion = null;
Site prevSite = null;
if (i > 0)
{
prevSite = SimpleLib.GetSite(prevSiteId);
var track = FindTrack(prevSiteId, siteId);
trackMotion = Fass2TrackFieldReader.Read(track);
}
var node = new Fass2NodeMessage
{
Node = resolveNodeId(siteId),
StartStop = ResolveStartStop(siteAction, isLast),
Distance = i > 0 ? ComputeEdgeDistance(prevSite, site) : (ushort)0,
Speed = SpeedToProtocol(trackMotion?.Speed ?? options.DefaultSpeed, options.CarSpeed),
Orientation = ResolveOrientation(prevSite, site, trackMotion),
Byroad = siteAction.Byroad ?? trackMotion?.Byroad ?? 0,
Direction = siteAction.Direction ?? trackMotion?.Direction ?? 0,
Lift = siteAction.Lift ?? 0,
Roll = siteAction.Roll ?? 0,
Charge = siteAction.Charge ?? 0,
Obstacle = siteAction.Obstacle ?? 0,
Audio = siteAction.Audio ?? 0,
Light = siteAction.Light ?? 0,
Rest = siteAction.Rest ?? 0,
Clamp = siteAction.Clamp ?? 0,
Tray = siteAction.Tray ?? 0,
Shutdown = siteAction.Shutdown ?? 0
};
if (siteAction.Orientation.HasValue)
{
node.Orientation = siteAction.Orientation.Value;
}
nodes.Add(node);
}
return nodes;
}
private static byte ResolveStartStop(Fass2SiteActionData siteAction, bool isLast)
{
if (siteAction.StartStop.HasValue)
{
return siteAction.StartStop.Value;
}
if (isLast)
{
return siteAction.PrecisionStop ? StartStopPrecision : StartStopStop;
}
return StartStopPass;
}
private static byte ResolveOrientation(Site src, Site dst, Fass2TrackMotionData trackMotion)
{
if (trackMotion?.Orientation != null)
{
return trackMotion.Orientation.Value;
}
if (src == null || dst == null)
{
return 0;
}
var angle = trackMotion != null && trackMotion.EnableCarAbsoluteDirection
? trackMotion.CarAbsoluteDirection
: Math.Atan2(dst.y - src.y, dst.x - src.x) / Math.PI * 180.0;
if (trackMotion?.Reverse == true)
{
angle += 180;
}
angle += trackMotion?.CarDirectionBias ?? 0;
angle %= 360;
if (angle < 0)
{
angle += 360;
}
return (byte)((int)Math.Round(angle / 360.0 * 256) % 256);
}
private static ushort ComputeEdgeDistance(Site src, Site dst)
{
var dx = dst.x - src.x;
var dy = dst.y - src.y;
var length = Math.Sqrt(dx * dx + dy * dy);
return (ushort)Math.Min(ushort.MaxValue, Math.Max(1, Math.Round(length)));
}
private static ushort SpeedToProtocol(double trackSpeed, double carSpeed)
{
var value = trackSpeed <= 0 ? carSpeed : trackSpeed;
var protocolSpeed = value <= 1
? (int)Math.Round(value * 600)
: (int)Math.Round(value * 10);
return (ushort)Math.Max(1, Math.Min(10000, protocolSpeed));
}
private static Track FindTrack(int fromSiteId, int toSiteId)
{
foreach (var track in SimpleLib.GetAllTracks())
{
if (track.direction == 0)
{
if ((track.siteA == fromSiteId && track.siteB == toSiteId) ||
(track.siteB == fromSiteId && track.siteA == toSiteId))
{
return track;
}
continue;
}
if (track.direction == 1 && track.siteA == fromSiteId && track.siteB == toSiteId)
{
return track;
}
if (track.direction == 2 && track.siteB == fromSiteId && track.siteA == toSiteId)
{
return track;
}
}
return null;
}
}
}
@@ -0,0 +1,149 @@
using SimpleCore.PropType;
using StandardScene.Magnetic.Protocol;
using System;
using System.Collections.Generic;
namespace StandardScene.Magnetic.Tasking
{
public enum Fass2TaskPhase
{
Idle,
Planning,
Dispatching,
Moving,
AtStation,
SegmentDone,
Complete,
Fault,
Cancelled
}
/// <summary>
/// 一次 FASS 2.0 任务执行上下文,供状态机推进与重启恢复使用。
/// </summary>
public sealed class Fass2TaskContext
{
public Fass2TaskPhase Phase { get; set; } = Fass2TaskPhase.Idle;
public int StartSiteId { get; set; }
public int GoalSiteId { get; set; }
public int CurrentIndex { get; set; }
public ulong TaskId { get; set; }
public Fass2TaskPlan Plan { get; set; }
public string FieldsSignature { get; set; } = string.Empty;
public double DefaultSpeed { get; set; } = -1;
public string FaultReason { get; set; }
public DateTime StartedAt { get; set; }
public DateTime LastDispatchAt { get; set; } = DateTime.MinValue;
public ulong LastActionId { get; set; }
public DateTime LastActionSentAt { get; set; } = DateTime.MinValue;
}
public static class Fass2TaskPersistence
{
public const string TagPhase = "Fass2Task_Phase";
public const string TagStartSite = "Fass2Task_StartSite";
public const string TagGoalSite = "Fass2Task_GoalSite";
public const string TagCurrentIndex = "Fass2Task_CurrentIndex";
public const string TagTaskId = "Fass2Task_TaskId";
public const string TagFieldsSignature = "Fass2Task_FieldsSig";
public const string TagDefaultSpeed = "Fass2Task_DefaultSpeed";
public static void Save(TagSet tags, Fass2TaskContext context)
{
if (tags == null || context == null)
{
return;
}
SetTag(tags, TagPhase, context.Phase.ToString());
SetTag(tags, TagStartSite, context.StartSiteId.ToString());
SetTag(tags, TagGoalSite, context.GoalSiteId.ToString());
SetTag(tags, TagCurrentIndex, context.CurrentIndex.ToString());
SetTag(tags, TagTaskId, context.TaskId.ToString());
SetTag(tags, TagFieldsSignature, context.FieldsSignature ?? string.Empty);
SetTag(tags, TagDefaultSpeed, context.DefaultSpeed.ToString(System.Globalization.CultureInfo.InvariantCulture));
}
public static bool TryLoad(TagSet tags, out Fass2TaskContext context)
{
context = null;
if (tags == null || !tags.TryGetValue(TagPhase, out var phaseText) ||
!Enum.TryParse(phaseText, out Fass2TaskPhase phase))
{
return false;
}
if (phase is Fass2TaskPhase.Idle or Fass2TaskPhase.Complete or Fass2TaskPhase.Cancelled)
{
return false;
}
if (!TryReadInt(tags, TagStartSite, out var startSite) ||
!TryReadInt(tags, TagGoalSite, out var goalSite) ||
!TryReadInt(tags, TagCurrentIndex, out var currentIndex))
{
return false;
}
tags.TryGetValue(TagFieldsSignature, out var fieldsSignature);
ulong.TryParse(tags.TryGetValue(TagTaskId, out var taskIdText) ? taskIdText : "0", out var taskId);
double.TryParse(
tags.TryGetValue(TagDefaultSpeed, out var speedText) ? speedText : "-1",
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture,
out var defaultSpeed);
context = new Fass2TaskContext
{
Phase = phase,
StartSiteId = startSite,
GoalSiteId = goalSite,
CurrentIndex = currentIndex,
TaskId = taskId,
FieldsSignature = fieldsSignature ?? string.Empty,
DefaultSpeed = defaultSpeed
};
return true;
}
public static void Clear(TagSet tags)
{
if (tags == null)
{
return;
}
RemoveTag(tags, TagPhase);
RemoveTag(tags, TagStartSite);
RemoveTag(tags, TagGoalSite);
RemoveTag(tags, TagCurrentIndex);
RemoveTag(tags, TagTaskId);
RemoveTag(tags, TagFieldsSignature);
RemoveTag(tags, TagDefaultSpeed);
}
private static void SetTag(TagSet tags, string key, string value)
{
if (tags.Contains(key))
{
tags.Remove(key);
}
tags.Add(key, value);
}
private static void RemoveTag(TagSet tags, string key)
{
if (tags.Contains(key))
{
tags.Remove(key);
}
}
private static bool TryReadInt(TagSet tags, string key, out int value)
{
value = 0;
return tags.TryGetValue(key, out var text) && int.TryParse(text, out value);
}
}
}
@@ -0,0 +1,26 @@
using StandardScene.Magnetic.Protocol;
using System.Collections.Generic;
namespace StandardScene.Magnetic.Tasking
{
public sealed class Fass2TaskBuildOptions
{
public bool UseTagValueAsNode { get; set; }
public double DefaultSpeed { get; set; } = 0.2;
public double CarSpeed { get; set; } = 1;
public int MaxNodesPerFrame { get; set; } = 10;
}
/// <summary>
/// 一次 FASS 2.0 路径任务计划:站点序列、节点报文、分批结果。
/// </summary>
public sealed class Fass2TaskPlan
{
public int StartSiteId { get; set; }
public int GoalSiteId { get; set; }
public IReadOnlyList<int> SiteIds { get; set; } = new List<int>();
public IReadOnlyList<Fass2NodeMessage> Nodes { get; set; } = new List<Fass2NodeMessage>();
public IReadOnlyList<Fass2NodeMessage[]> Batches { get; set; } = new List<Fass2NodeMessage[]>();
public string FieldsSignature { get; set; } = string.Empty;
}
}
@@ -0,0 +1,588 @@
using SimpleCore;
using SimpleCore.PropType;
using StandardScene.Magnetic.Protocol;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StandardScene.Magnetic.Tasking
{
public sealed class Fass2TaskCallbacks
{
public Func<int, int, double, Fass2TaskPlan> BuildPlan { get; set; }
public Action<Fass2NodeMessage[], ulong> SendNodes { get; set; }
public Action<Fass2NodeMessage, ulong> SendAction { get; set; }
public Action<byte, ushort> SendControl { get; set; }
public Func<int, ushort> ResolveNodeId { get; set; }
public Func<ushort, Site> ResolveSite { get; set; }
public Action<string> Log { get; set; }
public Action<Fass2TaskContext> Persist { get; set; }
public Action ClearPersisted { get; set; }
public Func<ulong> AllocateTaskId { get; set; }
public Func<ulong> AllocateActionId { get; set; }
}
public sealed class Fass2TaskTickResult
{
public Fass2TaskPhase Phase { get; set; }
public bool Dispatched { get; set; }
public bool Rebuilt { get; set; }
public bool ActionSent { get; set; }
public bool Advanced { get; set; }
public bool Completed { get; set; }
public bool Faulted { get; set; }
public string Message { get; set; }
}
/// <summary>
/// FASS 2.0 任务状态机:规划 → 滑动窗口下发 → UDP/TCP 状态驱动推进 → 动作闭环。
/// </summary>
public sealed class Fass2TaskStateMachine
{
private readonly Fass2TaskCallbacks _callbacks;
private readonly object _syncRoot = new object();
private TaskCompletionSource<int> _completionSource;
public Fass2TaskStateMachine(Fass2TaskCallbacks callbacks)
{
_callbacks = callbacks ?? throw new ArgumentNullException(nameof(callbacks));
Context = new Fass2TaskContext();
}
public Fass2TaskContext Context { get; }
public int LockCount { get; set; } = 4;
public int ResendIntervalMs { get; set; } = 500;
public int ActionRetryIntervalMs { get; set; } = 1000;
public bool StartBeforeMove { get; set; } = true;
public ushort HeadingAngle { get; set; }
public bool IsIdle
{
get
{
lock (_syncRoot)
{
return Context.Phase is Fass2TaskPhase.Idle or Fass2TaskPhase.Complete or Fass2TaskPhase.Cancelled;
}
}
}
public bool IsRunning
{
get
{
lock (_syncRoot)
{
return Context.Phase is Fass2TaskPhase.Planning or Fass2TaskPhase.Dispatching
or Fass2TaskPhase.Moving or Fass2TaskPhase.AtStation or Fass2TaskPhase.SegmentDone;
}
}
}
public event Action<Fass2TaskContext> Completed;
public event Action<Fass2TaskContext, string> Faulted;
public void Begin(int startSiteId, int goalSiteId, double defaultSpeed = -1)
{
lock (_syncRoot)
{
if (!IsIdle)
{
throw new InvalidOperationException($"task state machine busy, phase={Context.Phase}");
}
ResetCompletionSource();
Context.Phase = Fass2TaskPhase.Planning;
Context.StartSiteId = startSiteId;
Context.GoalSiteId = goalSiteId;
Context.CurrentIndex = 0;
Context.DefaultSpeed = defaultSpeed;
Context.StartedAt = DateTime.Now;
Context.FaultReason = null;
Context.Plan = null;
Context.FieldsSignature = string.Empty;
Context.LastDispatchAt = DateTime.MinValue;
Context.LastActionSentAt = DateTime.MinValue;
Context.LastActionId = 0;
Log($"task begin start={startSiteId}, goal={goalSiteId}, speed={defaultSpeed}");
Persist();
}
}
public bool TryRestore(Fass2TaskContext saved)
{
if (saved == null)
{
return false;
}
lock (_syncRoot)
{
if (IsRunning)
{
return false;
}
ResetCompletionSource();
Context.Phase = saved.Phase;
Context.StartSiteId = saved.StartSiteId;
Context.GoalSiteId = saved.GoalSiteId;
Context.CurrentIndex = saved.CurrentIndex;
Context.TaskId = saved.TaskId;
Context.DefaultSpeed = saved.DefaultSpeed;
Context.FieldsSignature = saved.FieldsSignature ?? string.Empty;
Context.StartedAt = DateTime.Now;
Context.LastDispatchAt = DateTime.MinValue;
Context.LastActionSentAt = DateTime.MinValue;
Context.Plan = null;
Context.FaultReason = null;
if (Context.Phase == Fass2TaskPhase.Planning)
{
Log($"task restored phase={Context.Phase}, goal={Context.GoalSiteId}, index={Context.CurrentIndex}");
Persist();
return true;
}
Context.Phase = Fass2TaskPhase.Planning;
Log($"task restored and normalized to Planning, goal={Context.GoalSiteId}, index={Context.CurrentIndex}");
Persist();
return true;
}
}
public Fass2TaskTickResult Tick(Fass2StateReport report)
{
lock (_syncRoot)
{
if (Context.Phase is Fass2TaskPhase.Idle or Fass2TaskPhase.Complete
or Fass2TaskPhase.Cancelled or Fass2TaskPhase.Fault)
{
return 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)
{
return Fault("vehicle emergency stop", result);
}
if (report?.State == 4)
{
return Fault("vehicle fault", result);
}
switch (Context.Phase)
{
case Fass2TaskPhase.Planning:
return PlanAndDispatch(report, result);
case Fass2TaskPhase.Dispatching:
Context.Phase = Fass2TaskPhase.Moving;
result.Phase = Context.Phase;
return result;
case Fass2TaskPhase.Moving:
return HandleMoving(report, result);
case Fass2TaskPhase.AtStation:
return HandleAtStation(report, result);
case Fass2TaskPhase.SegmentDone:
return HandleSegmentDone(report, result);
default:
return result;
}
}
}
public void Cancel(string reason)
{
lock (_syncRoot)
{
if (IsIdle)
{
return;
}
Context.Phase = Fass2TaskPhase.Cancelled;
Context.FaultReason = reason;
Log($"task cancelled: {reason}");
ClearPersisted();
_completionSource?.TrySetException(new OperationCanceledException(reason));
}
}
public async Task WaitAsync(int timeoutMs, Func<Fass2StateReport> poll, int pollIntervalMs,
CancellationToken cancellationToken = default)
{
if (poll == null)
{
throw new ArgumentNullException(nameof(poll));
}
TaskCompletionSource<int> waiter;
lock (_syncRoot)
{
waiter = _completionSource ?? ResetCompletionSource();
}
var timeoutTask = Task.Delay(timeoutMs, cancellationToken);
var pollTask = Task.Run(async () =>
{
while (!cancellationToken.IsCancellationRequested)
{
Fass2StateReport report;
lock (_syncRoot)
{
if (!IsRunning)
{
return;
}
}
report = poll();
Tick(report);
lock (_syncRoot)
{
if (!IsRunning)
{
return;
}
}
await Task.Delay(Math.Max(20, pollIntervalMs), cancellationToken);
}
}, cancellationToken);
var finished = await Task.WhenAny(waiter.Task, timeoutTask);
if (finished == timeoutTask)
{
Cancel("wait timeout");
throw new TimeoutException(
$"FASS2 task timeout after {timeoutMs}ms, phase={Context.Phase}, index={Context.CurrentIndex}, goal={Context.GoalSiteId}");
}
await waiter.Task;
}
/// <summary>
/// UDP 模式由 Hub 回调驱动 Tick,此处只等待完成信号,避免与 OnUdpStateReceived 争用锁导致死锁。
/// </summary>
public async Task WaitForCompletionAsync(int timeoutMs, CancellationToken cancellationToken = default)
{
TaskCompletionSource<int> waiter;
lock (_syncRoot)
{
if (Context.Phase is Fass2TaskPhase.Complete or Fass2TaskPhase.Cancelled or Fass2TaskPhase.Fault)
{
return;
}
waiter = _completionSource ?? ResetCompletionSource();
}
var timeoutTask = Task.Delay(timeoutMs, cancellationToken);
var finished = await Task.WhenAny(waiter.Task, timeoutTask);
if (finished == timeoutTask)
{
Cancel("wait timeout");
throw new TimeoutException(
$"FASS2 task timeout after {timeoutMs}ms, phase={Context.Phase}, index={Context.CurrentIndex}, goal={Context.GoalSiteId}");
}
await waiter.Task;
}
private Fass2TaskTickResult PlanAndDispatch(Fass2StateReport report, Fass2TaskTickResult result)
{
var startSiteId = ResolveCurrentSiteId(report);
Context.Plan = _callbacks.BuildPlan(startSiteId, Context.GoalSiteId, Context.DefaultSpeed);
if (Context.Plan == null || Context.Plan.Nodes.Count == 0)
{
return Fault($"empty task plan from {startSiteId} to {Context.GoalSiteId}", result);
}
if (Context.CurrentIndex >= Context.Plan.Nodes.Count)
{
Context.CurrentIndex = Math.Max(0, Context.Plan.Nodes.Count - 1);
}
Context.FieldsSignature = Context.Plan.FieldsSignature;
Context.TaskId = _callbacks.AllocateTaskId();
if (StartBeforeMove && Context.CurrentIndex == 0)
{
_callbacks.SendControl(Fass2Protocol.CmdStart, HeadingAngle);
}
DispatchCurrentWindow();
Context.Phase = Fass2TaskPhase.Moving;
result.Phase = Context.Phase;
result.Dispatched = true;
result.Message = $"planned nodes={Context.Plan.Nodes.Count}, task={Context.TaskId}";
Log(result.Message);
Persist();
return result;
}
private Fass2TaskTickResult HandleMoving(Fass2StateReport report, Fass2TaskTickResult result)
{
if (TryRebuildForFieldsChange(result))
{
return result;
}
if (ShouldResend())
{
DispatchCurrentWindow();
result.Dispatched = true;
}
var expected = GetExpectedNode();
if (!Fass2ActionResolver.IsAtNode(report, expected.Node))
{
result.Message = $"moving node={report.Node.Node}, expect={expected.Node}";
return result;
}
if (Fass2ActionResolver.IsVehicleMoving(report.State))
{
result.Message = $"at node={expected.Node}, still running";
return result;
}
Context.Phase = Fass2TaskPhase.AtStation;
result.Phase = Context.Phase;
result.Message = $"arrived site index={Context.CurrentIndex}, node={expected.Node}";
Log(result.Message);
Persist();
return result;
}
private Fass2TaskTickResult HandleAtStation(Fass2StateReport report, Fass2TaskTickResult result)
{
if (TryRebuildForFieldsChange(result))
{
return result;
}
if (ShouldResend())
{
DispatchCurrentWindow();
result.Dispatched = true;
}
var expected = GetExpectedNode();
if (!Fass2ActionResolver.IsAtNode(report, expected.Node))
{
Context.Phase = Fass2TaskPhase.Moving;
result.Phase = Context.Phase;
result.Message = $"left station node={report.Node.Node}, expect={expected.Node}";
return result;
}
if (!Fass2ActionResolver.IsStationActionComplete(expected, report.Node, report.State))
{
if (ShouldSendActionPatch())
{
var patch = Fass2ActionResolver.BuildActionPatch(expected, report.Node);
if (patch != null)
{
Context.LastActionId = _callbacks.AllocateActionId();
Context.LastActionSentAt = DateTime.Now;
_callbacks.SendAction(patch, Context.LastActionId);
result.ActionSent = true;
result.Message =
$"action pending [{Fass2ActionResolver.DescribePending(expected, report.Node)}], sent 0xA1={Context.LastActionId}";
Log(result.Message);
}
}
return result;
}
Context.Phase = Fass2TaskPhase.SegmentDone;
result.Phase = Context.Phase;
result.Message = $"station done index={Context.CurrentIndex}, node={expected.Node}";
Log(result.Message);
Persist();
return result;
}
private Fass2TaskTickResult HandleSegmentDone(Fass2StateReport report, Fass2TaskTickResult result)
{
Context.CurrentIndex++;
result.Advanced = true;
if (Context.Plan == null || Context.CurrentIndex >= Context.Plan.Nodes.Count)
{
Context.Phase = Fass2TaskPhase.Complete;
result.Phase = Context.Phase;
result.Completed = true;
result.Message = $"task complete goal={Context.GoalSiteId}";
Log(result.Message);
ClearPersisted();
Completed?.Invoke(Context);
_completionSource?.TrySetResult(1);
return result;
}
Context.Phase = Fass2TaskPhase.Dispatching;
DispatchCurrentWindow();
Context.Phase = Fass2TaskPhase.Moving;
result.Phase = Context.Phase;
result.Dispatched = true;
result.Message = $"segment advanced index={Context.CurrentIndex}";
Log(result.Message);
Persist();
return result;
}
private bool TryRebuildForFieldsChange(Fass2TaskTickResult result)
{
if (Context.Plan?.SiteIds == null || Context.Plan.SiteIds.Count == 0)
{
return false;
}
var newSignature = Fass2SiteFieldReader.BuildFieldsSignature(
Context.Plan.SiteIds, Context.CurrentIndex, SimpleLib.GetSite);
if (string.Equals(newSignature, Context.FieldsSignature, StringComparison.Ordinal))
{
return false;
}
var currentSiteId = Context.Plan.SiteIds[Context.CurrentIndex];
Log($"fields changed at index={Context.CurrentIndex}, rebuild from site={currentSiteId}");
Context.Plan = _callbacks.BuildPlan(currentSiteId, Context.GoalSiteId, Context.DefaultSpeed);
if (Context.Plan == null || Context.Plan.Nodes.Count == 0)
{
Fault($"rebuild failed from {currentSiteId} to {Context.GoalSiteId}", result);
return true;
}
Context.CurrentIndex = 0;
Context.FieldsSignature = Context.Plan.FieldsSignature;
Context.TaskId = _callbacks.AllocateTaskId();
DispatchCurrentWindow();
Context.Phase = Fass2TaskPhase.Moving;
result.Phase = Context.Phase;
result.Rebuilt = true;
result.Dispatched = true;
result.Message = $"rebuilt nodes={Context.Plan.Nodes.Count}, task={Context.TaskId}";
Persist();
return true;
}
private void DispatchCurrentWindow()
{
var window = BuildDispatchWindow();
if (window.Length == 0)
{
throw new InvalidOperationException("dispatch window is empty");
}
_callbacks.SendNodes(window, Context.TaskId);
Context.LastDispatchAt = DateTime.Now;
Log($"dispatch window count={window.Length}, fromIndex={Context.CurrentIndex}, task={Context.TaskId}");
}
private Fass2NodeMessage[] BuildDispatchWindow()
{
var nodes = Context.Plan.Nodes;
var start = Context.CurrentIndex;
var remaining = nodes.Count - start;
var windowSize = Math.Max(1, Math.Min(LockCount, remaining));
windowSize = Math.Min(windowSize, 10);
var window = new Fass2NodeMessage[windowSize];
for (var i = 0; i < windowSize; i++)
{
window[i] = nodes[start + i];
}
return window;
}
private Fass2NodeMessage GetExpectedNode()
{
return Context.Plan.Nodes[Context.CurrentIndex];
}
private int ResolveCurrentSiteId(Fass2StateReport report)
{
if (Context.CurrentIndex > 0 && Context.Plan?.SiteIds != null &&
Context.CurrentIndex < Context.Plan.SiteIds.Count)
{
return Context.Plan.SiteIds[Context.CurrentIndex];
}
if (report?.Node != null && report.Node.Node != 0)
{
var site = _callbacks.ResolveSite(report.Node.Node);
if (site != null)
{
return site.id;
}
}
return Context.StartSiteId;
}
private bool ShouldResend()
{
return Context.LastDispatchAt == DateTime.MinValue ||
(DateTime.Now - Context.LastDispatchAt).TotalMilliseconds >= ResendIntervalMs;
}
private bool ShouldSendActionPatch()
{
return Context.LastActionSentAt == DateTime.MinValue ||
(DateTime.Now - Context.LastActionSentAt).TotalMilliseconds >= ActionRetryIntervalMs;
}
private Fass2TaskTickResult Fault(string reason, Fass2TaskTickResult result)
{
Context.Phase = Fass2TaskPhase.Fault;
Context.FaultReason = reason;
result.Phase = Context.Phase;
result.Faulted = true;
result.Message = reason;
Log($"task fault: {reason}");
Persist();
Faulted?.Invoke(Context, reason);
_completionSource?.TrySetException(new InvalidOperationException(reason));
return result;
}
private void Persist()
{
_callbacks.Persist?.Invoke(Context);
}
private void ClearPersisted()
{
_callbacks.ClearPersisted?.Invoke();
}
private void Log(string message)
{
_callbacks.Log?.Invoke(message);
}
private TaskCompletionSource<int> ResetCompletionSource()
{
_completionSource = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
return _completionSource;
}
}
}
@@ -0,0 +1,84 @@
using SimpleCore.PropType;
using System;
using System.Collections.Generic;
using System.Globalization;
namespace StandardScene.Magnetic.Tasking
{
internal sealed class Fass2TrackMotionData
{
public float Speed { get; set; } = 0.2f;
public bool Reverse { get; set; }
public float CarDirectionBias { get; set; }
public bool EnableCarAbsoluteDirection { get; set; }
public float CarAbsoluteDirection { get; set; }
public byte? Byroad { get; set; }
public byte? Direction { get; set; }
public byte? Orientation { get; set; }
}
internal static class Fass2TrackFieldReader
{
public static Fass2TrackMotionData Read(Track track)
{
var data = new Fass2TrackMotionData();
if (track?.fields == null)
{
return data;
}
if (track.fields.TryGetValue("Speed", out var speedText) &&
float.TryParse(speedText, NumberStyles.Float, CultureInfo.InvariantCulture, out var speed))
{
data.Speed = speed;
}
if (track.fields.TryGetValue("Reverse", out var reverseText) &&
bool.TryParse(reverseText, out var reverse))
{
data.Reverse = reverse;
}
if (track.fields.TryGetValue("CarDirectionBias", out var biasText) &&
float.TryParse(biasText, NumberStyles.Float, CultureInfo.InvariantCulture, out var bias))
{
data.CarDirectionBias = bias;
}
if (track.fields.TryGetValue("EnableCarAbsoluteDirection", out var enableAbsText) &&
bool.TryParse(enableAbsText, out var enableAbs))
{
data.EnableCarAbsoluteDirection = enableAbs;
}
if (track.fields.TryGetValue("CarAbsoluteDirection", out var absText) &&
float.TryParse(absText, NumberStyles.Float, CultureInfo.InvariantCulture, out var absDir))
{
data.CarAbsoluteDirection = absDir;
}
data.Byroad = TryReadByte(track.fields, "Fass2_Byroad");
if (data.Byroad == null && track.fields.TryGetValue("magSelect", out var magSelect) &&
byte.TryParse(magSelect, NumberStyles.Integer, CultureInfo.InvariantCulture, out var magValue))
{
data.Byroad = magValue;
}
data.Direction = TryReadByte(track.fields, "Fass2_Direction");
data.Orientation = TryReadByte(track.fields, "Fass2_Orientation");
return data;
}
private static byte? TryReadByte(Dictionary<string, string> fields, string key)
{
if (!fields.TryGetValue(key, out var text) || string.IsNullOrWhiteSpace(text))
{
return null;
}
return byte.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)
? value
: (byte?)null;
}
}
}
@@ -0,0 +1,26 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StandardScene.Magnetic.Tasking
{
/// <summary>
/// FASS 2.0 车型与 LoopMission 衔接接口。
/// LoopMission 仍通过 <c>goalSite</c> 标签分配目标;车型侧负责一次全程任务执行。
/// </summary>
public interface IFass2LoopCar
{
bool IsTaskIdle { get; }
bool HasGoalSite { get; }
int? GoalSiteId { get; }
/// <summary>执行 <c>goalSite</c>(或指定目标)对应的全程 FASS 2.0 任务。</summary>
Task ExecuteGoalTaskAsync(int? goalSiteId = null, double defaultSpeed = -1,
CancellationToken cancellationToken = default);
/// <summary>任务到达终点后触发,参数为到达站 ID。</summary>
event Action<int> TaskCompleted;
}
}
@@ -0,0 +1,61 @@
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Text;
namespace StandardScene.Magnetic.Tasking
{
/// <summary>
/// MagFass2Car 本地文件日志,按协议车号分目录:logs/car{VehicleCode}/magfass2_yyyyMMdd.log
/// </summary>
public static class MagFass2CarFileLogger
{
private static readonly ConcurrentDictionary<ushort, object> CarLocks = new ConcurrentDictionary<ushort, object>();
private static string _baseDirectory = "logs";
private static bool _enabled = true;
public static void Configure(string baseDirectory, bool enabled)
{
_baseDirectory = string.IsNullOrWhiteSpace(baseDirectory) ? "logs" : baseDirectory.Trim();
_enabled = enabled;
}
public static string GetCarLogDirectory(ushort vehicleCode)
{
return Path.Combine(ResolveBaseDirectory(), $"car{vehicleCode}");
}
public static string GetCurrentLogFilePath(ushort vehicleCode)
{
var fileName = $"magfass2_{DateTime.Now:yyyyMMdd}.log";
return Path.Combine(GetCarLogDirectory(vehicleCode), fileName);
}
public static void Write(ushort vehicleCode, string message)
{
if (!_enabled || vehicleCode == 0 || string.IsNullOrWhiteSpace(message))
{
return;
}
var carLock = CarLocks.GetOrAdd(vehicleCode, _ => new object());
lock (carLock)
{
var directory = GetCarLogDirectory(vehicleCode);
Directory.CreateDirectory(directory);
var path = GetCurrentLogFilePath(vehicleCode);
File.AppendAllText(
path,
$"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] {message}{Environment.NewLine}",
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
}
}
private static string ResolveBaseDirectory()
{
return Path.IsPathRooted(_baseDirectory)
? _baseDirectory
: Path.Combine(AppContext.BaseDirectory, _baseDirectory);
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+209
View File
@@ -0,0 +1,209 @@
# Export MagFass2Car guide Markdown to PDF (requires Chrome or Edge)
param(
[string]$InputMd = "$PSScriptRoot\MagFass2Car使用指南.md",
[string]$OutputPdf = "$PSScriptRoot\MagFass2Car使用指南.pdf"
)
$ErrorActionPreference = 'Stop'
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
function Escape-Html([string]$text) {
return [System.Net.WebUtility]::HtmlEncode($text)
}
function Convert-MarkdownToHtml([string]$markdown) {
$lines = $markdown -split "`r?`n"
$sb = New-Object System.Text.StringBuilder
$inCode = $false
$inTable = $false
$tableRows = New-Object System.Collections.Generic.List[string[]]
$inList = $false
$listOrdered = $false
function Flush-Table {
param($builder, [ref]$inTableRef, $rows)
if (-not $inTableRef.Value) { return }
[void]$builder.AppendLine('<table>')
for ($i = 0; $i -lt $rows.Count; $i++) {
$cells = $rows[$i]
$tag = if ($i -eq 0) { 'th' } else { 'td' }
if ($i -eq 0) { [void]$builder.AppendLine('<thead><tr>') } elseif ($i -eq 1) { [void]$builder.AppendLine('<tbody><tr>') } else { [void]$builder.AppendLine('<tr>') }
foreach ($cell in $cells) {
$cellHtml = Escape-Html($cell.Trim())
$cellHtml = $cellHtml -replace '`([^`]+)`', '<code>$1</code>'
[void]$builder.AppendLine("<$tag>$cellHtml</$tag>")
}
[void]$builder.AppendLine('</tr>')
if ($i -eq 0) { [void]$builder.AppendLine('</thead>') }
}
if ($rows.Count -gt 1) { [void]$builder.AppendLine('</tbody>') }
[void]$builder.AppendLine('</table>')
$rows.Clear()
$inTableRef.Value = $false
}
function Close-List {
param($builder, [ref]$inListRef, [ref]$orderedRef)
if (-not $inListRef.Value) { return }
if ($orderedRef.Value) { [void]$builder.AppendLine('</ol>') } else { [void]$builder.AppendLine('</ul>') }
$inListRef.Value = $false
$orderedRef.Value = $false
}
foreach ($line in $lines) {
if ($line -match '^\s*```') {
if ($inCode) {
[void]$sb.AppendLine('</code></pre>')
$inCode = $false
} else {
Flush-Table $sb ([ref]$inTable) $tableRows
Close-List $sb ([ref]$inList) ([ref]$listOrdered)
[void]$sb.AppendLine('<pre><code>')
$inCode = $true
}
continue
}
if ($inCode) {
[void]$sb.AppendLine((Escape-Html $line))
continue
}
if ($line -match '^\s*\|(.+)\|\s*$') {
Close-List $sb ([ref]$inList) ([ref]$listOrdered)
if ($line -match '^\s*\|[-:\s|]+\|\s*$') { continue }
$cells = ($line.Trim('|') -split '\|') | ForEach-Object { $_.Trim() }
$tableRows.Add($cells)
$inTable = $true
continue
} else {
Flush-Table $sb ([ref]$inTable) $tableRows
}
if ($line -match '^(#{1,6})\s+(.+)$') {
Close-List $sb ([ref]$inList) ([ref]$listOrdered)
$level = $Matches[1].Length
$text = $Matches[2]
[void]$sb.AppendLine("<h$level>$(Escape-Html $text)</h$level>")
continue
}
if ($line -match '^>\s*(.+)$') {
Close-List $sb ([ref]$inList) ([ref]$listOrdered)
$text = $Matches[1]
[void]$sb.AppendLine("<blockquote><p>$(Escape-Html $text)</p></blockquote>")
continue
}
if ($line -match '^---\s*$') {
Close-List $sb ([ref]$inList) ([ref]$listOrdered)
[void]$sb.AppendLine('<hr/>')
continue
}
if ($line -match '^\d+\.\s+(.+)$') {
if (-not $inList) { [void]$sb.AppendLine('<ol>'); $inList = $true; $listOrdered = $true }
$text = Escape-Html $Matches[1]
$text = $text -replace '`([^`]+)`', '<code>$1</code>'
[void]$sb.AppendLine("<li>$text</li>")
continue
}
if ($line -match '^-\s+(.+)$') {
if (-not $inList) { [void]$sb.AppendLine('<ul>'); $inList = $true; $listOrdered = $false }
$text = Escape-Html $Matches[1]
$text = $text -replace '`([^`]+)`', '<code>$1</code>'
[void]$sb.AppendLine("<li>$text</li>")
continue
}
if ([string]::IsNullOrWhiteSpace($line)) {
Close-List $sb ([ref]$inList) ([ref]$listOrdered)
continue
}
Close-List $sb ([ref]$inList) ([ref]$listOrdered)
$p = Escape-Html $line
$p = $p -replace '`([^`]+)`', '<code>$1</code>'
[void]$sb.AppendLine("<p>$p</p>")
}
Flush-Table $sb ([ref]$inTable) $tableRows
Close-List $sb ([ref]$inList) ([ref]$listOrdered)
if ($inCode) { [void]$sb.AppendLine('</code></pre>') }
return $sb.ToString()
}
if (-not (Test-Path $InputMd)) {
throw "Markdown file not found: $InputMd"
}
$md = Get-Content -Path $InputMd -Raw -Encoding UTF8
$body = Convert-MarkdownToHtml $md
$htmlPath = [System.IO.Path]::ChangeExtension($OutputPdf, '.html')
$html = @"
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>MagFass2Car Guide</title>
<style>
@page { size: A4; margin: 18mm 16mm; }
body { font-family: "Microsoft YaHei", "Segoe UI", sans-serif; color: #222; line-height: 1.55; font-size: 11pt; }
h1 { font-size: 22pt; border-bottom: 2px solid #2563eb; padding-bottom: 8px; margin-top: 0; }
h2 { font-size: 16pt; color: #1e40af; margin-top: 22px; page-break-after: avoid; }
h3 { font-size: 13pt; color: #1d4ed8; margin-top: 16px; page-break-after: avoid; }
h4, h5 { font-size: 11.5pt; margin-top: 12px; page-break-after: avoid; }
p, li { orphans: 3; widows: 3; }
code, pre { font-family: Consolas, "Courier New", monospace; }
code { background: #f3f4f6; padding: 1px 4px; border-radius: 3px; font-size: 10pt; }
pre { background: #f8fafc; border: 1px solid #e5e7eb; padding: 10px; overflow-x: auto; font-size: 9.5pt; }
pre code { background: transparent; padding: 0; }
table { border-collapse: collapse; width: 100%; margin: 10px 0 14px; font-size: 10pt; page-break-inside: avoid; }
th, td { border: 1px solid #d1d5db; padding: 6px 8px; vertical-align: top; }
th { background: #eff6ff; text-align: left; }
tr:nth-child(even) td { background: #fafafa; }
blockquote { border-left: 4px solid #93c5fd; margin: 10px 0; padding: 6px 12px; color: #374151; background: #f8fafc; }
hr { border: none; border-top: 1px solid #e5e7eb; margin: 18px 0; }
ul, ol { margin: 6px 0 10px 20px; }
</style>
</head>
<body>
$body
</body>
</html>
"@
[System.IO.File]::WriteAllText($htmlPath, $html, [System.Text.UTF8Encoding]::new($false))
$browserCandidates = @(
"$env:ProgramFiles\Google\Chrome\Application\chrome.exe",
"${env:ProgramFiles(x86)}\Google\Chrome\Application\chrome.exe",
"${env:ProgramFiles(x86)}\Microsoft\Edge\Application\msedge.exe",
"$env:ProgramFiles\Microsoft\Edge\Application\msedge.exe"
)
$browser = $browserCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $browser) {
throw "Chrome/Edge not found. HTML generated at: $htmlPath"
}
if (Test-Path $OutputPdf) { Remove-Item $OutputPdf -Force }
$resolvedHtml = (Resolve-Path -LiteralPath $htmlPath).Path
$fileUri = [System.Uri]::new($resolvedHtml).AbsoluteUri
$args = @(
"--headless=new",
"--disable-gpu",
"--no-pdf-header-footer",
"--print-to-pdf=$OutputPdf",
$fileUri
)
$proc = Start-Process -FilePath $browser -ArgumentList $args -Wait -PassThru
if ($proc.ExitCode -ne 0 -or -not (Test-Path $OutputPdf)) {
throw "PDF export failed with exit code $($proc.ExitCode). HTML: $htmlPath"
}
Write-Output "PDF: $OutputPdf"
Write-Output "HTML: $htmlPath"