diff --git a/Directory.Build.targets b/Directory.Build.targets
index 468cd84..99b416f 100644
--- a/Directory.Build.targets
+++ b/Directory.Build.targets
@@ -33,12 +33,27 @@
Condition="Exists('$(TargetDir)IoTClient.dll')" />
+
+
+
+
+
diff --git a/Doc/磁条交管逻辑与策略说明.html b/Doc/磁条交管逻辑与策略说明.html
new file mode 100644
index 0000000..290f374
--- /dev/null
+++ b/Doc/磁条交管逻辑与策略说明.html
@@ -0,0 +1,355 @@
+
+
+
+
+ 磁条交管逻辑与策略说明
+
+
+
+
+
FASS 2.0 / scene.signal
+
磁条交管逻辑与策略说明
+
MagCar 管控区排队、兼容组并行与扫车放行
+
+ 适用对象:现场实施、调度配置、软件维护
+ 配置文件:SimpleLite 工作目录 Config/Signal/mag-control-areas.json
+ 进程名称:磁条交管进程(MagTrafficMission)
+ 文档日期:2026-08-18
+
+
+
+ 1. 文档目的与适用范围
+ 本文说明 FASS 2.0 磁导航场景中,磁条交管进程如何根据管控区配置表对 MagCar(FASS 1.0 磁条车)进行停车等待、排队和放行。读者按本文即可完成十字路口、汇合点、单入口限流的配表,并理解扫车每一拍在做什么。
+ 1.1 适用
+
+ - 车型仅 MagCar。类型名必须恰好为 MagCar。
+ - 车在配置的管控触发站点停稳后,由本进程发 FASS 1.0 启动指令(StartMagCar)。
+ - 配置表由迷毂数据中心「磁条管控区」或进程内「打开管控区列表」维护,保存到上述 JSON。
+
+ 1.2 不适用
+
+ - Mag2Car 不走本进程。Mag2 仍使用地图字段 Fass2_ControlArea / Fass2_ControlCapacity,以及任务报文 StartStop=12→11,本进程不改 Mag2 报文。
+ - 内核 TrafficControl.TryLock 点锁、交通管制进程 traffic.json、环线任务的流量字段,与本表独立。
+ - 不负责让车在触发点停车。车必须先由任务/站点策略停在触发点,本进程只负责「满足条件后发启动」。
+
+
+ 2. 总体策略
+ 交管策略可以概括为四句话:
+
+ - 按区绑定:同一「管控区名称」的多行表示同一个物理路口(或汇合区)。
+ - 按线占用:每一行有自己的内部站点列表和容量,占用只统计本行站点,不把整个路口并成一份。
+ - 冲突才互斥:同区里,兼容组相同,或两行内部站点有交集,视为冲突,不能同时放;否则可并行。
+ - 冲突才排队:优先级、到达时刻、等待时长只在冲突线路之间生效。不冲突的线路互不插队、互不占名额。
+
+
+ 兼容组留空且同区多行都为空时,视为同一组,整区互斥,与早期「同区容量 1 只放一辆」的行为兼容。旧 JSON 不填兼容组即可沿用。
+
+
+ 3. 配置表(交管策略字段)
+ 主键是管控触发站点。同一触发点多条启用记录只保留第一条。未启用(IsUse=false)的行保留在文件中但不参与判定。
+
+ | 字段 | JSON 键 | 含义与配法 |
+
+ | 管控触发站点 |
+ TriggerSit |
+ MagCar 必须停稳在此站点后才进入本行排队。应对齐现场停车点,不要把路口内部行驶点当成触发点。 |
+
+
+ | 管控区名称 |
+ AreaName |
+ 同名多行绑成一个路口,共用等待时长(取最长)。空则该触发点自成一区,相当于单入口限流。 |
+
+
+ | 兼容组 |
+ CompatGroup |
+ 同区里互不干扰的线路填不同组名(如东西 EW、南北 NS)。会交叉、会抢同一段路的填同组。空字符串与其他空组视为同组。 |
+
+
+ | 优先级 |
+ Priority |
+ 整数,越大越先放。干道高、支路低。只在冲突线路之间比较。 |
+
+
+ | 等待时长 |
+ WaitSeconds |
+ 秒。同区多行不一致时取最长。≤0 表示不做插队。用于防止支路被干道一直堵住。 |
+
+
+ | 管控区站点列表 |
+ ControlArea |
+ 本线路穿过路口的内部站点,逗号分隔,如 10,11。不要把各方向排队触发点写进来。空则回退为触发点本身。 |
+
+
+ | 区内最多几辆 |
+ ControlCapacity |
+ 本行容量。小于 1 按 1 处理。统计范围:占用本行内部站点的车 + 与本行冲突、已发启动但未进区的在途车。 |
+
+
+ | 放行后启停字节 |
+ ControlReleaseStartStop |
+ 兼容字段。MagCar 放行发 FASS 1.0 启动指令,不使用该字节。 |
+
+
+ | 启用 |
+ IsUse |
+ false 时本行不生效。 |
+
+
+ 3.1 运行态(不入库)
+
+ | 名称 | 说明 |
+
+ | 到达时刻 |
+ 进程第一次扫到该 MagCar 停在该触发点的时间。开走后再回来重新计时。不写入 JSON。 |
+
+
+ | 在途占额 |
+ 已对该车发过启动,但尚未占用本行内部站点。避免「刚放行、车还在触发点」时下一拍又放一辆冲突车。 |
+
+
+ 3.2 进程参数
+
+ | 参数 | 默认 | 说明 |
+ | TickMs | 1000 | 扫车周期(毫秒),下限按 200ms 夹紧。 |
+ | PendingTimeoutSeconds | 8 | 在途超时(秒)。超时后取消占额,防止启动失败把名额卡死。 |
+ | AreasPath | Config\Signal\mag-control-areas.json | 相对路径只解析 SimpleLite 工作目录,不读 plugins 目录。 |
+
+
+ 4. 交管逻辑(扫车每一拍)
+ 进程启动或点「重新加载配置」后,读取启用规则。此后约每秒执行一次 ScanAndRelease。
+
+
单拍流程
+
+ - 快照全场车辆:站点(siteID / GetLastSite)、占用站点(siteID + holdingLocks)、是否 MagCar、是否停稳。
+ - 启用规则按管控区名称分组(空名称则按「#sit:触发点」自成一区)。同区等待时长取各行之最长。
+ - 清理到达时刻:已离开原触发点的车重新计时。
+ - 清理在途:车消失、超时、或已经踩到本行内部站点 → 去掉在途标记(进区后改由占用统计)。
+ - 收集本区停在各触发点上的停稳 MagCar,记为等待队列;已在途的不再重复发启动。
+ - 按第 6 节规则排序,然后从队头到队尾贪心:本行有空位则发启动并登记在途;无空位则跳过,继续看后面不冲突的车。
+ - 放行动作:先尝试停充,再 StartMagCar(FASS 1.0 启动)。
+
+
+ 4.1 何谓停稳
+ 状态不是「运行中 / Running」。下列视为可排队:停止中、充电中、WaitPass,以及没有 pendingLocks、没有 occupied 标签的非运行状态。充电中的车放行前会尝试 StopCharge。
+ 4.2 占用怎么数
+ 对某一等待车辆,只统计占用该行 ControlArea 的他车。排队触发点上尚未放行的 MagCar 不计入占用(它们还在区外等候)。本车不计。
+ 占用站点来源:车辆当前 siteID,以及 status.holdingLocks。任一命中列表即算 1 辆。
+ 4.3 可放行数(空位)
+ 空位 = 本行容量 − 占用本行站点的车 − 与本行冲突的在途车
+ 空位 ≥ 1 才对该车发启动。容量 1 表示:本线路内部(含冲突在途)最多一辆。
+
+ 在途车若已经进入本行内部站点,只算「占用」,不再重复算「在途」,避免一名额扣两次。
+
+
+ 5. 冲突判定(能否并行)
+ 只比较同一管控区名称下的两行(或一行与一条在途记录)。不同区互不影响。
+ 满足下面任一条件即冲突(互斥):
+
+ - 兼容组相同(不区分大小写,去首尾空格)。两边都为空也算相同 → 整区互斥。
+ - 两行的管控区站点列表有交集。
+
+ 两组都不同、且站点列表不相交 → 不冲突,可同时放行。
+
+ | 场景 | 兼容组 | 内部站点 | 结果 |
+ | 旧表、未填兼容组 | 都空 | 任意 | 同区全部互斥 |
+ | 东西对向直行 | 都填 EW | 可不相交 | 同组,互斥(对向若也要并行,应拆成不同组且站点不相交) |
+ | 东西 / 南北互不交叉 | EW 与 NS | 10,11 与 20,21 | 不冲突,可并行 |
+ | 组名不同但抢同一段 | EW 与 NS | 都含 15 | 站点相交,仍互斥 |
+ | 左转与对向直行交叉 | 建议同组,或站点列出交叉段 | 有交集或同组 | 互斥 |
+
+
+ 若四个方向都复制同一份内部站点(例如都写 10,11,12,13),则即使兼容组不同也会因站点相交而全部互斥。要对向并行,必须按方向拆开内部点,且不要把触发点写进列表。
+
+
+ 6. 排队规则(仅冲突线路)
+ 同一管控区内,先按下列比较函数排序,再贪心放行。不冲突的车排序先后不影响能否同时过,只影响扫描顺序。
+ 6.1 插队(等待时长)
+ 当同区等待时长 W > 0,且两车优先级不同:
+ 若 高优先级到达时刻 − 低优先级到达时刻 > W,则低优先级先走。
+ 含义:低优先级更早到达,且已经比高优先级早到超过 W 秒,避免支路饿死。差值未超过 W 时,仍让优先级高的先走。
+ 使用「>」而不是「≥」。例如 W=10,恰好早到 10 秒时仍按优先级。
+ 6.2 默认顺序
+
+ - 未触发插队时:优先级数值大的在前。
+ - 优先级相同:到达时刻早的在前。
+ - 仍相同:车号小的在前(稳定次序)。
+
+ 6.3 贪心,而不是只放队头
+ 排序后从前往后看。队头因本线已满或冲突在途没有空位时,跳过队头,继续检查后面的车。这样南北已占用时,队头若是南北,不会挡住后面可以走的东西向车辆。
+
+ 7. 放行与在途生命周期
+
+ - 判定可放 → StopCharge(若有)→ StartMagCar。
+ - 登记在途:记录区名、触发点、兼容组、本行内部站点、放行时刻。
+ - 下一拍该车仍停在触发点:不再重复启动,但作为冲突在途占额。
+ - 车进入本行内部站点:清除在途,改为占用统计。
+ - 超过 PendingTimeoutSeconds(默认 8 秒)仍未进区:清除在途,避免卡死。车若仍停在触发点,下一拍可再次进入排队。
+ - 车离线或从系统消失:清除在途与到达时刻。
+
+ 重新加载配置会清空到达时刻和在途表,请避免在路口有车等待时频繁重载。
+
+ 8. 配置示例
+ 8.1 十字路口:东西与南北并行
+ 区名均为「十字路口A」。东西兼容组 EW,南北 NS。内部站点按方向拆开,容量均为 1。东西对向若现场确实会在同一段交汇,应把对向也拆站点或改为同组。
+ [
+ { "TriggerSit": 8, "AreaName": "十字路口A", "CompatGroup": "EW", "Priority": 10, "WaitSeconds": 15, "ControlArea": "10,11", "ControlCapacity": 1, "IsUse": true },
+ { "TriggerSit": 18, "AreaName": "十字路口A", "CompatGroup": "EW", "Priority": 10, "WaitSeconds": 15, "ControlArea": "12,13", "ControlCapacity": 1, "IsUse": true },
+ { "TriggerSit": 9, "AreaName": "十字路口A", "CompatGroup": "NS", "Priority": 5, "WaitSeconds": 15, "ControlArea": "20,21", "ControlCapacity": 1, "IsUse": true },
+ { "TriggerSit": 19, "AreaName": "十字路口A", "CompatGroup": "NS", "Priority": 5, "WaitSeconds": 15, "ControlArea": "22,23", "ControlCapacity": 1, "IsUse": true }
+]
+ 效果:东西一辆与南北一辆可同时过;同一兼容组内容量 1,同向或同组第二辆要等;南北优先级较低,但若已等待超过 15 秒且比干道更早到达,可以插队。
+ 8.2 单入口限流(旧用法)
+ 只配一行:触发点 8,区名为空,兼容组空,ControlArea 写内部点,容量 1。该点自成一区,他车占用内部点时不放行。
+ 8.3 支路防饿死
+ 干道 Priority=10,支路 Priority=1,同区 WaitSeconds=20。支路先到、干道后到且时间差大于 20 秒时放支路;否则放干道。
+
+ 9. 现场操作
+
+ - 确认已加载信号插件 StandardScene.Signal.dll,进程栏中有「磁条交管进程」。
+ - 将 JSON 放到 SimpleLite 工作目录 Config\Signal\mag-control-areas.json。可用迷毂数据中心「磁条管控区」编辑(含兼容组列,需较新的 MiGu.Server)。
+ - 添加并启动磁条交管进程。改表后点「重新加载配置」,或进程内打开列表保存后重载。
+ - 状态栏可见:规则条数、触发点等待数、在途数、扫车拍数。
+ - 诊断通道 MagTraffic 会记录放行:区名、兼容组、触发点、本线占用/容量、冲突在途。
+
+
+ 交管不会替任务系统停车。请保证 MagCar 到达触发点时处于停止,否则不会进入排队,也不会发启动。
+
+
+ 10. 与 Mag2 / 其他交管的边界
+
+ | 机制 | 对象 | 关系 |
+ | 本进程 mag-control-areas.json | MagCar | 触发点停稳后发 StartMagCar |
+ | 地图 Fass2_ControlArea 等 | Mag2Car | StartStop=12 等待,放行改本车报文,本进程不介入 |
+ | TrafficControl 点锁 | MagCar / Mag2 路径执行 | 锁站点资源,与本表并行存在 |
+ | 交通管制 traffic.json | 区域互斥钩子 | 需单独启动「交通管制」进程 |
+
+
+ 11. 常见问题
+ 十字路口空着,四个方向却一起冲出去?
+ 兼容组未区分,或内部站点写成同一串。请按方向拆兼容组并拆开 ControlArea。
+ 四个方向谁也不走?
+ 把四个停车触发点写进了 ControlArea,容量又为 1,等待车被当成区内占用。触发点不要写入内部列表。
+ 改了 JSON 不生效?
+ 必须点「重新加载配置」或重启进程。路径必须是工作目录 Config/Signal,不是插件目录。
+ Mag2 在同一路口不停不等?
+ 本策略不管 Mag2。Mag2 需在地图配置 Fass2_StartStop=12 及管控区字段。
+ 容量填 0?
+ 当前实现小于 1 按 1 处理,不会变成不限流。
+ 对向也要并行,但写成了同一兼容组?
+ 同组即互斥。对向要并行:不同组名,且内部站点不相交。
+
+ 12. 逻辑对照(维护用)
+
+ | 内容 | 位置 |
+ | 表字段 | StandardScene.Signal / MagControlAreaModel |
+ | 冲突、占用、容量 | MagControlAreaGate |
+ | 扫车、排队、在途、贪心放行 | MagControlRuntime.ScanAndRelease |
+ | 进程与重载 | MagTrafficMission |
+ | 迷毂列定义 | MiGu.Server / SignalDataStore(表 id: mag-control) |
+
+
+
+
diff --git a/Doc/磁条交管逻辑与策略说明.pdf b/Doc/磁条交管逻辑与策略说明.pdf
new file mode 100644
index 0000000..0e78d23
Binary files /dev/null and b/Doc/磁条交管逻辑与策略说明.pdf differ
diff --git a/StandardScene.Core/InterLock/MagControlReleaseHook.cs b/StandardScene.Core/InterLock/MagControlReleaseHook.cs
new file mode 100644
index 0000000..aef402e
--- /dev/null
+++ b/StandardScene.Core/InterLock/MagControlReleaseHook.cs
@@ -0,0 +1,75 @@
+using System;
+
+namespace StandardScene
+{
+ ///
+ /// 磁条管控区放行判定结果。由信号插件「磁条交管进程」填写,Mag2Car 在 StartStop=12 停稳后消费。
+ ///
+ public sealed class MagControlReleaseDecision
+ {
+ /// 当前触发点在交管表里有启用规则。false 时 Mag2Car 回退地图 Fass2_ControlArea 字段。
+ public bool RuleFound { get; set; }
+
+ public bool CanRelease { get; set; }
+
+ public string AreaId { get; set; } = string.Empty;
+
+ public int Capacity { get; set; } = 1;
+
+ public int OthersInArea { get; set; }
+
+ /// 放行后写入本车报文的 StartStop,通常 11(管控启动)或 1(过站)。
+ public byte ReleaseStartStop { get; set; } = 11;
+
+ public string Reason { get; set; } = string.Empty;
+ }
+
+ ///
+ /// Mag2Car 与信号插件之间的管控放行钩子。不让 Magnetic / Signal 互相引用。
+ ///
+ ///
+ /// 磁条交管进程启动时 ,停止时 。
+ /// Mag2Car 在管控停止点调用 :有规则用表判定,没有则用地图字段。
+ ///
+ public static class MagControlReleaseHook
+ {
+ private static readonly object Sync = new object();
+ private static object _owner;
+ private static Func _check;
+
+ /// 注册判定函数。 用于停止时只清自己的注册。
+ public static void Register(object owner, Func check)
+ {
+ if (owner == null) throw new ArgumentNullException(nameof(owner));
+ lock (Sync)
+ {
+ _owner = owner;
+ _check = check;
+ }
+ }
+
+ public static void Unregister(object owner)
+ {
+ if (owner == null) return;
+ lock (Sync)
+ {
+ if (!ReferenceEquals(_owner, owner))
+ return;
+ _owner = null;
+ _check = null;
+ }
+ }
+
+ ///
+ /// 询问当前注册的交管进程。未注册返回 null;已注册但该触发点无规则时
+ /// 返回 = false。
+ ///
+ public static MagControlReleaseDecision TryCheck(int carId, int triggerSiteId)
+ {
+ Func check;
+ lock (Sync)
+ check = _check;
+ return check?.Invoke(carId, triggerSiteId);
+ }
+ }
+}
diff --git a/StandardScene.Fass2Simulator/Fass2SimConfig.cs b/StandardScene.Fass2Simulator/Fass2SimConfig.cs
index e21b3fc..c400baf 100644
--- a/StandardScene.Fass2Simulator/Fass2SimConfig.cs
+++ b/StandardScene.Fass2Simulator/Fass2SimConfig.cs
@@ -24,7 +24,7 @@ namespace StandardScene.Fass2Simulator
public bool AutoCompleteStationActions { get; set; }
public string NodeProfilesPath { get; set; } = "sim-nodes.json";
public bool EnableFileLog { get; set; } = true;
- public string LogDirectory { get; set; } = "logs";
+ public string LogDirectory { get; set; } = "log";
public Dictionary NodeProfiles { get; set; } = new Dictionary();
}
}
diff --git a/StandardScene.Fass2Simulator/Fass2SimFileLogger.cs b/StandardScene.Fass2Simulator/Fass2SimFileLogger.cs
index fb553d3..5c8a719 100644
--- a/StandardScene.Fass2Simulator/Fass2SimFileLogger.cs
+++ b/StandardScene.Fass2Simulator/Fass2SimFileLogger.cs
@@ -86,7 +86,7 @@ namespace StandardScene.Fass2Simulator
public static string GetLogDirectory()
{
- return _logDirectory ?? ResolveLogDirectory("logs");
+ return _logDirectory ?? ResolveLogDirectory("log");
}
private static void EnsureDefaultWriter()
@@ -129,7 +129,7 @@ namespace StandardScene.Fass2Simulator
{
if (string.IsNullOrWhiteSpace(configuredPath))
{
- return Path.Combine(AppContext.BaseDirectory, "logs");
+ return Path.Combine(AppContext.BaseDirectory, "log");
}
return Path.IsPathRooted(configuredPath)
diff --git a/StandardScene.Fass2Simulator/MainWindow.xaml.cs b/StandardScene.Fass2Simulator/MainWindow.xaml.cs
index 49ba71a..049146b 100644
--- a/StandardScene.Fass2Simulator/MainWindow.xaml.cs
+++ b/StandardScene.Fass2Simulator/MainWindow.xaml.cs
@@ -43,7 +43,7 @@ namespace StandardScene.Fass2Simulator
Fass2SimFileLogger.Shutdown();
};
- AppendLog("就绪。配置参数后点击「启动模拟」开始联调。完整日志将写入 logs 目录。");
+ AppendLog("就绪。配置参数后点击「启动模拟」开始联调。完整日志将写入 log 目录。");
}
private void StartButton_Click(object sender, RoutedEventArgs e)
@@ -317,7 +317,7 @@ namespace StandardScene.Fass2Simulator
AutoCompleteStationActions = AutoCompleteBox.IsChecked == true,
LogRawFrames = LogRawFramesBox.IsChecked == true,
EnableFileLog = _config?.EnableFileLog ?? true,
- LogDirectory = _config?.LogDirectory ?? "logs"
+ LogDirectory = _config?.LogDirectory ?? "log"
};
}
diff --git a/StandardScene.Fass2Simulator/appsettings.car2.json b/StandardScene.Fass2Simulator/appsettings.car2.json
index 2fae109..9c72e3a 100644
--- a/StandardScene.Fass2Simulator/appsettings.car2.json
+++ b/StandardScene.Fass2Simulator/appsettings.car2.json
@@ -20,6 +20,6 @@
"AutoCompleteStationActions": false,
"NodeProfilesPath": "sim-nodes.json",
"EnableFileLog": true,
- "LogDirectory": "logs\\car2"
+ "LogDirectory": "log\\car2"
}
}
diff --git a/StandardScene.Fass2Simulator/appsettings.json b/StandardScene.Fass2Simulator/appsettings.json
index 1b0ddd4..3162adc 100644
--- a/StandardScene.Fass2Simulator/appsettings.json
+++ b/StandardScene.Fass2Simulator/appsettings.json
@@ -20,6 +20,6 @@
"AutoCompleteStationActions": false,
"NodeProfilesPath": "sim-nodes.json",
"EnableFileLog": true,
- "LogDirectory": "logs"
+ "LogDirectory": "log"
}
}
diff --git a/StandardScene.MagCarSimulator/MagCarSimLog.cs b/StandardScene.MagCarSimulator/MagCarSimLog.cs
new file mode 100644
index 0000000..29bc440
--- /dev/null
+++ b/StandardScene.MagCarSimulator/MagCarSimLog.cs
@@ -0,0 +1,131 @@
+using System;
+using System.IO;
+using System.Text;
+
+namespace StandardScene.MagCarSimulator
+{
+ ///
+ /// 模拟器日志:UI 回调 + 写入 exe 旁 log/ 目录。
+ ///
+ public static class MagCarSimLog
+ {
+ private static readonly object SyncRoot = new object();
+ private static StreamWriter _writer;
+ private static bool _enabled = true;
+ private static string _logDirectory;
+
+ public static event Action MessageWritten;
+
+ public static string CurrentFilePath { get; private set; }
+
+ public static void Configure(MagCarSimConfig config)
+ {
+ lock (SyncRoot)
+ {
+ _enabled = config == null || config.EnableFileLog;
+ _logDirectory = ResolveLogDirectory(config?.LogDirectory);
+ if (_enabled)
+ {
+ EnsureWriterUnlocked();
+ }
+ else
+ {
+ CloseWriterUnlocked();
+ }
+ }
+ }
+
+ public static string GetLogDirectory()
+ {
+ return _logDirectory ?? ResolveLogDirectory("log");
+ }
+
+ public static void WriteLine(string message)
+ {
+ if (string.IsNullOrEmpty(message))
+ {
+ return;
+ }
+
+ MessageWritten?.Invoke(message);
+
+ if (!_enabled)
+ {
+ return;
+ }
+
+ lock (SyncRoot)
+ {
+ EnsureWriterUnlocked();
+ _writer?.WriteLine(message);
+ }
+ }
+
+ public static void Shutdown()
+ {
+ lock (SyncRoot)
+ {
+ if (_writer != null)
+ {
+ try
+ {
+ _writer.WriteLine($"===== 会话结束 {DateTime.Now:yyyy-MM-dd HH:mm:ss} =====");
+ }
+ catch
+ {
+ }
+ }
+
+ CloseWriterUnlocked();
+ }
+ }
+
+ private static void EnsureWriterUnlocked()
+ {
+ if (!_enabled)
+ {
+ return;
+ }
+
+ if (_writer != null)
+ {
+ return;
+ }
+
+ Directory.CreateDirectory(GetLogDirectory());
+ var fileName = $"magcar-sim_{DateTime.Now:yyyyMMdd}.log";
+ CurrentFilePath = Path.Combine(GetLogDirectory(), fileName);
+ _writer = new StreamWriter(CurrentFilePath, true, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false))
+ {
+ AutoFlush = true
+ };
+ _writer.WriteLine($"[{DateTime.Now:HH:mm:ss}] 文件日志已启用: {CurrentFilePath}");
+ }
+
+ private static void CloseWriterUnlocked()
+ {
+ try
+ {
+ _writer?.Flush();
+ _writer?.Dispose();
+ }
+ catch
+ {
+ }
+
+ _writer = null;
+ }
+
+ private static string ResolveLogDirectory(string configuredPath)
+ {
+ if (string.IsNullOrWhiteSpace(configuredPath))
+ {
+ return Path.Combine(AppContext.BaseDirectory, "log");
+ }
+
+ return Path.IsPathRooted(configuredPath)
+ ? configuredPath
+ : Path.Combine(AppContext.BaseDirectory, configuredPath);
+ }
+ }
+}
diff --git a/StandardScene.MagCarSimulator/MainForm.cs b/StandardScene.MagCarSimulator/MainForm.cs
new file mode 100644
index 0000000..c6dc87d
--- /dev/null
+++ b/StandardScene.MagCarSimulator/MainForm.cs
@@ -0,0 +1,630 @@
+using System;
+using System.Collections.Generic;
+using System.Drawing;
+using System.IO;
+using System.Text;
+using System.Windows.Forms;
+
+namespace StandardScene.MagCarSimulator
+{
+ public sealed class MainForm : Form
+ {
+ private const int MaxLogLines = 400;
+ private const int MaxPendingLogs = 300;
+
+ private readonly MagCarSimConfig _config;
+ private readonly MagCarSimHost _host;
+ private readonly StringBuilder _logBuffer = new StringBuilder();
+ private readonly Queue _pendingLogs = new Queue();
+ private readonly object _logLock = new object();
+ private readonly Timer _uiTimer;
+ private bool _logFlushScheduled;
+ private int _logLineCount;
+
+ private TextBox _mapPathBox;
+ private CheckBox _useTagValueBox;
+ private Label _mapInfoLabel;
+ private DataGridView _grid;
+ private ListBox _siteList;
+ private TextBox _logBox;
+ private Button _listenButton;
+ private Button _loopButton;
+ private Button _stopButton;
+ private Button _releaseButton;
+ private Button _addButton;
+ private Button _removeButton;
+ private Button _saveButton;
+
+ public MainForm()
+ {
+ _config = MagCarSimConfig.Load();
+ MagCarSimLog.Configure(_config);
+ _host = new MagCarSimHost(_config);
+
+ Text = "MagCar 模拟器(FASS 1.0 / SimpleLite 地图循环)";
+ Width = 1280;
+ Height = 820;
+ MinimumSize = new Size(980, 640);
+ StartPosition = FormStartPosition.CenterScreen;
+ Font = new Font("Microsoft YaHei UI", 9F);
+
+ BuildLayout();
+ LoadConfigToUi();
+
+ MagCarSimLog.MessageWritten += OnLogMessage;
+ _uiTimer = new Timer { Interval = 250 };
+ _uiTimer.Tick += (_, __) => RefreshGridStatus();
+ _uiTimer.Start();
+
+ FormClosed += (_, __) =>
+ {
+ MagCarSimLog.MessageWritten -= OnLogMessage;
+ _uiTimer.Stop();
+ _host.Dispose();
+ MagCarSimLog.Shutdown();
+ };
+
+ AppendLog($"就绪。文件日志目录: {MagCarSimLog.GetLogDirectory()}。加载 SimpleLite 地图后启动监听,再开始循环。普通站可叠车;停止点和交管点同时只允许一辆。");
+ TryLoadMap(_config.MapPath, silent: true);
+ }
+
+ private void BuildLayout()
+ {
+ var root = new TableLayoutPanel
+ {
+ Dock = DockStyle.Fill,
+ ColumnCount = 1,
+ RowCount = 4,
+ Padding = new Padding(8)
+ };
+ root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
+ root.RowStyles.Add(new RowStyle(SizeType.Percent, 42));
+ root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
+ root.RowStyles.Add(new RowStyle(SizeType.Percent, 58));
+ Controls.Add(root);
+
+ root.Controls.Add(BuildToolbar(), 0, 0);
+ _grid = BuildGrid();
+ root.Controls.Add(_grid, 0, 1);
+ root.Controls.Add(BuildVehicleButtons(), 0, 2);
+
+ var split = new SplitContainer
+ {
+ Dock = DockStyle.Fill,
+ Orientation = Orientation.Vertical,
+ SplitterDistance = 280
+ };
+ _siteList = new ListBox { Dock = DockStyle.Fill, IntegralHeight = false };
+ var sitePanel = new Panel { Dock = DockStyle.Fill, Padding = new Padding(0, 4, 4, 0) };
+ sitePanel.Controls.Add(_siteList);
+ sitePanel.Controls.Add(new Label
+ {
+ Text = "地图站点(带 [停止] 的为 Mag_NeedStop)",
+ Dock = DockStyle.Top,
+ Height = 22
+ });
+ split.Panel1.Controls.Add(sitePanel);
+
+ _logBox = new TextBox
+ {
+ Dock = DockStyle.Fill,
+ Multiline = true,
+ ReadOnly = true,
+ ScrollBars = ScrollBars.Both,
+ WordWrap = false,
+ Font = new Font("Consolas", 9F)
+ };
+ var logPanel = new Panel { Dock = DockStyle.Fill, Padding = new Padding(4, 4, 0, 0) };
+ logPanel.Controls.Add(_logBox);
+ logPanel.Controls.Add(new Label
+ {
+ Text = "协议 / 运动日志",
+ Dock = DockStyle.Top,
+ Height = 22
+ });
+ split.Panel2.Controls.Add(logPanel);
+ root.Controls.Add(split, 0, 3);
+ }
+
+ private Control BuildToolbar()
+ {
+ var panel = new TableLayoutPanel
+ {
+ Dock = DockStyle.Top,
+ AutoSize = true,
+ ColumnCount = 8,
+ RowCount = 2,
+ Padding = new Padding(0, 0, 0, 6)
+ };
+ panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
+ panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
+ panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
+ panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
+ panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
+ panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
+ panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
+ panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
+
+ panel.Controls.Add(new Label { Text = "地图", AutoSize = true, Anchor = AnchorStyles.Left }, 0, 0);
+ _mapPathBox = new TextBox { Dock = DockStyle.Fill, Width = 480 };
+ panel.Controls.Add(_mapPathBox, 1, 0);
+
+ var browse = new Button { Text = "浏览…", AutoSize = true };
+ browse.Click += (_, __) => BrowseMap();
+ panel.Controls.Add(browse, 2, 0);
+
+ var load = new Button { Text = "加载地图", AutoSize = true };
+ load.Click += (_, __) => TryLoadMap(_mapPathBox.Text, silent: false);
+ panel.Controls.Add(load, 3, 0);
+
+ _listenButton = new Button { Text = "启动监听", AutoSize = true };
+ _listenButton.Click += (_, __) => StartListen();
+ panel.Controls.Add(_listenButton, 4, 0);
+
+ _loopButton = new Button { Text = "开始循环", AutoSize = true };
+ _loopButton.Click += (_, __) => StartLoop();
+ panel.Controls.Add(_loopButton, 5, 0);
+
+ _stopButton = new Button { Text = "停止模拟", AutoSize = true, Enabled = false };
+ _stopButton.Click += (_, __) => StopSim();
+ panel.Controls.Add(_stopButton, 6, 0);
+
+ _saveButton = new Button { Text = "保存配置", AutoSize = true };
+ _saveButton.Click += (_, __) => SaveConfig();
+ panel.Controls.Add(_saveButton, 7, 0);
+
+ _useTagValueBox = new CheckBox { Text = "用 TagValue 作为节点号", AutoSize = true, Anchor = AnchorStyles.Left };
+ panel.SetColumnSpan(_useTagValueBox, 2);
+ panel.Controls.Add(_useTagValueBox, 0, 1);
+
+ _mapInfoLabel = new Label
+ {
+ Text = "未加载地图",
+ AutoSize = true,
+ Anchor = AnchorStyles.Left,
+ ForeColor = Color.DimGray
+ };
+ panel.SetColumnSpan(_mapInfoLabel, 6);
+ panel.Controls.Add(_mapInfoLabel, 2, 1);
+ return panel;
+ }
+
+ private Control BuildVehicleButtons()
+ {
+ var panel = new FlowLayoutPanel
+ {
+ Dock = DockStyle.Fill,
+ AutoSize = true,
+ WrapContents = false,
+ Padding = new Padding(0, 4, 0, 4)
+ };
+ _addButton = new Button { Text = "添加车辆", AutoSize = true };
+ _addButton.Click += (_, __) => AddVehicleRow();
+ _removeButton = new Button { Text = "删除选中", AutoSize = true };
+ _removeButton.Click += (_, __) => RemoveSelectedVehicle();
+ _releaseButton = new Button { Text = "放行选中车", AutoSize = true };
+ _releaseButton.Click += (_, __) => ReleaseSelected();
+ panel.Controls.Add(_addButton);
+ panel.Controls.Add(_removeButton);
+ panel.Controls.Add(_releaseButton);
+ panel.Controls.Add(new Label
+ {
+ Text = "普通站可叠车。停止点和交管点同时只允许一辆,后车停在上一站保持运行中。仅 Mag_NeedStop 停车等 0x01。",
+ AutoSize = true,
+ Padding = new Padding(12, 8, 0, 0),
+ ForeColor = Color.DimGray
+ });
+ return panel;
+ }
+
+ private DataGridView BuildGrid()
+ {
+ var grid = new DataGridView
+ {
+ Dock = DockStyle.Fill,
+ AllowUserToAddRows = false,
+ AllowUserToDeleteRows = false,
+ RowHeadersVisible = false,
+ SelectionMode = DataGridViewSelectionMode.FullRowSelect,
+ MultiSelect = false,
+ AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill,
+ BackgroundColor = Color.White
+ };
+ grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Name", HeaderText = "名称", FillWeight = 80 });
+ grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Code", HeaderText = "车号", FillWeight = 50 });
+ grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Port", HeaderText = "端口", FillWeight = 55 });
+ grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Start", HeaderText = "起点", FillWeight = 50 });
+ grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "End", HeaderText = "终点", FillWeight = 50 });
+ grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Interval", HeaderText = "间隔ms", FillWeight = 60 });
+ grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Site", HeaderText = "当前站", ReadOnly = true, FillWeight = 55 });
+ grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Node", HeaderText = "节点", ReadOnly = true, FillWeight = 50 });
+ grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "State", HeaderText = "状态", ReadOnly = true, FillWeight = 70 });
+ grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Wait", HeaderText = "等待", ReadOnly = true, FillWeight = 55 });
+ grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Clients", HeaderText = "连接", ReadOnly = true, FillWeight = 45 });
+ grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Path", HeaderText = "循环路径", ReadOnly = true, FillWeight = 160 });
+ return grid;
+ }
+
+ private void LoadConfigToUi()
+ {
+ _mapPathBox.Text = _config.MapPath ?? "";
+ _useTagValueBox.Checked = _config.UseTagValueAsNode;
+ _grid.Rows.Clear();
+ foreach (var vehicle in _config.Vehicles)
+ {
+ _grid.Rows.Add(
+ vehicle.Name,
+ vehicle.VehicleCode,
+ vehicle.ListenPort,
+ vehicle.StartSiteId,
+ vehicle.EndSiteId,
+ vehicle.IntervalMs,
+ "",
+ "",
+ "",
+ "",
+ "",
+ "");
+ }
+ }
+
+ private List ReadVehiclesFromGrid()
+ {
+ var list = new List();
+ foreach (DataGridViewRow row in _grid.Rows)
+ {
+ if (row.IsNewRow)
+ {
+ continue;
+ }
+
+ var code = ToUShort(row.Cells["Code"].Value, 1);
+ list.Add(new MagCarSimVehicleConfig
+ {
+ Name = Convert.ToString(row.Cells["Name"].Value),
+ VehicleCode = code,
+ ListenPort = ToInt(row.Cells["Port"].Value, 5001),
+ StartSiteId = ToInt(row.Cells["Start"].Value, 1),
+ EndSiteId = ToInt(row.Cells["End"].Value, 2),
+ IntervalMs = ToInt(row.Cells["Interval"].Value, _config.DefaultIntervalMs),
+ LoopSiteIds = PreserveLoop(code)
+ });
+ }
+
+ return list;
+ }
+
+ private List PreserveLoop(ushort vehicleCode)
+ {
+ var existing = _config.Vehicles?.Find(v => v.VehicleCode == vehicleCode);
+ if (existing?.LoopSiteIds == null || existing.LoopSiteIds.Count == 0)
+ {
+ return null;
+ }
+
+ return new List(existing.LoopSiteIds);
+ }
+
+ private void BrowseMap()
+ {
+ using var dialog = new OpenFileDialog
+ {
+ Filter = "SimpleLite 地图 (*.json)|*.json|所有文件 (*.*)|*.*",
+ Title = "选择 SimpleLite 地图"
+ };
+ if (!string.IsNullOrWhiteSpace(_mapPathBox.Text) && File.Exists(_mapPathBox.Text))
+ {
+ dialog.InitialDirectory = Path.GetDirectoryName(_mapPathBox.Text);
+ dialog.FileName = Path.GetFileName(_mapPathBox.Text);
+ }
+
+ if (dialog.ShowDialog(this) == DialogResult.OK)
+ {
+ _mapPathBox.Text = dialog.FileName;
+ TryLoadMap(dialog.FileName, silent: false);
+ }
+ }
+
+ private void TryLoadMap(string path, bool silent)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ if (!silent)
+ {
+ MessageBox.Show(this, "请先选择 SimpleLite 地图文件。", "加载地图", MessageBoxButtons.OK, MessageBoxIcon.Information);
+ }
+
+ return;
+ }
+
+ try
+ {
+ _config.UseTagValueAsNode = _useTagValueBox.Checked;
+ var map = _host.LoadMap(path);
+ _mapPathBox.Text = map.FilePath;
+ RefreshSiteList(map);
+ var stops = map.ListNeedStopSiteIds();
+ _mapInfoLabel.Text = $"站点 {map.Sites.Count},路径 {map.Tracks.Count},停止点 {(stops.Count == 0 ? "无(循环将一直过站)" : string.Join(",", stops))}";
+ }
+ catch (Exception ex)
+ {
+ _mapInfoLabel.Text = "地图加载失败";
+ if (!silent)
+ {
+ MessageBox.Show(this, ex.Message, "加载地图失败", MessageBoxButtons.OK, MessageBoxIcon.Error);
+ }
+ else
+ {
+ AppendLog("地图未加载:" + ex.Message);
+ }
+ }
+ }
+
+ private void RefreshSiteList(SimpleLiteMap map)
+ {
+ _siteList.Items.Clear();
+ foreach (var site in map.Sites.Values)
+ {
+ var tag = site.NeedStop ? " [停止]" : "";
+ var node = map.ResolveNode(site.Id);
+ _siteList.Items.Add($"站 {site.Id} node={node}{tag}");
+ }
+ }
+
+ private void StartListen()
+ {
+ try
+ {
+ ApplyGridToHost();
+ _host.StartListen();
+ SetRunningUi(true, looping: false);
+ AppendLog("TCP 监听已启动。请在 SimpleLite 中把 MagCar 的 address/Port/VehicleCode 配成与上表一致。");
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show(this, ex.Message, "启动监听失败", MessageBoxButtons.OK, MessageBoxIcon.Error);
+ }
+ }
+
+ private void StartLoop()
+ {
+ try
+ {
+ if (!_host.IsListening)
+ {
+ ApplyGridToHost();
+ }
+
+ _host.StartLoops();
+ SetRunningUi(true, looping: true);
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show(this, ex.Message, "开始循环失败", MessageBoxButtons.OK, MessageBoxIcon.Error);
+ }
+ }
+
+ private void StopSim()
+ {
+ _host.StopAll();
+ SetRunningUi(false, looping: false);
+ AppendLog("模拟已停止。");
+ RefreshGridStatus();
+ }
+
+ private void ApplyGridToHost()
+ {
+ _config.MapPath = _mapPathBox.Text;
+ _config.UseTagValueAsNode = _useTagValueBox.Checked;
+ _config.Vehicles = ReadVehiclesFromGrid();
+ if (!string.IsNullOrWhiteSpace(_config.MapPath))
+ {
+ TryLoadMap(_config.MapPath, silent: false);
+ }
+
+ _host.RebuildVehicles(_config.Vehicles);
+ }
+
+ private void AddVehicleRow()
+ {
+ if (_host.IsListening)
+ {
+ MessageBox.Show(this, "运行中不能改车辆列表,请先停止模拟。", "添加车辆", MessageBoxButtons.OK, MessageBoxIcon.Information);
+ return;
+ }
+
+ var nextCode = _grid.Rows.Count + 1;
+ _grid.Rows.Add($"AGV-{nextCode}", nextCode, 5000 + nextCode, 1, 6, _config.DefaultIntervalMs, "", "", "", "", "", "");
+ }
+
+ private void RemoveSelectedVehicle()
+ {
+ if (_host.IsListening)
+ {
+ MessageBox.Show(this, "运行中不能改车辆列表,请先停止模拟。", "删除车辆", MessageBoxButtons.OK, MessageBoxIcon.Information);
+ return;
+ }
+
+ if (_grid.CurrentRow == null)
+ {
+ return;
+ }
+
+ _grid.Rows.Remove(_grid.CurrentRow);
+ }
+
+ private void ReleaseSelected()
+ {
+ if (_grid.CurrentRow == null)
+ {
+ MessageBox.Show(this, "请先选中一辆车。", "放行", MessageBoxButtons.OK, MessageBoxIcon.Information);
+ return;
+ }
+
+ var code = ToUShort(_grid.CurrentRow.Cells["Code"].Value, 0);
+ if (!_host.TryRelease(code))
+ {
+ MessageBox.Show(this, $"未找到车号 {code},请先启动监听。", "放行", MessageBoxButtons.OK, MessageBoxIcon.Information);
+ }
+ }
+
+ private void SaveConfig()
+ {
+ try
+ {
+ _config.MapPath = _mapPathBox.Text;
+ _config.UseTagValueAsNode = _useTagValueBox.Checked;
+ _config.Vehicles = ReadVehiclesFromGrid();
+ _config.Save();
+ AppendLog("配置已保存到 " + MagCarSimConfig.SettingsPath);
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show(this, ex.Message, "保存失败", MessageBoxButtons.OK, MessageBoxIcon.Error);
+ }
+ }
+
+ private void SetRunningUi(bool listening, bool looping)
+ {
+ _listenButton.Enabled = !listening;
+ _loopButton.Enabled = !looping;
+ _stopButton.Enabled = listening;
+ _addButton.Enabled = !listening;
+ _removeButton.Enabled = !listening;
+ _grid.ReadOnly = listening;
+ _useTagValueBox.Enabled = !listening;
+ }
+
+ private void RefreshGridStatus()
+ {
+ var snapshots = _host.Snapshots();
+ if (snapshots.Count == 0)
+ {
+ return;
+ }
+
+ for (var i = 0; i < _grid.Rows.Count && i < snapshots.Count; i++)
+ {
+ var snap = snapshots[i];
+ var row = _grid.Rows[i];
+ row.Cells["Site"].Value = snap.CurrentSiteId;
+ row.Cells["Node"].Value = snap.Node;
+ row.Cells["State"].Value = snap.StateText;
+ row.Cells["Wait"].Value = snap.WaitingRelease ? "等放行" : (snap.HoldingForStop ? "让行" : "");
+ row.Cells["Clients"].Value = snap.ClientCount;
+ row.Cells["Path"].Value = snap.PathText;
+ row.DefaultCellStyle.BackColor = snap.WaitingRelease
+ ? Color.FromArgb(255, 236, 179)
+ : snap.HoldingForStop
+ ? Color.FromArgb(207, 232, 255)
+ : Color.White;
+ }
+ }
+
+ private void OnLogMessage(string message)
+ {
+ lock (_logLock)
+ {
+ while (_pendingLogs.Count >= MaxPendingLogs)
+ {
+ _pendingLogs.Dequeue();
+ }
+
+ _pendingLogs.Enqueue(message);
+ }
+
+ if (_logFlushScheduled || !IsHandleCreated)
+ {
+ return;
+ }
+
+ _logFlushScheduled = true;
+ try
+ {
+ BeginInvoke(new Action(FlushLogs));
+ }
+ catch
+ {
+ _logFlushScheduled = false;
+ }
+ }
+
+ private void FlushLogs()
+ {
+ _logFlushScheduled = false;
+ List batch;
+ lock (_logLock)
+ {
+ if (_pendingLogs.Count == 0)
+ {
+ return;
+ }
+
+ batch = new List(_pendingLogs.Count);
+ while (_pendingLogs.Count > 0)
+ {
+ batch.Add(_pendingLogs.Dequeue());
+ }
+ }
+
+ foreach (var line in batch)
+ {
+ _logLineCount++;
+ _logBuffer.AppendLine(line);
+ }
+
+ while (_logLineCount > MaxLogLines)
+ {
+ var text = _logBuffer.ToString();
+ var firstBreak = text.IndexOf('\n');
+ if (firstBreak < 0)
+ {
+ break;
+ }
+
+ _logBuffer.Remove(0, firstBreak + 1);
+ _logLineCount--;
+ }
+
+ _logBox.Text = _logBuffer.ToString();
+ _logBox.SelectionStart = _logBox.TextLength;
+ _logBox.ScrollToCaret();
+ }
+
+ private void AppendLog(string message)
+ {
+ lock (_logLock)
+ {
+ while (_pendingLogs.Count >= MaxPendingLogs)
+ {
+ _pendingLogs.Dequeue();
+ }
+
+ _pendingLogs.Enqueue(message);
+ }
+
+ if (IsHandleCreated)
+ {
+ if (!_logFlushScheduled)
+ {
+ _logFlushScheduled = true;
+ BeginInvoke(new Action(FlushLogs));
+ }
+ }
+ else
+ {
+ FlushLogs();
+ }
+ }
+
+ private static int ToInt(object value, int fallback)
+ {
+ return int.TryParse(Convert.ToString(value), out var n) ? n : fallback;
+ }
+
+ private static ushort ToUShort(object value, ushort fallback)
+ {
+ return ushort.TryParse(Convert.ToString(value), out var n) ? n : fallback;
+ }
+ }
+}
diff --git a/StandardScene.MagCarSimulator/Map/MagControlAreaFile.cs b/StandardScene.MagCarSimulator/Map/MagControlAreaFile.cs
new file mode 100644
index 0000000..9704146
--- /dev/null
+++ b/StandardScene.MagCarSimulator/Map/MagControlAreaFile.cs
@@ -0,0 +1,83 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using Newtonsoft.Json.Linq;
+
+namespace StandardScene.MagCarSimulator
+{
+ /// 读取 mag-control-areas.json,收集触发点与管控区站点。
+ public static class MagControlAreaFile
+ {
+ private static readonly char[] SiteSeparators = { ',', ';', '|', ' ', '\t' };
+
+ public static HashSet LoadSiteIds(string path)
+ {
+ var ids = new HashSet();
+ if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
+ {
+ return ids;
+ }
+
+ var root = JToken.Parse(File.ReadAllText(path));
+ if (root is not JArray rows)
+ {
+ return ids;
+ }
+
+ foreach (var row in rows)
+ {
+ if (row is not JObject obj)
+ {
+ continue;
+ }
+
+ if (obj.Value("IsUse") == false)
+ {
+ continue;
+ }
+
+ AddIfPositive(ids, obj.Value("TriggerSit") ?? 0);
+ foreach (var part in (obj.Value("ControlArea") ?? "").Split(SiteSeparators, StringSplitOptions.RemoveEmptyEntries))
+ {
+ if (int.TryParse(part.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var id))
+ {
+ AddIfPositive(ids, id);
+ }
+ }
+ }
+
+ return ids;
+ }
+
+ public static string GuessPath(string mapPath, string configuredPath)
+ {
+ if (!string.IsNullOrWhiteSpace(configuredPath) && File.Exists(configuredPath))
+ {
+ return Path.GetFullPath(configuredPath);
+ }
+
+ if (string.IsNullOrWhiteSpace(mapPath))
+ {
+ return configuredPath;
+ }
+
+ var mapDir = Path.GetDirectoryName(Path.GetFullPath(mapPath));
+ if (string.IsNullOrWhiteSpace(mapDir))
+ {
+ return configuredPath;
+ }
+
+ var guessed = Path.GetFullPath(Path.Combine(mapDir, "..", "config", "Signal", "mag-control-areas.json"));
+ return File.Exists(guessed) ? guessed : configuredPath;
+ }
+
+ private static void AddIfPositive(HashSet ids, int id)
+ {
+ if (id > 0)
+ {
+ ids.Add(id);
+ }
+ }
+ }
+}
diff --git a/StandardScene.MagCarSimulator/Map/MapPathFinder.cs b/StandardScene.MagCarSimulator/Map/MapPathFinder.cs
new file mode 100644
index 0000000..868f8a8
--- /dev/null
+++ b/StandardScene.MagCarSimulator/Map/MapPathFinder.cs
@@ -0,0 +1,213 @@
+using System.Collections.Generic;
+using System.Text;
+
+namespace StandardScene.MagCarSimulator
+{
+ public static class MapPathFinder
+ {
+ ///
+ /// 生成往返循环:起点→终点→起点(终点不重复,回到起点靠下标回绕)。
+ ///
+ public static List BuildLoop(SimpleLiteMap map, int startSiteId, int endSiteId)
+ {
+ if (map == null)
+ {
+ throw new System.InvalidOperationException("尚未加载地图");
+ }
+
+ if (startSiteId == endSiteId)
+ {
+ throw new System.InvalidOperationException($"起点和终点不能相同(站点 {startSiteId})");
+ }
+
+ if (!map.Sites.ContainsKey(startSiteId))
+ {
+ throw new System.InvalidOperationException($"地图中没有起点 {startSiteId}");
+ }
+
+ if (!map.Sites.ContainsKey(endSiteId))
+ {
+ throw new System.InvalidOperationException($"地图中没有终点 {endSiteId}");
+ }
+
+ var forward = FindPath(map, startSiteId, endSiteId);
+ if (forward == null)
+ {
+ throw new System.InvalidOperationException($"找不到路径 {startSiteId} → {endSiteId}");
+ }
+
+ var back = FindPath(map, endSiteId, startSiteId);
+ if (back == null)
+ {
+ throw new System.InvalidOperationException($"找不到返回路径 {endSiteId} → {startSiteId}");
+ }
+
+ var loop = new List(forward.Count + back.Count);
+ loop.AddRange(forward);
+ for (var i = 1; i < back.Count - 1; i++)
+ {
+ loop.Add(back[i]);
+ }
+
+ if (loop.Count < 2)
+ {
+ throw new System.InvalidOperationException("循环路径至少需要两个站点");
+ }
+
+ return loop;
+ }
+
+ public static List NormalizeLoop(IEnumerable siteIds)
+ {
+ var loop = new List();
+ if (siteIds == null)
+ {
+ return loop;
+ }
+
+ foreach (var id in siteIds)
+ {
+ if (id <= 0)
+ {
+ continue;
+ }
+
+ if (loop.Count > 0 && loop[loop.Count - 1] == id)
+ {
+ continue;
+ }
+
+ loop.Add(id);
+ }
+
+ if (loop.Count >= 2 && loop[0] == loop[loop.Count - 1])
+ {
+ loop.RemoveAt(loop.Count - 1);
+ }
+
+ return loop;
+ }
+
+ public static List ResolveLoop(SimpleLiteMap map, MagCarSimVehicleConfig config)
+ {
+ if (map == null)
+ {
+ throw new System.InvalidOperationException("尚未加载地图");
+ }
+
+ if (config?.LoopSiteIds != null && config.LoopSiteIds.Count >= 3)
+ {
+ var loop = NormalizeLoop(config.LoopSiteIds);
+ if (loop.Count < 3)
+ {
+ throw new System.InvalidOperationException($"{config.Name} 环线站点不足");
+ }
+
+ foreach (var id in loop)
+ {
+ if (!map.Sites.ContainsKey(id))
+ {
+ throw new System.InvalidOperationException($"{config.Name} 环线站点 {id} 不在地图中");
+ }
+ }
+
+ return loop;
+ }
+
+ return BuildLoop(map, config.StartSiteId, config.EndSiteId);
+ }
+
+ public static string FormatPath(IReadOnlyList siteIds, int maxShow = 0)
+ {
+ if (siteIds == null || siteIds.Count == 0)
+ {
+ return "";
+ }
+
+ var show = siteIds.Count;
+ var truncated = false;
+ if (maxShow > 0 && siteIds.Count > maxShow)
+ {
+ show = maxShow;
+ truncated = true;
+ }
+
+ var sb = new StringBuilder();
+ for (var i = 0; i < show; i++)
+ {
+ if (i > 0)
+ {
+ sb.Append("→");
+ }
+
+ sb.Append(siteIds[i]);
+ }
+
+ if (truncated)
+ {
+ sb.Append("→…共").Append(siteIds.Count).Append("站");
+ }
+ else
+ {
+ sb.Append("→").Append(siteIds[0]);
+ }
+
+ return sb.ToString();
+ }
+
+ public static List FindPath(SimpleLiteMap map, int fromSiteId, int toSiteId)
+ {
+ if (fromSiteId == toSiteId)
+ {
+ return new List { fromSiteId };
+ }
+
+ var queue = new Queue();
+ var prev = new Dictionary();
+ var visited = new HashSet { fromSiteId };
+ queue.Enqueue(fromSiteId);
+
+ while (queue.Count > 0)
+ {
+ var current = queue.Dequeue();
+ if (!map.Adjacency.TryGetValue(current, out var nexts))
+ {
+ continue;
+ }
+
+ foreach (var next in nexts)
+ {
+ if (!visited.Add(next))
+ {
+ continue;
+ }
+
+ prev[next] = current;
+ if (next == toSiteId)
+ {
+ return Reconstruct(prev, fromSiteId, toSiteId);
+ }
+
+ queue.Enqueue(next);
+ }
+ }
+
+ return null;
+ }
+
+ private static List Reconstruct(Dictionary prev, int fromSiteId, int toSiteId)
+ {
+ var path = new List();
+ var current = toSiteId;
+ path.Add(current);
+ while (current != fromSiteId)
+ {
+ current = prev[current];
+ path.Add(current);
+ }
+
+ path.Reverse();
+ return path;
+ }
+ }
+}
diff --git a/StandardScene.MagCarSimulator/Map/SimpleLiteMap.cs b/StandardScene.MagCarSimulator/Map/SimpleLiteMap.cs
new file mode 100644
index 0000000..14fcdc2
--- /dev/null
+++ b/StandardScene.MagCarSimulator/Map/SimpleLiteMap.cs
@@ -0,0 +1,324 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using Newtonsoft.Json.Linq;
+
+namespace StandardScene.MagCarSimulator
+{
+ public sealed class SimpleLiteSite
+ {
+ public int Id { get; set; }
+ public string Name { get; set; }
+ public double X { get; set; }
+ public double Y { get; set; }
+ public int? TagValue { get; set; }
+ public bool NeedStop { get; set; }
+ public IReadOnlyDictionary Fields { get; set; }
+ }
+
+ public sealed class SimpleLiteTrack
+ {
+ public int Id { get; set; }
+ public int SiteA { get; set; }
+ public int SiteB { get; set; }
+ public int Direction { get; set; }
+ }
+
+ /// 解析 SimpleLite maps/*.json 的 Sites / Tracks。
+ public sealed class SimpleLiteMap
+ {
+ public const string NeedStopField = "Mag_NeedStop";
+
+ public string FilePath { get; private set; }
+ public string FileName { get; private set; }
+ public IReadOnlyDictionary Sites { get; private set; }
+ public IReadOnlyList Tracks { get; private set; }
+ public IReadOnlyDictionary> Adjacency { get; private set; }
+ private readonly HashSet _singleOccupancySites = new HashSet();
+
+ public static SimpleLiteMap Load(string path, bool useTagValueAsNode)
+ {
+ if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
+ {
+ throw new FileNotFoundException("找不到 SimpleLite 地图文件", path);
+ }
+
+ var root = JObject.Parse(File.ReadAllText(path));
+ var sites = ParseSites(root["Sites"] as JObject);
+ var tracks = ParseTracks(root["Tracks"] as JObject);
+ var adjacency = BuildAdjacency(sites, tracks);
+
+ var map = new SimpleLiteMap
+ {
+ FilePath = Path.GetFullPath(path),
+ FileName = Path.GetFileName(path),
+ Sites = sites,
+ Tracks = tracks,
+ Adjacency = adjacency,
+ UseTagValueAsNode = useTagValueAsNode
+ };
+ map.ResetSingleOccupancyFromStops();
+ return map;
+ }
+
+ public bool UseTagValueAsNode { get; private set; }
+
+ public bool TryGetSite(int siteId, out SimpleLiteSite site)
+ {
+ return Sites.TryGetValue(siteId, out site);
+ }
+
+ public ushort ResolveNode(int siteId)
+ {
+ if (!Sites.TryGetValue(siteId, out var site))
+ {
+ return (ushort)Math.Clamp(siteId, 0, ushort.MaxValue);
+ }
+
+ if (UseTagValueAsNode && site.TagValue.HasValue && site.TagValue.Value >= 0)
+ {
+ return (ushort)Math.Clamp(site.TagValue.Value, 0, ushort.MaxValue);
+ }
+
+ return (ushort)Math.Clamp(site.Id, 0, ushort.MaxValue);
+ }
+
+ public bool NeedStop(int siteId)
+ {
+ return Sites.TryGetValue(siteId, out var site) && site.NeedStop;
+ }
+
+ /// 停止点或交管点:同时只允许一辆。普通站可叠车。
+ public bool SingleOccupancy(int siteId)
+ {
+ return _singleOccupancySites.Contains(siteId);
+ }
+
+ public IReadOnlyList ListSingleOccupancySiteIds()
+ {
+ var list = new List(_singleOccupancySites);
+ list.Sort();
+ return list;
+ }
+
+ public void ResetSingleOccupancyFromStops()
+ {
+ _singleOccupancySites.Clear();
+ foreach (var site in Sites.Values)
+ {
+ if (site.NeedStop)
+ {
+ _singleOccupancySites.Add(site.Id);
+ }
+ }
+ }
+
+ public void AddSingleOccupancySites(IEnumerable siteIds)
+ {
+ if (siteIds == null)
+ {
+ return;
+ }
+
+ foreach (var id in siteIds)
+ {
+ if (id > 0 && Sites.ContainsKey(id))
+ {
+ _singleOccupancySites.Add(id);
+ }
+ }
+ }
+
+ public IReadOnlyList ListNeedStopSiteIds()
+ {
+ var list = new List();
+ foreach (var site in Sites.Values)
+ {
+ if (site.NeedStop)
+ {
+ list.Add(site.Id);
+ }
+ }
+
+ list.Sort();
+ return list;
+ }
+
+ private static Dictionary ParseSites(JObject sitesNode)
+ {
+ var result = new Dictionary();
+ if (sitesNode == null)
+ {
+ return result;
+ }
+
+ foreach (var property in sitesNode.Properties())
+ {
+ if (property.Value is not JObject obj)
+ {
+ continue;
+ }
+
+ var id = obj.Value("id") ?? ParseInt(property.Name);
+ if (id <= 0)
+ {
+ continue;
+ }
+
+ var fields = ReadFields(obj["fields"] as JObject);
+ var site = new SimpleLiteSite
+ {
+ Id = id,
+ Name = obj.Value("name") ?? "",
+ X = obj.Value("x") ?? 0,
+ Y = obj.Value("y") ?? 0,
+ TagValue = TryReadInt(fields, "TagValue"),
+ NeedStop = IsNeedStop(fields),
+ Fields = fields
+ };
+ result[id] = site;
+ }
+
+ return result;
+ }
+
+ private static List ParseTracks(JObject tracksNode)
+ {
+ var result = new List();
+ if (tracksNode == null)
+ {
+ return result;
+ }
+
+ foreach (var property in tracksNode.Properties())
+ {
+ if (property.Value is not JObject obj)
+ {
+ continue;
+ }
+
+ var siteA = obj.Value("siteA") ?? obj.Value("_siteA") ?? 0;
+ var siteB = obj.Value("siteB") ?? obj.Value("_siteB") ?? 0;
+ if (siteA <= 0 || siteB <= 0)
+ {
+ continue;
+ }
+
+ result.Add(new SimpleLiteTrack
+ {
+ Id = obj.Value("id") ?? ParseInt(property.Name),
+ SiteA = siteA,
+ SiteB = siteB,
+ Direction = obj.Value("direction") ?? 0
+ });
+ }
+
+ return result;
+ }
+
+ private static Dictionary> BuildAdjacency(
+ Dictionary sites,
+ List tracks)
+ {
+ var mutable = new Dictionary>();
+ foreach (var id in sites.Keys)
+ {
+ mutable[id] = new List();
+ }
+
+ foreach (var track in tracks)
+ {
+ AddEdge(mutable, track.SiteA, track.SiteB, track.Direction);
+ }
+
+ var result = new Dictionary>();
+ foreach (var pair in mutable)
+ {
+ result[pair.Key] = pair.Value;
+ }
+
+ return result;
+ }
+
+ private static void AddEdge(Dictionary> graph, int from, int to, int direction)
+ {
+ // 0 双向;1 仅 A→B;2 仅 B→A。其它值按双向处理。
+ var aToB = direction != 2;
+ var bToA = direction != 1;
+ if (aToB)
+ {
+ AddUnique(graph, from, to);
+ }
+
+ if (bToA)
+ {
+ AddUnique(graph, to, from);
+ }
+ }
+
+ private static void AddUnique(Dictionary> graph, int from, int to)
+ {
+ if (!graph.TryGetValue(from, out var list))
+ {
+ list = new List();
+ graph[from] = list;
+ }
+
+ if (!list.Contains(to))
+ {
+ list.Add(to);
+ }
+ }
+
+ private static Dictionary ReadFields(JObject fields)
+ {
+ var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ if (fields == null)
+ {
+ return result;
+ }
+
+ foreach (var property in fields.Properties())
+ {
+ result[property.Name] = property.Value?.Type == JTokenType.Null
+ ? ""
+ : property.Value.ToString();
+ }
+
+ return result;
+ }
+
+ private static bool IsNeedStop(Dictionary fields)
+ {
+ if (!fields.TryGetValue(NeedStopField, out var raw))
+ {
+ return false;
+ }
+
+ raw = (raw ?? "").Trim();
+ return raw.Equals("true", StringComparison.OrdinalIgnoreCase)
+ || raw.Equals("1", StringComparison.OrdinalIgnoreCase)
+ || raw.Equals("yes", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static int? TryReadInt(Dictionary fields, string key)
+ {
+ if (!fields.TryGetValue(key, out var raw) || string.IsNullOrWhiteSpace(raw))
+ {
+ return null;
+ }
+
+ return int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)
+ ? value
+ : (int?)null;
+ }
+
+ private static int ParseInt(string text)
+ {
+ return int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)
+ ? value
+ : 0;
+ }
+ }
+}
diff --git a/StandardScene.MagCarSimulator/Net/MagCarSimTcpServer.cs b/StandardScene.MagCarSimulator/Net/MagCarSimTcpServer.cs
new file mode 100644
index 0000000..3c638f0
--- /dev/null
+++ b/StandardScene.MagCarSimulator/Net/MagCarSimTcpServer.cs
@@ -0,0 +1,242 @@
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Net.Sockets;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace StandardScene.MagCarSimulator
+{
+ public sealed class MagCarSimTcpServer : IDisposable
+ {
+ private readonly IPAddress _address;
+ private readonly int _port;
+ private readonly Func _handler;
+ private readonly object _clientsLock = new object();
+ private readonly HashSet _clients = new HashSet();
+ private TcpListener _listener;
+ private CancellationTokenSource _cts;
+ private int _clientCount;
+ private bool _running;
+
+ public MagCarSimTcpServer(IPAddress address, int port, Func handler)
+ {
+ _address = address ?? IPAddress.Any;
+ _port = port;
+ _handler = handler ?? throw new ArgumentNullException(nameof(handler));
+ }
+
+ public bool IsRunning => _running;
+ public int ClientCount => Volatile.Read(ref _clientCount);
+
+ public void Start()
+ {
+ if (_running)
+ {
+ return;
+ }
+
+ _cts = new CancellationTokenSource();
+ _listener = new TcpListener(_address, _port);
+ _listener.Server.NoDelay = true;
+ _listener.Start();
+ _running = true;
+ _ = Task.Run(() => AcceptLoop(_cts.Token));
+ }
+
+ public void Dispose()
+ {
+ _running = false;
+ try
+ {
+ _cts?.Cancel();
+ }
+ catch
+ {
+ }
+
+ try
+ {
+ _listener?.Stop();
+ }
+ catch
+ {
+ }
+
+ List clients;
+ lock (_clientsLock)
+ {
+ clients = new List(_clients);
+ _clients.Clear();
+ }
+
+ foreach (var client in clients)
+ {
+ try
+ {
+ client.Close();
+ }
+ catch
+ {
+ }
+ }
+
+ _cts?.Dispose();
+ _cts = null;
+ _listener = null;
+ }
+
+ private async Task AcceptLoop(CancellationToken token)
+ {
+ while (!token.IsCancellationRequested)
+ {
+ TcpClient client;
+ try
+ {
+ client = await _listener.AcceptTcpClientAsync(token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ return;
+ }
+ catch (ObjectDisposedException)
+ {
+ return;
+ }
+ catch (SocketException)
+ {
+ if (token.IsCancellationRequested)
+ {
+ return;
+ }
+
+ continue;
+ }
+
+ lock (_clientsLock)
+ {
+ _clients.Add(client);
+ }
+
+ _ = Task.Run(() => HandleClient(client, token), token);
+ }
+ }
+
+ private void HandleClient(TcpClient client, CancellationToken token)
+ {
+ Interlocked.Increment(ref _clientCount);
+ try
+ {
+ client.NoDelay = true;
+ client.ReceiveTimeout = 0;
+ using (var stream = client.GetStream())
+ {
+ var buffer = new byte[MagCarSimProtocol.FrameLength];
+ while (!token.IsCancellationRequested)
+ {
+ if (!ReadExact(stream, buffer, token))
+ {
+ return;
+ }
+
+ byte[] response;
+ try
+ {
+ response = _handler(buffer);
+ }
+ catch (Exception ex)
+ {
+ MagCarSimLog.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] TCP 处理异常: {ex.Message}");
+ return;
+ }
+
+ if (response == null || response.Length != MagCarSimProtocol.FrameLength)
+ {
+ continue;
+ }
+
+ stream.Write(response, 0, response.Length);
+ stream.Flush();
+ }
+ }
+ }
+ catch (Exception ex) when (IsBenign(ex))
+ {
+ }
+ catch (Exception ex)
+ {
+ MagCarSimLog.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] TCP 连接异常: {ex.Message}");
+ }
+ finally
+ {
+ lock (_clientsLock)
+ {
+ _clients.Remove(client);
+ }
+
+ try
+ {
+ client.Dispose();
+ }
+ catch
+ {
+ }
+
+ Interlocked.Decrement(ref _clientCount);
+ }
+ }
+
+ private static bool ReadExact(NetworkStream stream, byte[] buffer, CancellationToken token)
+ {
+ var offset = 0;
+ while (offset < buffer.Length)
+ {
+ token.ThrowIfCancellationRequested();
+ int read;
+ try
+ {
+ read = stream.Read(buffer, offset, buffer.Length - offset);
+ }
+ catch (Exception ex) when (IsBenign(ex))
+ {
+ return false;
+ }
+
+ if (read == 0)
+ {
+ return false;
+ }
+
+ offset += read;
+ }
+
+ return true;
+ }
+
+ private static bool IsBenign(Exception ex)
+ {
+ for (var current = ex; current != null; current = current.InnerException)
+ {
+ if (current is ObjectDisposedException || current is OperationCanceledException)
+ {
+ return true;
+ }
+
+ if (current is SocketException socketEx)
+ {
+ switch (socketEx.SocketErrorCode)
+ {
+ case SocketError.OperationAborted:
+ case SocketError.Interrupted:
+ case SocketError.ConnectionAborted:
+ case SocketError.ConnectionReset:
+ case SocketError.Shutdown:
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+ }
+}
diff --git a/StandardScene.MagCarSimulator/Program.cs b/StandardScene.MagCarSimulator/Program.cs
new file mode 100644
index 0000000..1cb796a
--- /dev/null
+++ b/StandardScene.MagCarSimulator/Program.cs
@@ -0,0 +1,15 @@
+using System;
+using System.Windows.Forms;
+
+namespace StandardScene.MagCarSimulator
+{
+ internal static class Program
+ {
+ [STAThread]
+ private static void Main()
+ {
+ ApplicationConfiguration.Initialize();
+ Application.Run(new MainForm());
+ }
+ }
+}
diff --git a/StandardScene.MagCarSimulator/Protocol/MagCarSimProtocol.cs b/StandardScene.MagCarSimulator/Protocol/MagCarSimProtocol.cs
new file mode 100644
index 0000000..8713d95
--- /dev/null
+++ b/StandardScene.MagCarSimulator/Protocol/MagCarSimProtocol.cs
@@ -0,0 +1,144 @@
+using System;
+
+namespace StandardScene.MagCarSimulator
+{
+ ///
+ /// FASS 1.0 32 字节帧,字段布局与 MagCar.MagCarReport 对齐。
+ ///
+ public static class MagCarSimProtocol
+ {
+ public const int FrameLength = 32;
+ public const byte Begin = 0xBB;
+ public const byte End = 0xEE;
+
+ public const byte CmdQueryState = 0x00;
+ public const byte CmdStart = 0x01;
+ public const byte CmdStop = 0x02;
+ public const byte CmdSpeed = 0x03;
+ public const byte CmdAngle = 0x04;
+ public const byte CmdLegacyNode = 0x05;
+ public const byte CmdTask = 0xA1;
+
+ public const byte StateIdle = 0;
+ public const byte StateRunning = 1;
+ public const byte StateStopping = 2;
+ public const byte StateCharging = 3;
+
+ public static bool TryParse(byte[] bytes, out MagCarSimRequest request)
+ {
+ request = null;
+ if (bytes == null || bytes.Length < FrameLength)
+ {
+ return false;
+ }
+
+ if (bytes[0] != Begin || bytes[FrameLength - 1] != End)
+ {
+ return false;
+ }
+
+ request = new MagCarSimRequest
+ {
+ Command = bytes[1],
+ VehicleCode = ReadUInt16(bytes, 2),
+ Data0 = ReadUInt16(bytes, 4),
+ Data1 = ReadUInt16(bytes, 6)
+ };
+ return true;
+ }
+
+ public static byte[] BuildStatus(
+ byte command,
+ ushort vehicleCode,
+ ushort node,
+ byte state,
+ byte charge,
+ float current,
+ float voltage,
+ byte speed,
+ ushort angle,
+ byte task,
+ byte lift,
+ byte roll)
+ {
+ var bytes = new byte[FrameLength];
+ bytes[0] = Begin;
+ bytes[1] = command;
+ WriteUInt16(bytes, 2, vehicleCode);
+ WriteUInt16(bytes, 4, node);
+ WriteUInt16(bytes, 25, angle);
+ WriteSingle(bytes, 16, current);
+ WriteSingle(bytes, 20, voltage);
+ bytes[14] = state;
+ bytes[15] = charge;
+ bytes[24] = speed;
+ bytes[27] = task;
+ bytes[28] = lift;
+ bytes[29] = roll;
+ bytes[31] = End;
+ return bytes;
+ }
+
+ public static string CommandName(byte command)
+ {
+ switch (command)
+ {
+ case CmdQueryState: return "Query(0x00)";
+ case CmdStart: return "Start(0x01)";
+ case CmdStop: return "Stop(0x02)";
+ case CmdSpeed: return "Speed(0x03)";
+ case CmdAngle: return "Angle(0x04)";
+ case CmdLegacyNode: return "LegacyNode(0x05)";
+ case CmdTask: return "Task(0xA1)";
+ default: return $"Unknown(0x{command:X2})";
+ }
+ }
+
+ public static string StateText(byte state)
+ {
+ switch (state)
+ {
+ case StateIdle: return "未准备";
+ case StateRunning: return "运行中";
+ case StateStopping: return "停止中";
+ case StateCharging: return "充电中";
+ default: return $"未知({state})";
+ }
+ }
+
+ public static string ToHex(byte[] bytes)
+ {
+ return bytes == null ? string.Empty : BitConverter.ToString(bytes).Replace("-", " ");
+ }
+
+ public static ushort ReadUInt16(byte[] bytes, int offset)
+ {
+ return (ushort)((bytes[offset] << 8) | bytes[offset + 1]);
+ }
+
+ public static void WriteUInt16(byte[] bytes, int offset, ushort value)
+ {
+ bytes[offset] = (byte)(value >> 8);
+ bytes[offset + 1] = (byte)(value & 0xFF);
+ }
+
+ public static void WriteSingle(byte[] bytes, int offset, float value)
+ {
+ var raw = BitConverter.GetBytes(value);
+ if (BitConverter.IsLittleEndian)
+ {
+ Array.Reverse(raw);
+ }
+
+ Buffer.BlockCopy(raw, 0, bytes, offset, 4);
+ }
+ }
+
+ public sealed class MagCarSimRequest
+ {
+ public byte Command { get; set; }
+ public ushort VehicleCode { get; set; }
+ public ushort Data0 { get; set; }
+ public ushort Data1 { get; set; }
+ }
+}
diff --git a/StandardScene.MagCarSimulator/Runtime/MagCarSimConfig.cs b/StandardScene.MagCarSimulator/Runtime/MagCarSimConfig.cs
new file mode 100644
index 0000000..27d541f
--- /dev/null
+++ b/StandardScene.MagCarSimulator/Runtime/MagCarSimConfig.cs
@@ -0,0 +1,114 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using Newtonsoft.Json;
+
+namespace StandardScene.MagCarSimulator
+{
+ public sealed class MagCarSimVehicleConfig
+ {
+ public string Name { get; set; } = "AGV";
+ public ushort VehicleCode { get; set; } = 1;
+ public int ListenPort { get; set; } = 5001;
+ public int StartSiteId { get; set; } = 1;
+ public int EndSiteId { get; set; } = 2;
+ public int IntervalMs { get; set; } = 2000;
+
+ /// 显式环线站点序列(不含回到起点的重复点)。有值时优先于起点/终点寻路。
+ public List LoopSiteIds { get; set; }
+ }
+
+ public sealed class MagCarSimConfig
+ {
+ public string MapPath { get; set; } = "";
+ public bool UseTagValueAsNode { get; set; }
+ public string ListenAddress { get; set; } = "0.0.0.0";
+ public bool LogRawFrames { get; set; }
+ public bool LogQueries { get; set; }
+
+ /// 文件日志根目录(相对 exe 或绝对路径),默认 log。
+ public string LogDirectory { get; set; } = "log";
+
+ /// 是否写入 log 目录下的 magcar-sim_yyyyMMdd.log。
+ public bool EnableFileLog { get; set; } = true;
+
+ public int DefaultIntervalMs { get; set; } = 2000;
+
+ /// 同一环线后车相对前车的发车间隔。≤0 时用该车 IntervalMs。
+ public int LaunchStaggerMs { get; set; }
+
+ /// 磁条交管表。空则按地图目录推断 SimpleLite/config/Signal/mag-control-areas.json。
+ public string ControlAreasPath { get; set; }
+ public List Vehicles { get; set; } = new List();
+
+ public static string SettingsPath => Path.Combine(AppContext.BaseDirectory, "appsettings.json");
+
+ public static MagCarSimConfig Load(string path = null)
+ {
+ path ??= SettingsPath;
+ if (!File.Exists(path))
+ {
+ return CreateDefault();
+ }
+
+ var json = File.ReadAllText(path);
+ var config = JsonConvert.DeserializeObject(json) ?? CreateDefault();
+ if (config.Vehicles == null || config.Vehicles.Count == 0)
+ {
+ config.Vehicles = CreateDefault().Vehicles;
+ }
+
+ if (config.DefaultIntervalMs <= 0)
+ {
+ config.DefaultIntervalMs = 2000;
+ }
+
+ foreach (var vehicle in config.Vehicles)
+ {
+ if (vehicle.IntervalMs <= 0)
+ {
+ vehicle.IntervalMs = config.DefaultIntervalMs;
+ }
+
+ if (string.IsNullOrWhiteSpace(vehicle.Name))
+ {
+ vehicle.Name = $"AGV-{vehicle.VehicleCode}";
+ }
+
+ if (vehicle.LoopSiteIds != null && vehicle.LoopSiteIds.Count > 0)
+ {
+ vehicle.LoopSiteIds = MapPathFinder.NormalizeLoop(vehicle.LoopSiteIds);
+ }
+ }
+
+ return config;
+ }
+
+ public void Save(string path = null)
+ {
+ path ??= SettingsPath;
+ var json = JsonConvert.SerializeObject(this, Formatting.Indented);
+ File.WriteAllText(path, json);
+ }
+
+ public static MagCarSimConfig CreateDefault()
+ {
+ return new MagCarSimConfig
+ {
+ DefaultIntervalMs = 2000,
+ Vehicles =
+ {
+ new MagCarSimVehicleConfig
+ {
+ Name = "AGV-1",
+ VehicleCode = 1,
+ ListenPort = 5001,
+ StartSiteId = 1,
+ EndSiteId = 6,
+ IntervalMs = 2000
+ }
+ }
+ };
+ }
+ }
+}
diff --git a/StandardScene.MagCarSimulator/Runtime/MagCarSimHost.cs b/StandardScene.MagCarSimulator/Runtime/MagCarSimHost.cs
new file mode 100644
index 0000000..8792e1e
--- /dev/null
+++ b/StandardScene.MagCarSimulator/Runtime/MagCarSimHost.cs
@@ -0,0 +1,335 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+
+namespace StandardScene.MagCarSimulator
+{
+ public sealed class MagCarSimHost : IDisposable
+ {
+ private readonly object _syncRoot = new object();
+ private readonly List _vehicles = new List();
+ private Timer _timer;
+ private SimpleLiteMap _map;
+ private int _tickBusy;
+
+ public MagCarSimHost(MagCarSimConfig config)
+ {
+ Config = config ?? throw new ArgumentNullException(nameof(config));
+ }
+
+ public MagCarSimConfig Config { get; }
+ public SimpleLiteMap Map => _map;
+ public bool IsListening { get; private set; }
+ public bool IsLooping { get; private set; }
+
+ public IReadOnlyList Vehicles
+ {
+ get
+ {
+ lock (_syncRoot)
+ {
+ return _vehicles.ToList();
+ }
+ }
+ }
+
+ public SimpleLiteMap LoadMap(string path)
+ {
+ var map = SimpleLiteMap.Load(path, Config.UseTagValueAsNode);
+ var controlPath = MagControlAreaFile.GuessPath(map.FilePath, Config.ControlAreasPath);
+ var controlSites = MagControlAreaFile.LoadSiteIds(controlPath);
+ map.AddSingleOccupancySites(controlSites);
+ lock (_syncRoot)
+ {
+ _map = map;
+ Config.MapPath = map.FilePath;
+ if (!string.IsNullOrWhiteSpace(controlPath))
+ {
+ Config.ControlAreasPath = controlPath;
+ }
+
+ foreach (var vehicle in _vehicles)
+ {
+ vehicle.AttachMap(map);
+ }
+ }
+
+ var exclusive = map.ListSingleOccupancySiteIds();
+ MagCarSimLog.WriteLine(
+ $"[{DateTime.Now:HH:mm:ss.fff}] 已加载地图 {map.FileName},站点 {map.Sites.Count},路径 {map.Tracks.Count},停止/交管点 {(exclusive.Count == 0 ? "无" : string.Join(",", exclusive))}");
+ return map;
+ }
+
+ public void RebuildVehicles(IEnumerable configs)
+ {
+ if (IsListening)
+ {
+ throw new InvalidOperationException("请先停止模拟再改车辆列表");
+ }
+
+ lock (_syncRoot)
+ {
+ DisposeVehiclesUnlocked();
+ foreach (var config in configs ?? Array.Empty())
+ {
+ if (config == null)
+ {
+ continue;
+ }
+
+ var vehicle = new MagCarSimVehicle(Clone(config), Config);
+ if (_map != null)
+ {
+ vehicle.AttachMap(_map);
+ }
+
+ _vehicles.Add(vehicle);
+ }
+ }
+ }
+
+ public void StartListen()
+ {
+ List vehicles;
+ SimpleLiteMap map;
+ lock (_syncRoot)
+ {
+ if (IsListening)
+ {
+ return;
+ }
+
+ if (_map == null)
+ {
+ throw new InvalidOperationException("请先加载 SimpleLite 地图");
+ }
+
+ if (_vehicles.Count == 0)
+ {
+ throw new InvalidOperationException("没有可启动的车辆");
+ }
+
+ EnsureUniquePortsUnlocked();
+ vehicles = _vehicles.ToList();
+ map = _map;
+ EnsureTimerUnlocked();
+ IsListening = true;
+ }
+
+ foreach (var vehicle in vehicles)
+ {
+ vehicle.AttachMap(map);
+ vehicle.StartListen();
+ }
+ }
+
+ public void StartLoops()
+ {
+ if (!IsListening)
+ {
+ StartListen();
+ }
+
+ List vehicles;
+ lock (_syncRoot)
+ {
+ vehicles = _vehicles.ToList();
+ IsLooping = true;
+ }
+
+ var now = DateTime.Now;
+ foreach (var group in vehicles.GroupBy(RingKey).OrderBy(g => g.Key))
+ {
+ var members = group.OrderBy(v => v.VehicleCode).ToList();
+ var dispatchAt = now;
+ foreach (var vehicle in members)
+ {
+ vehicle.StartLoop(dispatchAt);
+ dispatchAt = dispatchAt.AddMilliseconds(StaggerMs(vehicle));
+ }
+
+ MagCarSimLog.WriteLine(
+ $"[{DateTime.Now:HH:mm:ss.fff}] 同环依次发车 {members[0].Name} 等 {members.Count} 台,间隔 {StaggerMs(members[0])}ms");
+ }
+ }
+
+ public void StopAll()
+ {
+ Timer timer;
+ List vehicles;
+ lock (_syncRoot)
+ {
+ timer = _timer;
+ _timer = null;
+ IsListening = false;
+ IsLooping = false;
+ vehicles = _vehicles.ToList();
+ }
+
+ try
+ {
+ timer?.Dispose();
+ }
+ catch
+ {
+ }
+
+ foreach (var vehicle in vehicles)
+ {
+ vehicle.StopLoop();
+ vehicle.StopListen();
+ }
+ }
+
+ public bool TryRelease(ushort vehicleCode)
+ {
+ lock (_syncRoot)
+ {
+ var vehicle = _vehicles.FirstOrDefault(v => v.VehicleCode == vehicleCode);
+ if (vehicle == null)
+ {
+ return false;
+ }
+
+ vehicle.Release();
+ return true;
+ }
+ }
+
+ public List Snapshots()
+ {
+ lock (_syncRoot)
+ {
+ return _vehicles.Select(v => v.Snapshot()).ToList();
+ }
+ }
+
+ public void Dispose()
+ {
+ StopAll();
+ lock (_syncRoot)
+ {
+ DisposeVehiclesUnlocked();
+ }
+ }
+
+ private void EnsureTimerUnlocked()
+ {
+ _timer ??= new Timer(_ => OnTick(), null, 50, 50);
+ }
+
+ private void OnTick()
+ {
+ if (Interlocked.Exchange(ref _tickBusy, 1) != 0)
+ {
+ return;
+ }
+
+ try
+ {
+ List vehicles;
+ SimpleLiteMap map;
+ lock (_syncRoot)
+ {
+ vehicles = _vehicles.ToList();
+ map = _map;
+ }
+
+ var occupancy = SnapshotExclusiveOccupancy(map, vehicles);
+ var now = DateTime.Now;
+ foreach (var vehicle in vehicles)
+ {
+ vehicle.Tick(now, occupancy);
+ }
+ }
+ finally
+ {
+ Interlocked.Exchange(ref _tickBusy, 0);
+ }
+ }
+
+ private void EnsureUniquePortsUnlocked()
+ {
+ var seen = new HashSet();
+ foreach (var vehicle in _vehicles)
+ {
+ if (!seen.Add(vehicle.ListenPort))
+ {
+ throw new InvalidOperationException($"端口 {vehicle.ListenPort} 被多辆车占用");
+ }
+ }
+ }
+
+ private static Dictionary SnapshotExclusiveOccupancy(
+ SimpleLiteMap map,
+ IEnumerable vehicles)
+ {
+ var occupancy = new Dictionary();
+ if (map == null)
+ {
+ return occupancy;
+ }
+
+ foreach (var vehicle in vehicles)
+ {
+ if (vehicle.CurrentSiteId <= 0 ||
+ !map.SingleOccupancy(vehicle.CurrentSiteId) ||
+ occupancy.ContainsKey(vehicle.CurrentSiteId))
+ {
+ continue;
+ }
+
+ occupancy[vehicle.CurrentSiteId] = vehicle.VehicleCode;
+ }
+
+ return occupancy;
+ }
+
+ private int StaggerMs(MagCarSimVehicle vehicle)
+ {
+ if (Config.LaunchStaggerMs > 0)
+ {
+ return Math.Max(200, Config.LaunchStaggerMs);
+ }
+
+ var interval = vehicle.Config.IntervalMs > 0 ? vehicle.Config.IntervalMs : Config.DefaultIntervalMs;
+ return Math.Max(200, interval);
+ }
+
+ private static string RingKey(MagCarSimVehicle vehicle)
+ {
+ var ids = vehicle.Config.LoopSiteIds;
+ if (ids != null && ids.Count > 0)
+ {
+ return string.Join("-", ids);
+ }
+
+ return $"s{vehicle.Config.StartSiteId}-e{vehicle.Config.EndSiteId}";
+ }
+
+ private void DisposeVehiclesUnlocked()
+ {
+ foreach (var vehicle in _vehicles)
+ {
+ vehicle.Dispose();
+ }
+
+ _vehicles.Clear();
+ }
+
+ private static MagCarSimVehicleConfig Clone(MagCarSimVehicleConfig source)
+ {
+ return new MagCarSimVehicleConfig
+ {
+ Name = source.Name,
+ VehicleCode = source.VehicleCode,
+ ListenPort = source.ListenPort,
+ StartSiteId = source.StartSiteId,
+ EndSiteId = source.EndSiteId,
+ IntervalMs = source.IntervalMs,
+ LoopSiteIds = source.LoopSiteIds == null ? null : new List(source.LoopSiteIds)
+ };
+ }
+ }
+}
diff --git a/StandardScene.MagCarSimulator/Runtime/MagCarSimVehicle.cs b/StandardScene.MagCarSimulator/Runtime/MagCarSimVehicle.cs
new file mode 100644
index 0000000..0282fbf
--- /dev/null
+++ b/StandardScene.MagCarSimulator/Runtime/MagCarSimVehicle.cs
@@ -0,0 +1,499 @@
+using System;
+using System.Collections.Generic;
+using System.Net;
+
+namespace StandardScene.MagCarSimulator
+{
+ public sealed class MagCarSimVehicleSnapshot
+ {
+ public string Name { get; set; }
+ public ushort VehicleCode { get; set; }
+ public int ListenPort { get; set; }
+ public int StartSiteId { get; set; }
+ public int EndSiteId { get; set; }
+ public int IntervalMs { get; set; }
+ public int CurrentSiteId { get; set; }
+ public ushort Node { get; set; }
+ public byte State { get; set; }
+ public string StateText { get; set; }
+ public bool WaitingRelease { get; set; }
+ public bool HoldingForStop { get; set; }
+ public bool Looping { get; set; }
+ public bool Listening { get; set; }
+ public int ClientCount { get; set; }
+ public string PathText { get; set; }
+ }
+
+ ///
+ /// 单车循环:普通站可叠车;停止点与交管点同时只允许一辆,后车停在上一站并保持运行中;仅 Mag_NeedStop 停车等 0x01。
+ ///
+ public sealed class MagCarSimVehicle : IDisposable
+ {
+ private readonly object _syncRoot = new object();
+ private readonly MagCarSimConfig _hostConfig;
+ private MagCarSimTcpServer _server;
+ private SimpleLiteMap _map;
+ private List _loopSiteIds = new List();
+ private int _index;
+ private DateTime _nextHopAt = DateTime.MaxValue;
+ private DateTime _dispatchAt = DateTime.MinValue;
+ private DateTime _lastHoldLogAt = DateTime.MinValue;
+ private bool _looping;
+ private bool _dispatched;
+ private bool _waitingRelease;
+ private bool _pausedByStopCommand;
+ private bool _holdingForStop;
+
+ public MagCarSimVehicle(MagCarSimVehicleConfig config, MagCarSimConfig hostConfig)
+ {
+ Config = config ?? throw new ArgumentNullException(nameof(config));
+ _hostConfig = hostConfig ?? throw new ArgumentNullException(nameof(hostConfig));
+ Name = string.IsNullOrWhiteSpace(config.Name) ? $"AGV-{config.VehicleCode}" : config.Name;
+ Charge = 100;
+ Voltage = 24f;
+ Speed = 50;
+ }
+
+ public MagCarSimVehicleConfig Config { get; }
+ public string Name { get; }
+ public ushort VehicleCode => Config.VehicleCode;
+ public int ListenPort => Config.ListenPort;
+ public int CurrentSiteId { get; private set; }
+ public ushort Node { get; private set; }
+ public byte State { get; private set; }
+ public byte Charge { get; private set; }
+ public float Current { get; private set; }
+ public float Voltage { get; private set; }
+ public byte Speed { get; private set; }
+ public ushort Angle { get; private set; }
+ public byte Task { get; private set; }
+ public byte Lift { get; private set; }
+ public byte Roll { get; private set; }
+ public bool WaitingRelease => _waitingRelease;
+ public bool HoldingForStop => _holdingForStop;
+ public bool Looping => _looping;
+
+ public bool Listening => _server != null && _server.IsRunning;
+
+ public void AttachMap(SimpleLiteMap map)
+ {
+ lock (_syncRoot)
+ {
+ _map = map;
+ if (!_looping)
+ {
+ PlaceAtStartUnlocked();
+ }
+ }
+ }
+
+ public void StartListen()
+ {
+ MagCarSimTcpServer server;
+ lock (_syncRoot)
+ {
+ if (_server != null && _server.IsRunning)
+ {
+ return;
+ }
+
+ _server?.Dispose();
+ var address = string.IsNullOrWhiteSpace(_hostConfig.ListenAddress)
+ ? "0.0.0.0"
+ : _hostConfig.ListenAddress;
+ server = new MagCarSimTcpServer(IPAddress.Parse(address), Config.ListenPort, HandleFrame);
+ _server = server;
+ PlaceAtStartUnlocked();
+ Log($"监听 {address}:{Config.ListenPort},当前站={CurrentSiteId} node={Node} {MagCarSimProtocol.StateText(State)}");
+ }
+
+ server.Start();
+ }
+
+ public void StopListen()
+ {
+ MagCarSimTcpServer server;
+ lock (_syncRoot)
+ {
+ server = _server;
+ _server = null;
+ }
+
+ server?.Dispose();
+ }
+
+ public void StartLoop(DateTime dispatchAt)
+ {
+ lock (_syncRoot)
+ {
+ if (_map == null)
+ {
+ throw new InvalidOperationException($"{Name} 尚未加载地图");
+ }
+
+ _loopSiteIds = MapPathFinder.ResolveLoop(_map, Config);
+ _index = 0;
+ if (Config.StartSiteId > 0)
+ {
+ var found = _loopSiteIds.IndexOf(Config.StartSiteId);
+ if (found >= 0)
+ {
+ _index = found;
+ }
+ }
+
+ _looping = true;
+ _dispatched = false;
+ _holdingForStop = false;
+ _pausedByStopCommand = false;
+ _waitingRelease = false;
+ _dispatchAt = dispatchAt;
+ PlaceAtStartUnlocked();
+ // 待命阶段若起点是停止点,先报运行中,避免交管误放行并占住 pending,导致真发车后长时间不放。
+ if (_map != null && _map.NeedStop(CurrentSiteId))
+ {
+ State = MagCarSimProtocol.StateRunning;
+ _waitingRelease = false;
+ }
+
+ var delayMs = Math.Max(0, (int)(dispatchAt - DateTime.Now).TotalMilliseconds);
+ Log($"待命发车,环线 {MapPathFinder.FormatPath(_loopSiteIds, 16)},间隔 {IntervalOrDefault()}ms" +
+ (delayMs > 50 ? $",{delayMs}ms 后启动" : ",立即启动"));
+ }
+ }
+
+ public void StopLoop()
+ {
+ lock (_syncRoot)
+ {
+ _looping = false;
+ _dispatched = false;
+ _waitingRelease = false;
+ _holdingForStop = false;
+ _pausedByStopCommand = false;
+ _nextHopAt = DateTime.MaxValue;
+ State = MagCarSimProtocol.StateStopping;
+ Log("已停止循环");
+ }
+ }
+
+ public void Release()
+ {
+ lock (_syncRoot)
+ {
+ ApplyStartUnlocked("界面放行");
+ }
+ }
+
+ public void Tick(DateTime now, Dictionary occupancy)
+ {
+ lock (_syncRoot)
+ {
+ if (!_looping || _pausedByStopCommand)
+ {
+ return;
+ }
+
+ if (!_dispatched)
+ {
+ TryDispatchUnlocked(now, occupancy);
+ return;
+ }
+
+ if (_waitingRelease)
+ {
+ return;
+ }
+
+ if (State != MagCarSimProtocol.StateRunning)
+ {
+ return;
+ }
+
+ if (now < _nextHopAt)
+ {
+ return;
+ }
+
+ AdvanceUnlocked(now, occupancy);
+ }
+ }
+
+ public MagCarSimVehicleSnapshot Snapshot()
+ {
+ lock (_syncRoot)
+ {
+ return new MagCarSimVehicleSnapshot
+ {
+ Name = Name,
+ VehicleCode = VehicleCode,
+ ListenPort = ListenPort,
+ StartSiteId = Config.StartSiteId,
+ EndSiteId = Config.EndSiteId,
+ IntervalMs = Config.IntervalMs,
+ CurrentSiteId = CurrentSiteId,
+ Node = Node,
+ State = State,
+ StateText = MagCarSimProtocol.StateText(State),
+ WaitingRelease = _waitingRelease,
+ HoldingForStop = _holdingForStop,
+ Looping = _looping,
+ Listening = Listening,
+ ClientCount = _server?.ClientCount ?? 0,
+ PathText = MapPathFinder.FormatPath(_loopSiteIds, 12)
+ };
+ }
+ }
+
+ public void Dispose()
+ {
+ lock (_syncRoot)
+ {
+ _looping = false;
+ }
+
+ StopListen();
+ }
+
+ private byte[] HandleFrame(byte[] request)
+ {
+ lock (_syncRoot)
+ {
+ if (!MagCarSimProtocol.TryParse(request, out var parsed))
+ {
+ Log("收到非法帧");
+ return BuildStatusUnlocked(MagCarSimProtocol.CmdQueryState);
+ }
+
+ if (parsed.VehicleCode != 0 && parsed.VehicleCode != VehicleCode)
+ {
+ Log($"帧车号 {parsed.VehicleCode} 与本车 {VehicleCode} 不一致,仍按本车应答");
+ }
+
+ if (_hostConfig.LogRawFrames)
+ {
+ Log($"RX {MagCarSimProtocol.CommandName(parsed.Command)} {MagCarSimProtocol.ToHex(request)}");
+ }
+ else if (parsed.Command != MagCarSimProtocol.CmdQueryState || _hostConfig.LogQueries)
+ {
+ Log($"RX {MagCarSimProtocol.CommandName(parsed.Command)}");
+ }
+
+ switch (parsed.Command)
+ {
+ case MagCarSimProtocol.CmdStart:
+ ApplyStartUnlocked("0x01");
+ break;
+ case MagCarSimProtocol.CmdStop:
+ _pausedByStopCommand = true;
+ State = MagCarSimProtocol.StateStopping;
+ Log("收到停止,暂停循环");
+ break;
+ case MagCarSimProtocol.CmdSpeed:
+ Speed = (byte)Math.Clamp((int)parsed.Data0 >> 8, 0, 100);
+ break;
+ case MagCarSimProtocol.CmdAngle:
+ Angle = parsed.Data0;
+ break;
+ case MagCarSimProtocol.CmdLegacyNode:
+ case MagCarSimProtocol.CmdTask:
+ Log($"忽略路径命令 {MagCarSimProtocol.CommandName(parsed.Command)}(本模拟器按 WinForm 循环跑)");
+ break;
+ }
+
+ return BuildStatusUnlocked(parsed.Command);
+ }
+ }
+
+ private void ApplyStartUnlocked(string source)
+ {
+ if (!_dispatched)
+ {
+ Log($"待命中忽略放行({source})");
+ return;
+ }
+
+ if (_waitingRelease)
+ {
+ _waitingRelease = false;
+ _pausedByStopCommand = false;
+ State = MagCarSimProtocol.StateRunning;
+ _nextHopAt = DateTime.Now.AddMilliseconds(IntervalOrDefault());
+ Log($"放行成功({source}),站点 {CurrentSiteId} 改为运行中,{IntervalOrDefault()}ms 后前往下一站");
+ return;
+ }
+
+ if (_pausedByStopCommand && _looping)
+ {
+ _pausedByStopCommand = false;
+ State = MagCarSimProtocol.StateRunning;
+ _nextHopAt = DateTime.Now.AddMilliseconds(IntervalOrDefault());
+ Log($"从暂停恢复({source})");
+ return;
+ }
+
+ if (_looping && State != MagCarSimProtocol.StateRunning)
+ {
+ State = MagCarSimProtocol.StateRunning;
+ _nextHopAt = DateTime.Now.AddMilliseconds(IntervalOrDefault());
+ Log($"启动运行({source})");
+ }
+ }
+
+ private void PlaceAtStartUnlocked()
+ {
+ CurrentSiteId = Config.StartSiteId;
+ Node = _map == null ? (ushort)Math.Clamp(Config.StartSiteId, 0, ushort.MaxValue) : _map.ResolveNode(Config.StartSiteId);
+ State = MagCarSimProtocol.StateStopping;
+ _waitingRelease = false;
+ _pausedByStopCommand = false;
+ }
+
+ private void ArriveUnlocked(int siteId, bool isStart)
+ {
+ CurrentSiteId = siteId;
+ Node = _map.ResolveNode(siteId);
+ var needStop = _map.NeedStop(siteId);
+ if (needStop)
+ {
+ State = MagCarSimProtocol.StateStopping;
+ _waitingRelease = true;
+ _nextHopAt = DateTime.MaxValue;
+ Log($"{(isStart ? "起点" : "到站")} {siteId} node={Node} Mag_NeedStop=true → 停止中,等待放行");
+ return;
+ }
+
+ State = MagCarSimProtocol.StateRunning;
+ _waitingRelease = false;
+ _nextHopAt = DateTime.Now.AddMilliseconds(IntervalOrDefault());
+ if (isStart)
+ {
+ Log($"起点 {siteId} node={Node} 过站,{IntervalOrDefault()}ms 后下一地标");
+ }
+ }
+
+ private void TryDispatchUnlocked(DateTime now, Dictionary occupancy)
+ {
+ if (now < _dispatchAt)
+ {
+ return;
+ }
+
+ if (_loopSiteIds.Count == 0)
+ {
+ return;
+ }
+
+ var startSite = _loopSiteIds[_index];
+ if (IsOccupiedUnlocked(startSite, occupancy))
+ {
+ HoldForOccupiedUnlocked(now, startSite, "发车");
+ return;
+ }
+
+ OccupyUnlocked(occupancy, startSite);
+ _dispatched = true;
+ _holdingForStop = false;
+ ArriveUnlocked(startSite, isStart: true);
+ }
+
+ private void AdvanceUnlocked(DateTime now, Dictionary occupancy)
+ {
+ if (_loopSiteIds.Count < 2)
+ {
+ return;
+ }
+
+ var nextIndex = (_index + 1) % _loopSiteIds.Count;
+ var nextSite = _loopSiteIds[nextIndex];
+ if (IsOccupiedUnlocked(nextSite, occupancy))
+ {
+ HoldForOccupiedUnlocked(now, nextSite, "进站");
+ return;
+ }
+
+ MoveOccupancyUnlocked(occupancy, CurrentSiteId, nextSite);
+ _holdingForStop = false;
+ _index = nextIndex;
+ ArriveUnlocked(nextSite, isStart: false);
+ }
+
+ private bool IsOccupiedUnlocked(int siteId, Dictionary occupancy)
+ {
+ if (_map == null || occupancy == null || !_map.SingleOccupancy(siteId))
+ {
+ return false;
+ }
+
+ return occupancy.TryGetValue(siteId, out var occupier) && occupier != VehicleCode;
+ }
+
+ private void OccupyUnlocked(Dictionary occupancy, int siteId)
+ {
+ if (occupancy == null || siteId <= 0 || _map == null || !_map.SingleOccupancy(siteId))
+ {
+ return;
+ }
+
+ occupancy[siteId] = VehicleCode;
+ }
+
+ private void MoveOccupancyUnlocked(Dictionary occupancy, int fromSiteId, int toSiteId)
+ {
+ if (occupancy == null)
+ {
+ return;
+ }
+
+ if (fromSiteId > 0 &&
+ occupancy.TryGetValue(fromSiteId, out var occupier) &&
+ occupier == VehicleCode)
+ {
+ occupancy.Remove(fromSiteId);
+ }
+
+ OccupyUnlocked(occupancy, toSiteId);
+ }
+
+ private void HoldForOccupiedUnlocked(DateTime now, int occupiedSiteId, string action)
+ {
+ _holdingForStop = true;
+ State = MagCarSimProtocol.StateRunning;
+ _waitingRelease = false;
+ _nextHopAt = now.AddMilliseconds(200);
+ if ((now - _lastHoldLogAt).TotalMilliseconds >= 5000)
+ {
+ _lastHoldLogAt = now;
+ Log($"停止/交管点 {occupiedSiteId} 已有车,{action}受阻,保持站点 {CurrentSiteId} 运行中");
+ }
+ }
+
+ private int IntervalOrDefault()
+ {
+ var value = Config.IntervalMs > 0 ? Config.IntervalMs : _hostConfig.DefaultIntervalMs;
+ return Math.Max(200, value);
+ }
+
+ private byte[] BuildStatusUnlocked(byte command)
+ {
+ return MagCarSimProtocol.BuildStatus(
+ command,
+ VehicleCode,
+ Node,
+ State,
+ Charge,
+ Current,
+ Voltage,
+ Speed,
+ Angle,
+ Task,
+ Lift,
+ Roll);
+ }
+
+ private void Log(string message)
+ {
+ MagCarSimLog.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] [{Name}/{VehicleCode}] {message}");
+ }
+ }
+}
diff --git a/StandardScene.MagCarSimulator/StandardScene.MagCarSimulator.csproj b/StandardScene.MagCarSimulator/StandardScene.MagCarSimulator.csproj
new file mode 100644
index 0000000..9762141
--- /dev/null
+++ b/StandardScene.MagCarSimulator/StandardScene.MagCarSimulator.csproj
@@ -0,0 +1,24 @@
+
+
+
+ WinExe
+ net8.0-windows
+ StandardScene.MagCarSimulator
+ StandardScene.MagCarSimulator
+ latest
+ disable
+ disable
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/StandardScene.MagCarSimulator/appsettings.json b/StandardScene.MagCarSimulator/appsettings.json
new file mode 100644
index 0000000..ec60897
--- /dev/null
+++ b/StandardScene.MagCarSimulator/appsettings.json
@@ -0,0 +1,574 @@
+{
+ "MapPath": "D:\\工作\\stand\\SimpleLite\\maps\\复杂交管.json",
+ "UseTagValueAsNode": false,
+ "ListenAddress": "0.0.0.0",
+ "LogRawFrames": false,
+ "LogQueries": false,
+ "LogDirectory": "log",
+ "EnableFileLog": true,
+ "DefaultIntervalMs": 2000,
+ "LaunchStaggerMs": 0,
+ "ControlAreasPath": "D:\\工作\\stand\\SimpleLite\\config\\Signal\\mag-control-areas.json",
+ "Vehicles": [
+ {
+ "Name": "西环-1",
+ "VehicleCode": 1,
+ "ListenPort": 5001,
+ "StartSiteId": 1,
+ "EndSiteId": 7,
+ "IntervalMs": 1800,
+ "LoopSiteIds": [
+ 1,
+ 2,
+ 3,
+ 4,
+ 5,
+ 104,
+ 100,
+ 103,
+ 6,
+ 7,
+ 8,
+ 9,
+ 10,
+ 11,
+ 12,
+ 201,
+ 13,
+ 14
+ ]
+ },
+ {
+ "Name": "西环-2",
+ "VehicleCode": 2,
+ "ListenPort": 5002,
+ "StartSiteId": 4,
+ "EndSiteId": 7,
+ "IntervalMs": 1880,
+ "LoopSiteIds": [
+ 1,
+ 2,
+ 3,
+ 4,
+ 5,
+ 104,
+ 100,
+ 103,
+ 6,
+ 7,
+ 8,
+ 9,
+ 10,
+ 11,
+ 12,
+ 201,
+ 13,
+ 14
+ ]
+ },
+ {
+ "Name": "西环-3",
+ "VehicleCode": 3,
+ "ListenPort": 5003,
+ "StartSiteId": 8,
+ "EndSiteId": 7,
+ "IntervalMs": 1960,
+ "LoopSiteIds": [
+ 1,
+ 2,
+ 3,
+ 4,
+ 5,
+ 104,
+ 100,
+ 103,
+ 6,
+ 7,
+ 8,
+ 9,
+ 10,
+ 11,
+ 12,
+ 201,
+ 13,
+ 14
+ ]
+ },
+ {
+ "Name": "西环-4",
+ "VehicleCode": 4,
+ "ListenPort": 5004,
+ "StartSiteId": 12,
+ "EndSiteId": 7,
+ "IntervalMs": 2040,
+ "LoopSiteIds": [
+ 1,
+ 2,
+ 3,
+ 4,
+ 5,
+ 104,
+ 100,
+ 103,
+ 6,
+ 7,
+ 8,
+ 9,
+ 10,
+ 11,
+ 12,
+ 201,
+ 13,
+ 14
+ ]
+ },
+ {
+ "Name": "西环-5",
+ "VehicleCode": 5,
+ "ListenPort": 5005,
+ "StartSiteId": 13,
+ "EndSiteId": 7,
+ "IntervalMs": 2120,
+ "LoopSiteIds": [
+ 1,
+ 2,
+ 3,
+ 4,
+ 5,
+ 104,
+ 100,
+ 103,
+ 6,
+ 7,
+ 8,
+ 9,
+ 10,
+ 11,
+ 12,
+ 201,
+ 13,
+ 14
+ ]
+ },
+ {
+ "Name": "东环-1",
+ "VehicleCode": 6,
+ "ListenPort": 5006,
+ "StartSiteId": 15,
+ "EndSiteId": 23,
+ "IntervalMs": 1900,
+ "LoopSiteIds": [
+ 15,
+ 16,
+ 17,
+ 18,
+ 19,
+ 20,
+ 202,
+ 21,
+ 22,
+ 23,
+ 24,
+ 25,
+ 26,
+ 6,
+ 103,
+ 100,
+ 104,
+ 5
+ ]
+ },
+ {
+ "Name": "东环-2",
+ "VehicleCode": 7,
+ "ListenPort": 5007,
+ "StartSiteId": 18,
+ "EndSiteId": 23,
+ "IntervalMs": 1980,
+ "LoopSiteIds": [
+ 15,
+ 16,
+ 17,
+ 18,
+ 19,
+ 20,
+ 202,
+ 21,
+ 22,
+ 23,
+ 24,
+ 25,
+ 26,
+ 6,
+ 103,
+ 100,
+ 104,
+ 5
+ ]
+ },
+ {
+ "Name": "东环-3",
+ "VehicleCode": 8,
+ "ListenPort": 5008,
+ "StartSiteId": 20,
+ "EndSiteId": 23,
+ "IntervalMs": 2060,
+ "LoopSiteIds": [
+ 15,
+ 16,
+ 17,
+ 18,
+ 19,
+ 20,
+ 202,
+ 21,
+ 22,
+ 23,
+ 24,
+ 25,
+ 26,
+ 6,
+ 103,
+ 100,
+ 104,
+ 5
+ ]
+ },
+ {
+ "Name": "东环-4",
+ "VehicleCode": 9,
+ "ListenPort": 5009,
+ "StartSiteId": 22,
+ "EndSiteId": 23,
+ "IntervalMs": 2140,
+ "LoopSiteIds": [
+ 15,
+ 16,
+ 17,
+ 18,
+ 19,
+ 20,
+ 202,
+ 21,
+ 22,
+ 23,
+ 24,
+ 25,
+ 26,
+ 6,
+ 103,
+ 100,
+ 104,
+ 5
+ ]
+ },
+ {
+ "Name": "东环-5",
+ "VehicleCode": 10,
+ "ListenPort": 5010,
+ "StartSiteId": 25,
+ "EndSiteId": 23,
+ "IntervalMs": 2220,
+ "LoopSiteIds": [
+ 15,
+ 16,
+ 17,
+ 18,
+ 19,
+ 20,
+ 202,
+ 21,
+ 22,
+ 23,
+ 24,
+ 25,
+ 26,
+ 6,
+ 103,
+ 100,
+ 104,
+ 5
+ ]
+ },
+ {
+ "Name": "北环-1",
+ "VehicleCode": 11,
+ "ListenPort": 5011,
+ "StartSiteId": 35,
+ "EndSiteId": 28,
+ "IntervalMs": 2000,
+ "LoopSiteIds": [
+ 35,
+ 36,
+ 9,
+ 37,
+ 38,
+ 27,
+ 101,
+ 100,
+ 102,
+ 28,
+ 29,
+ 30,
+ 24,
+ 31,
+ 32,
+ 33,
+ 203,
+ 34
+ ]
+ },
+ {
+ "Name": "北环-2",
+ "VehicleCode": 12,
+ "ListenPort": 5012,
+ "StartSiteId": 37,
+ "EndSiteId": 28,
+ "IntervalMs": 2080,
+ "LoopSiteIds": [
+ 35,
+ 36,
+ 9,
+ 37,
+ 38,
+ 27,
+ 101,
+ 100,
+ 102,
+ 28,
+ 29,
+ 30,
+ 24,
+ 31,
+ 32,
+ 33,
+ 203,
+ 34
+ ]
+ },
+ {
+ "Name": "北环-3",
+ "VehicleCode": 13,
+ "ListenPort": 5013,
+ "StartSiteId": 29,
+ "EndSiteId": 28,
+ "IntervalMs": 2160,
+ "LoopSiteIds": [
+ 35,
+ 36,
+ 9,
+ 37,
+ 38,
+ 27,
+ 101,
+ 100,
+ 102,
+ 28,
+ 29,
+ 30,
+ 24,
+ 31,
+ 32,
+ 33,
+ 203,
+ 34
+ ]
+ },
+ {
+ "Name": "北环-4",
+ "VehicleCode": 14,
+ "ListenPort": 5014,
+ "StartSiteId": 32,
+ "EndSiteId": 28,
+ "IntervalMs": 2240,
+ "LoopSiteIds": [
+ 35,
+ 36,
+ 9,
+ 37,
+ 38,
+ 27,
+ 101,
+ 100,
+ 102,
+ 28,
+ 29,
+ 30,
+ 24,
+ 31,
+ 32,
+ 33,
+ 203,
+ 34
+ ]
+ },
+ {
+ "Name": "北环-5",
+ "VehicleCode": 15,
+ "ListenPort": 5015,
+ "StartSiteId": 203,
+ "EndSiteId": 28,
+ "IntervalMs": 2320,
+ "LoopSiteIds": [
+ 35,
+ 36,
+ 9,
+ 37,
+ 38,
+ 27,
+ 101,
+ 100,
+ 102,
+ 28,
+ 29,
+ 30,
+ 24,
+ 31,
+ 32,
+ 33,
+ 203,
+ 34
+ ]
+ },
+ {
+ "Name": "南环-1",
+ "VehicleCode": 16,
+ "ListenPort": 5016,
+ "StartSiteId": 39,
+ "EndSiteId": 28,
+ "IntervalMs": 2100,
+ "LoopSiteIds": [
+ 39,
+ 40,
+ 204,
+ 41,
+ 42,
+ 43,
+ 17,
+ 44,
+ 45,
+ 28,
+ 102,
+ 100,
+ 101,
+ 27,
+ 46,
+ 47,
+ 2,
+ 48
+ ]
+ },
+ {
+ "Name": "南环-2",
+ "VehicleCode": 17,
+ "ListenPort": 5017,
+ "StartSiteId": 204,
+ "EndSiteId": 28,
+ "IntervalMs": 2180,
+ "LoopSiteIds": [
+ 39,
+ 40,
+ 204,
+ 41,
+ 42,
+ 43,
+ 17,
+ 44,
+ 45,
+ 28,
+ 102,
+ 100,
+ 101,
+ 27,
+ 46,
+ 47,
+ 2,
+ 48
+ ]
+ },
+ {
+ "Name": "南环-3",
+ "VehicleCode": 18,
+ "ListenPort": 5018,
+ "StartSiteId": 42,
+ "EndSiteId": 28,
+ "IntervalMs": 2260,
+ "LoopSiteIds": [
+ 39,
+ 40,
+ 204,
+ 41,
+ 42,
+ 43,
+ 17,
+ 44,
+ 45,
+ 28,
+ 102,
+ 100,
+ 101,
+ 27,
+ 46,
+ 47,
+ 2,
+ 48
+ ]
+ },
+ {
+ "Name": "南环-4",
+ "VehicleCode": 19,
+ "ListenPort": 5019,
+ "StartSiteId": 45,
+ "EndSiteId": 28,
+ "IntervalMs": 2340,
+ "LoopSiteIds": [
+ 39,
+ 40,
+ 204,
+ 41,
+ 42,
+ 43,
+ 17,
+ 44,
+ 45,
+ 28,
+ 102,
+ 100,
+ 101,
+ 27,
+ 46,
+ 47,
+ 2,
+ 48
+ ]
+ },
+ {
+ "Name": "南环-5",
+ "VehicleCode": 20,
+ "ListenPort": 5020,
+ "StartSiteId": 47,
+ "EndSiteId": 28,
+ "IntervalMs": 2420,
+ "LoopSiteIds": [
+ 39,
+ 40,
+ 204,
+ 41,
+ 42,
+ 43,
+ 17,
+ 44,
+ 45,
+ 28,
+ 102,
+ 100,
+ 101,
+ 27,
+ 46,
+ 47,
+ 2,
+ 48
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/StandardScene.Magnetic/CarTypes/Mag2Car.cs b/StandardScene.Magnetic/CarTypes/Mag2Car.cs
index b0c633b..c9e2bd4 100644
--- a/StandardScene.Magnetic/CarTypes/Mag2Car.cs
+++ b/StandardScene.Magnetic/CarTypes/Mag2Car.cs
@@ -79,7 +79,7 @@ namespace StandardScene.CarTypes
[FieldMember] public bool EnableDetailLog = true;
[FieldMember] public bool LogRawFrame = true;
[FieldMember] public bool EnableFileLog = true;
- [FieldMember] public string LogDirectory = "logs";
+ [FieldMember] public string LogDirectory = "log";
[FieldMember] public float CarLength = 1200;
[FieldMember] public float CarWidth = 800;
diff --git a/StandardScene.Magnetic/CarTypes/MagCar.cs b/StandardScene.Magnetic/CarTypes/MagCar.cs
index 246ca4d..c0e533a 100644
--- a/StandardScene.Magnetic/CarTypes/MagCar.cs
+++ b/StandardScene.Magnetic/CarTypes/MagCar.cs
@@ -17,6 +17,7 @@ using System.Drawing.Drawing2D;
using System.Globalization;
using System.IO;
using System.Linq;
+using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
@@ -101,6 +102,9 @@ namespace StandardScene.CarTypes
///
private DateTime _lastPollTime = DateTime.MinValue;
+ /// 最近一次与 AGV 通讯成功的时间;用于「断开超过 OfflineAfterMs → 离线」。
+ private DateTime _lastCommOkTime = DateTime.Now;
+
///
/// AGV 服务端监听端口,默认 5000。
///
@@ -135,6 +139,12 @@ namespace StandardScene.CarTypes
///
[FieldMember] public int PollIntervalMs = 1000;
+ ///
+ /// 连续通讯失败超过该毫秒数后,概况显示「离线」。
+ /// 默认 20000(20 秒);期间失败会显示「连接中」。
+ ///
+ [FieldMember] public int OfflineAfterMs = 20000;
+
///
/// 移动任务的预留超时时间,单位秒。
/// 当前 MagCar 不下发脚本,保留该字段用于后续任务调度或界面配置兼容。
@@ -280,11 +290,28 @@ namespace StandardScene.CarTypes
}
catch (Exception ex)
{
- lstatus = IsBenignSocketFault(ex) ? lstatus : "离线";
- if (!IsBenignSocketFault(ex))
- {
- DetailLog($"keepAlive poll failed: {ex.Message}");
- }
+ ApplyOfflineAfterDisconnect(ex);
+ }
+ }
+
+ ///
+ /// 通讯失败时:未满 显示「连接中」,满 20 秒(可配)后显示「离线」。
+ ///
+ private void ApplyOfflineAfterDisconnect(Exception ex)
+ {
+ var offlineAfter = OfflineAfterMs < 1000 ? 1000 : OfflineAfterMs;
+ if ((DateTime.Now - _lastCommOkTime).TotalMilliseconds >= offlineAfter)
+ {
+ lstatus = "离线";
+ }
+ else if (lstatus != "离线")
+ {
+ lstatus = "连接中";
+ }
+
+ if (!IsBenignSocketFault(ex))
+ {
+ DetailLog($"keepAlive poll failed: {ex.Message}");
}
}
@@ -590,6 +617,13 @@ namespace StandardScene.CarTypes
try
{
ConnectWithTimeout(client);
+ if (IsLoopbackSelfConnect(client))
+ {
+ AbortClientQuietly(client);
+ throw new IOException(
+ $"TCP self-connect {address}:{Port} (local ephemeral hit listen port); refuse and retry");
+ }
+
client.SendTimeout = SendTimeoutMs;
client.ReceiveTimeout = ReceiveTimeoutMs;
_persistentClient = client;
@@ -619,7 +653,51 @@ namespace StandardScene.CarTypes
{
try
{
- return _persistentClient != null && _persistentClient.Connected;
+ if (_persistentClient == null || !_persistentClient.Connected)
+ {
+ return false;
+ }
+
+ // 本机动态端口若从 1024 起,连 500x 且模拟器未监听时,Windows 可能自连成功且 Connected=true,
+ // 之后会一直把自身请求当响应(State=0 → 未准备),必须主动拆掉重连。
+ if (IsLoopbackSelfConnect(_persistentClient))
+ {
+ ClosePersistentConnection("loopback self-connect");
+ return false;
+ }
+
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// 检测 Windows/localhost 上「源端口=目标端口」的自连(无真实服务端)。
+ ///
+ private static bool IsLoopbackSelfConnect(TcpClient client)
+ {
+ try
+ {
+ if (client?.Client == null)
+ {
+ return false;
+ }
+
+ if (!(client.Client.LocalEndPoint is IPEndPoint local) ||
+ !(client.Client.RemoteEndPoint is IPEndPoint remote))
+ {
+ return false;
+ }
+
+ if (local.Port != remote.Port)
+ {
+ return false;
+ }
+
+ return IPAddress.IsLoopback(local.Address) && IPAddress.IsLoopback(remote.Address);
}
catch
{
@@ -680,6 +758,13 @@ namespace StandardScene.CarTypes
{
DetailLog($"short connect begin {address}:{Port}, timeout={ConnectTimeoutMs}ms");
ConnectWithTimeout(client);
+ if (IsLoopbackSelfConnect(client))
+ {
+ AbortClientQuietly(client);
+ throw new IOException(
+ $"TCP self-connect {address}:{Port} (local ephemeral hit listen port); refuse and retry");
+ }
+
DetailLog($"short connect ok {address}:{Port}");
client.SendTimeout = SendTimeoutMs;
@@ -785,11 +870,17 @@ namespace StandardScene.CarTypes
if (!string.IsNullOrEmpty(message) &&
(message.Contains("已中止 I/O", StringComparison.Ordinal) ||
message.Contains("I/O operation", StringComparison.OrdinalIgnoreCase) ||
- message.Contains("operation was aborted", StringComparison.OrdinalIgnoreCase)))
+ message.Contains("operation was aborted", StringComparison.OrdinalIgnoreCase) ||
+ message.Contains("self-connect", StringComparison.OrdinalIgnoreCase)))
{
return true;
}
}
+
+ if (current is TimeoutException)
+ {
+ return true;
+ }
}
return false;
@@ -899,6 +990,7 @@ namespace StandardScene.CarTypes
private void ApplyReport(MagCarReport report)
{
LastReport = report;
+ _lastCommOkTime = DateTime.Now;
lstatus = report.Alarm != 0 ? "报警" : report.State == 0 ? "未准备" : "上线";
th = report.Angle;
@@ -925,12 +1017,108 @@ namespace StandardScene.CarTypes
haveCoordination = true;
siteID = site.id;
- x = site.x;
- y = site.y;
+ ApplyStackedSitePose(site);
SyncTrafficFromReport(site, report);
DetailLog($"report applied, node={report.Node}, site={siteID}, x={x:0.###}, y={y:0.###}, th={th:0.###}, state={StateText(report.State)}, soc={report.Charge}, current={report.Current:0.###}, voltage={report.Voltage:0.###}, speed={report.Speed}, task={report.Task}, lift={report.Lift}, roll={report.Roll}, alarm=0x{report.Alarm:X}");
}
+ ///
+ /// 同一普通站多车时,沿路网方向按车号依次错开坐标,避免 SimpleLite 地图上完全重叠。
+ /// 站点逻辑仍用 ,不改变交管占用。
+ ///
+ private void ApplyStackedSitePose(Site site)
+ {
+ if (site == null)
+ {
+ return;
+ }
+
+ var ids = SameSiteCarIds(site.id);
+ var index = Math.Max(0, ids.IndexOf(id));
+ var (ux, uy) = SiteSpreadAxis(site);
+ var step = Math.Max(CarLength * 0.9f, 900f);
+ var centered = index - (ids.Count - 1) * 0.5;
+ x = (float)(site.x + ux * step * centered);
+ y = (float)(site.y + uy * step * centered);
+ }
+
+ private List SameSiteCarIds(int siteId)
+ {
+ var ids = new List();
+ try
+ {
+ foreach (var car in SimpleLib.GetAllCars().OfType())
+ {
+ if (car != null && car.siteID == siteId && !ids.Contains(car.id))
+ {
+ ids.Add(car.id);
+ }
+ }
+ }
+ catch
+ {
+ }
+
+ if (!ids.Contains(id))
+ {
+ ids.Add(id);
+ }
+
+ ids.Sort();
+ return ids;
+ }
+
+ private static (double ux, double uy) SiteSpreadAxis(Site site)
+ {
+ double bestDx = 0;
+ double bestDy = 0;
+ double bestLen = 0;
+ try
+ {
+ foreach (var track in SimpleLib.GetAllTracks())
+ {
+ if (track == null)
+ {
+ continue;
+ }
+
+ var otherId = track.siteA == site.id ? track.siteB
+ : track.siteB == site.id ? track.siteA
+ : 0;
+ if (otherId <= 0)
+ {
+ continue;
+ }
+
+ var other = SimpleLib.GetSite(otherId);
+ if (other == null)
+ {
+ continue;
+ }
+
+ var dx = other.x - site.x;
+ var dy = other.y - site.y;
+ var len = Math.Sqrt(dx * dx + dy * dy);
+ if (len > bestLen)
+ {
+ bestLen = len;
+ bestDx = dx;
+ bestDy = dy;
+ }
+ }
+ }
+ catch
+ {
+ }
+
+ if (bestLen < 1)
+ {
+ return (1, 0);
+ }
+
+ return (bestDx / bestLen, bestDy / bestLen);
+ }
+
///
/// 将 AGV 上报的当前地标同步到交通锁(holdingLocks)。
/// GhostCar 调度依赖 holdingLocks/GetLastSite,仅改 x/y/siteID 会导致“画面在动、初始化占点不动”。
@@ -954,6 +1142,25 @@ namespace StandardScene.CarTypes
return;
}
+ // 他车仍占本站锁但物理站已离开 → 回收陈旧锁,否则 TrafficReset 失败,画面占点不跟着走。
+ var holder = FindOtherCarHoldingSite(site.id);
+ if (holder != null)
+ {
+ if (!TryReleaseStaleHolder(holder, site.id))
+ {
+ if (_lastSyncedTrafficSiteId != -site.id)
+ {
+ DetailLog(
+ $"traffic sync skip, site={site.id} held by car={holder.id}, selfLock={lockedSite}, node={report.Node}");
+ _lastSyncedTrafficSiteId = -site.id;
+ }
+
+ return;
+ }
+
+ DetailLog($"traffic sync released stale holder car={holder.id}, site={site.id}");
+ }
+
try
{
DetailLog($"traffic sync TrafficReset, lock={lockedSite}, targetSite={site.id}, node={report.Node}, state={StateText(report.State)}");
@@ -966,6 +1173,52 @@ namespace StandardScene.CarTypes
}
}
+ private Car FindOtherCarHoldingSite(int siteId)
+ {
+ foreach (var other in SimpleLib.GetAllCars().OfType())
+ {
+ if (other == null || other.id == id)
+ {
+ continue;
+ }
+
+ if (other.status?.holdingLocks != null &&
+ Array.IndexOf(other.status.holdingLocks, siteId) >= 0)
+ {
+ return other;
+ }
+ }
+
+ return null;
+ }
+
+ private bool TryReleaseStaleHolder(Car other, int siteId)
+ {
+ if (other == null)
+ {
+ return false;
+ }
+
+ var online = other.tags?.Contains("Online") == true;
+ var physicalSiteId = other.siteID > 0 ? other.siteID : other.GetLastSite();
+ var stale = !online || (physicalSiteId > 0 && physicalSiteId != siteId);
+ if (!stale)
+ {
+ return false;
+ }
+
+ try
+ {
+ TrafficControl.Leave(other, siteId);
+ return Array.IndexOf(other.status.holdingLocks ?? Array.Empty(), siteId) < 0;
+ }
+ catch (Exception ex)
+ {
+ DetailLog($"traffic sync release stale holder failed: car={other.id}, site={siteId}, {ex.Message}");
+ return false;
+ }
+ }
+
///
/// 将 AGV 返回的节点号解析为场景站点。
/// 根据 决定优先匹配站点 TagValue 还是直接匹配站点 id。
diff --git a/StandardScene.Magnetic/Tasking/Fass2ControlAreaOccupancy.cs b/StandardScene.Magnetic/Tasking/Fass2ControlAreaOccupancy.cs
index 6563346..daed672 100644
--- a/StandardScene.Magnetic/Tasking/Fass2ControlAreaOccupancy.cs
+++ b/StandardScene.Magnetic/Tasking/Fass2ControlAreaOccupancy.cs
@@ -9,6 +9,7 @@ namespace StandardScene.Magnetic.Tasking
{
///
/// 按 holdingLocks + 物理 siteID 统计管控站点列表上的他车占用。
+ /// Mag2 仍读地图 Fass2_ControlArea 字段;磁条交管进程只放行 MagCar,不介入本路径。
///
internal static class Fass2ControlAreaOccupancy
{
diff --git a/StandardScene.Magnetic/Tasking/Mag2CarFileLogger.cs b/StandardScene.Magnetic/Tasking/Mag2CarFileLogger.cs
index 24c920c..8713f69 100644
--- a/StandardScene.Magnetic/Tasking/Mag2CarFileLogger.cs
+++ b/StandardScene.Magnetic/Tasking/Mag2CarFileLogger.cs
@@ -6,17 +6,17 @@ using System.Text;
namespace StandardScene.Magnetic.Tasking
{
///
- /// Mag2Car 本地文件日志,按协议车号分目录:logs/car{VehicleCode}/mag2_yyyyMMdd.log
+ /// Mag2Car 本地文件日志,按协议车号分目录:log/car{VehicleCode}/mag2_yyyyMMdd.log
///
public static class Mag2CarFileLogger
{
private static readonly ConcurrentDictionary CarLocks = new ConcurrentDictionary();
- private static string _baseDirectory = "logs";
+ private static string _baseDirectory = "log";
private static bool _enabled = true;
public static void Configure(string baseDirectory, bool enabled)
{
- _baseDirectory = string.IsNullOrWhiteSpace(baseDirectory) ? "logs" : baseDirectory.Trim();
+ _baseDirectory = string.IsNullOrWhiteSpace(baseDirectory) ? "log" : baseDirectory.Trim();
_enabled = enabled;
}
diff --git a/StandardScene.Signal/Config/Signal/handshake-points.json b/StandardScene.Signal/Config/Signal/handshake-points.json
index d732469..2ad4688 100644
--- a/StandardScene.Signal/Config/Signal/handshake-points.json
+++ b/StandardScene.Signal/Config/Signal/handshake-points.json
@@ -2,31 +2,46 @@
{
"Sit": 98,
"SignalType": "JGControl",
- "SignalParams": "上线机构1;RequestIn"
+ "JgName": "上线机构1",
+ "DockId": "dock1",
+ "Action": "RequestIn"
},
{
"Sit": 100,
"SignalType": "JGControl",
- "SignalParams": "上线机构1;Arrived"
+ "JgName": "上线机构1",
+ "DockId": "dock1",
+ "Action": "Arrived"
+ },
+ {
+ "Sit": 108,
+ "SignalType": "JGControl",
+ "JgName": "上线机构1",
+ "DockId": "dock2",
+ "Action": "RequestIn"
},
{
"Sit": 199,
"SignalType": "JGControl",
- "SignalParams": "下线机构1;RequestIn"
+ "JgName": "下线机构1",
+ "DockId": "dock1",
+ "Action": "RequestIn"
},
{
"Sit": 201,
"SignalType": "PointControl",
- "SignalParams": "UP1;下线机构1"
+ "JgName": "下线机构1",
+ "DockId": "dock1",
+ "Action": "UP1"
},
{
"Sit": 50,
"SignalType": "TimedCharging",
- "SignalParams": "300;95;20"
+ "ExtraParams": "300;95;20"
},
{
"Sit": 60,
"SignalType": "RateFlowControl",
- "SignalParams": "缓存区;A;2;60,61,62"
+ "ExtraParams": "缓存区;A;2;60,61,62"
}
]
diff --git a/StandardScene.Signal/Config/Signal/mag-control-areas.json b/StandardScene.Signal/Config/Signal/mag-control-areas.json
new file mode 100644
index 0000000..ea67120
--- /dev/null
+++ b/StandardScene.Signal/Config/Signal/mag-control-areas.json
@@ -0,0 +1,134 @@
+[
+ {
+ "TriggerSit": 4,
+ "AreaName": "路口中",
+ "CompatGroup": "NS-W",
+ "Priority": 10,
+ "WaitSeconds": 12,
+ "ControlArea": "5,104,100,103,6",
+ "ControlCapacity": 1,
+ "ControlReleaseStartStop": 11,
+ "IsUse": true
+ },
+ {
+ "TriggerSit": 26,
+ "AreaName": "路口中",
+ "CompatGroup": "NS-E",
+ "Priority": 10,
+ "WaitSeconds": 12,
+ "ControlArea": "6,103,100,104,5",
+ "ControlCapacity": 1,
+ "ControlReleaseStartStop": 11,
+ "IsUse": true
+ },
+ {
+ "TriggerSit": 38,
+ "AreaName": "路口中",
+ "CompatGroup": "EW-N",
+ "Priority": 10,
+ "WaitSeconds": 12,
+ "ControlArea": "27,101,100,102,28",
+ "ControlCapacity": 1,
+ "ControlReleaseStartStop": 11,
+ "IsUse": true
+ },
+ {
+ "TriggerSit": 45,
+ "AreaName": "路口中",
+ "CompatGroup": "EW-S",
+ "Priority": 10,
+ "WaitSeconds": 12,
+ "ControlArea": "28,102,100,101,27",
+ "ControlCapacity": 1,
+ "ControlReleaseStartStop": 11,
+ "IsUse": true
+ },
+ {
+ "TriggerSit": 1,
+ "AreaName": "西南角",
+ "CompatGroup": "SW-W",
+ "Priority": 10,
+ "WaitSeconds": 12,
+ "ControlArea": "2",
+ "ControlCapacity": 1,
+ "ControlReleaseStartStop": 11,
+ "IsUse": true
+ },
+ {
+ "TriggerSit": 47,
+ "AreaName": "西南角",
+ "CompatGroup": "SW-S",
+ "Priority": 10,
+ "WaitSeconds": 12,
+ "ControlArea": "2",
+ "ControlCapacity": 1,
+ "ControlReleaseStartStop": 11,
+ "IsUse": true
+ },
+ {
+ "TriggerSit": 8,
+ "AreaName": "西北角",
+ "CompatGroup": "NW-W",
+ "Priority": 10,
+ "WaitSeconds": 12,
+ "ControlArea": "9",
+ "ControlCapacity": 1,
+ "ControlReleaseStartStop": 11,
+ "IsUse": true
+ },
+ {
+ "TriggerSit": 36,
+ "AreaName": "西北角",
+ "CompatGroup": "NW-N",
+ "Priority": 10,
+ "WaitSeconds": 12,
+ "ControlArea": "9",
+ "ControlCapacity": 1,
+ "ControlReleaseStartStop": 11,
+ "IsUse": true
+ },
+ {
+ "TriggerSit": 16,
+ "AreaName": "东南角",
+ "CompatGroup": "SE-E",
+ "Priority": 10,
+ "WaitSeconds": 12,
+ "ControlArea": "17",
+ "ControlCapacity": 1,
+ "ControlReleaseStartStop": 11,
+ "IsUse": true
+ },
+ {
+ "TriggerSit": 43,
+ "AreaName": "东南角",
+ "CompatGroup": "SE-S",
+ "Priority": 10,
+ "WaitSeconds": 12,
+ "ControlArea": "17",
+ "ControlCapacity": 1,
+ "ControlReleaseStartStop": 11,
+ "IsUse": true
+ },
+ {
+ "TriggerSit": 23,
+ "AreaName": "东北角",
+ "CompatGroup": "NE-E",
+ "Priority": 10,
+ "WaitSeconds": 12,
+ "ControlArea": "24",
+ "ControlCapacity": 1,
+ "ControlReleaseStartStop": 11,
+ "IsUse": true
+ },
+ {
+ "TriggerSit": 30,
+ "AreaName": "东北角",
+ "CompatGroup": "NE-N",
+ "Priority": 10,
+ "WaitSeconds": 12,
+ "ControlArea": "24",
+ "ControlCapacity": 1,
+ "ControlReleaseStartStop": 11,
+ "IsUse": true
+ }
+]
diff --git a/StandardScene.Signal/Config/Signal/mag-control-areas.复杂交管.json b/StandardScene.Signal/Config/Signal/mag-control-areas.复杂交管.json
new file mode 100644
index 0000000..ea67120
--- /dev/null
+++ b/StandardScene.Signal/Config/Signal/mag-control-areas.复杂交管.json
@@ -0,0 +1,134 @@
+[
+ {
+ "TriggerSit": 4,
+ "AreaName": "路口中",
+ "CompatGroup": "NS-W",
+ "Priority": 10,
+ "WaitSeconds": 12,
+ "ControlArea": "5,104,100,103,6",
+ "ControlCapacity": 1,
+ "ControlReleaseStartStop": 11,
+ "IsUse": true
+ },
+ {
+ "TriggerSit": 26,
+ "AreaName": "路口中",
+ "CompatGroup": "NS-E",
+ "Priority": 10,
+ "WaitSeconds": 12,
+ "ControlArea": "6,103,100,104,5",
+ "ControlCapacity": 1,
+ "ControlReleaseStartStop": 11,
+ "IsUse": true
+ },
+ {
+ "TriggerSit": 38,
+ "AreaName": "路口中",
+ "CompatGroup": "EW-N",
+ "Priority": 10,
+ "WaitSeconds": 12,
+ "ControlArea": "27,101,100,102,28",
+ "ControlCapacity": 1,
+ "ControlReleaseStartStop": 11,
+ "IsUse": true
+ },
+ {
+ "TriggerSit": 45,
+ "AreaName": "路口中",
+ "CompatGroup": "EW-S",
+ "Priority": 10,
+ "WaitSeconds": 12,
+ "ControlArea": "28,102,100,101,27",
+ "ControlCapacity": 1,
+ "ControlReleaseStartStop": 11,
+ "IsUse": true
+ },
+ {
+ "TriggerSit": 1,
+ "AreaName": "西南角",
+ "CompatGroup": "SW-W",
+ "Priority": 10,
+ "WaitSeconds": 12,
+ "ControlArea": "2",
+ "ControlCapacity": 1,
+ "ControlReleaseStartStop": 11,
+ "IsUse": true
+ },
+ {
+ "TriggerSit": 47,
+ "AreaName": "西南角",
+ "CompatGroup": "SW-S",
+ "Priority": 10,
+ "WaitSeconds": 12,
+ "ControlArea": "2",
+ "ControlCapacity": 1,
+ "ControlReleaseStartStop": 11,
+ "IsUse": true
+ },
+ {
+ "TriggerSit": 8,
+ "AreaName": "西北角",
+ "CompatGroup": "NW-W",
+ "Priority": 10,
+ "WaitSeconds": 12,
+ "ControlArea": "9",
+ "ControlCapacity": 1,
+ "ControlReleaseStartStop": 11,
+ "IsUse": true
+ },
+ {
+ "TriggerSit": 36,
+ "AreaName": "西北角",
+ "CompatGroup": "NW-N",
+ "Priority": 10,
+ "WaitSeconds": 12,
+ "ControlArea": "9",
+ "ControlCapacity": 1,
+ "ControlReleaseStartStop": 11,
+ "IsUse": true
+ },
+ {
+ "TriggerSit": 16,
+ "AreaName": "东南角",
+ "CompatGroup": "SE-E",
+ "Priority": 10,
+ "WaitSeconds": 12,
+ "ControlArea": "17",
+ "ControlCapacity": 1,
+ "ControlReleaseStartStop": 11,
+ "IsUse": true
+ },
+ {
+ "TriggerSit": 43,
+ "AreaName": "东南角",
+ "CompatGroup": "SE-S",
+ "Priority": 10,
+ "WaitSeconds": 12,
+ "ControlArea": "17",
+ "ControlCapacity": 1,
+ "ControlReleaseStartStop": 11,
+ "IsUse": true
+ },
+ {
+ "TriggerSit": 23,
+ "AreaName": "东北角",
+ "CompatGroup": "NE-E",
+ "Priority": 10,
+ "WaitSeconds": 12,
+ "ControlArea": "24",
+ "ControlCapacity": 1,
+ "ControlReleaseStartStop": 11,
+ "IsUse": true
+ },
+ {
+ "TriggerSit": 30,
+ "AreaName": "东北角",
+ "CompatGroup": "NE-N",
+ "Priority": 10,
+ "WaitSeconds": 12,
+ "ControlArea": "24",
+ "ControlCapacity": 1,
+ "ControlReleaseStartStop": 11,
+ "IsUse": true
+ }
+]
diff --git a/StandardScene.Signal/Config/Signal/signal-tables.json b/StandardScene.Signal/Config/Signal/signal-tables.json
new file mode 100644
index 0000000..0c92692
--- /dev/null
+++ b/StandardScene.Signal/Config/Signal/signal-tables.json
@@ -0,0 +1,40 @@
+{
+ "version": 1,
+ "tables": [
+ {
+ "id": "stations",
+ "title": "PLC机构",
+ "category": "PLC数据管理",
+ "fileName": "stations.json",
+ "model": "PlcStationModel"
+ },
+ {
+ "id": "docks",
+ "title": "机构工位",
+ "category": "PLC数据管理",
+ "fileName": "station-docks.json",
+ "model": "PlcStationDockModel"
+ },
+ {
+ "id": "handshake",
+ "title": "握手点",
+ "category": "握手点数据管理",
+ "fileName": "handshake-points.json",
+ "model": "HandshakePointModel"
+ },
+ {
+ "id": "release",
+ "title": "放行点",
+ "category": "放行点数据管理",
+ "fileName": "release-points.json",
+ "model": "ReleasePointModel"
+ },
+ {
+ "id": "mag-control",
+ "title": "磁条管控区",
+ "category": "磁条交管",
+ "fileName": "mag-control-areas.json",
+ "model": "MagControlAreaModel"
+ }
+ ]
+}
diff --git a/StandardScene.Signal/Config/Signal/station-docks.json b/StandardScene.Signal/Config/Signal/station-docks.json
new file mode 100644
index 0000000..5398a3d
--- /dev/null
+++ b/StandardScene.Signal/Config/Signal/station-docks.json
@@ -0,0 +1,62 @@
+[
+ {
+ "JgName": "上线机构1",
+ "DockId": "dock1",
+ "AllowInByte": 0,
+ "AllowInBit": 0,
+ "AllowOutByte": 0,
+ "AllowOutBit": 1,
+ "EnteringByte": 2,
+ "EnteringBit": 0,
+ "ArrivedByte": 2,
+ "ArrivedBit": 1,
+ "LeavingByte": 2,
+ "LeavingBit": 2,
+ "LeftByte": 2,
+ "LeftBit": 3,
+ "JgInPointId": 99,
+ "JgInContains": "99,100",
+ "JgPointId": 100,
+ "JgContains": "100,101"
+ },
+ {
+ "JgName": "上线机构1",
+ "DockId": "dock2",
+ "AllowInByte": 0,
+ "AllowInBit": 2,
+ "AllowOutByte": 0,
+ "AllowOutBit": 3,
+ "EnteringByte": 2,
+ "EnteringBit": 4,
+ "ArrivedByte": 2,
+ "ArrivedBit": 5,
+ "LeavingByte": 2,
+ "LeavingBit": 6,
+ "LeftByte": 2,
+ "LeftBit": 7,
+ "JgInPointId": 109,
+ "JgInContains": "109,110",
+ "JgPointId": 110,
+ "JgContains": "110,111"
+ },
+ {
+ "JgName": "下线机构1",
+ "DockId": "dock1",
+ "AllowInByte": 0,
+ "AllowInBit": 0,
+ "AllowOutByte": 0,
+ "AllowOutBit": 1,
+ "EnteringByte": 2,
+ "EnteringBit": 0,
+ "ArrivedByte": 2,
+ "ArrivedBit": 1,
+ "LeavingByte": 2,
+ "LeavingBit": 2,
+ "LeftByte": 2,
+ "LeftBit": 3,
+ "JgInPointId": 200,
+ "JgInContains": "200",
+ "JgPointId": 201,
+ "JgContains": "201,202"
+ }
+]
diff --git a/StandardScene.Signal/Config/Signal/stations.json b/StandardScene.Signal/Config/Signal/stations.json
index 004df13..6259680 100644
--- a/StandardScene.Signal/Config/Signal/stations.json
+++ b/StandardScene.Signal/Config/Signal/stations.json
@@ -3,26 +3,20 @@
"JgName": "上线机构1",
"Ip": "192.168.1.10",
"Port": 102,
- "ReadDb": "DB1.DBB0",
- "WriteDb": "DB1.DBB1",
- "HeartDb": "DB1.DBX2.0",
- "JgInPointId": 99,
- "JgInContains": "99,100",
- "JgPointId": 100,
- "JgContains": "100,101,102",
+ "PlcType": "S7_1500",
+ "DbNumber": 100,
+ "HeartByte": 32,
+ "HeartBit": 0,
"LineType": "上线机构"
},
{
"JgName": "下线机构1",
"Ip": "192.168.1.11",
"Port": 102,
- "ReadDb": "DB1.DBB0",
- "WriteDb": "DB1.DBB1",
- "HeartDb": "DB1.DBX2.0",
- "JgInPointId": 200,
- "JgInContains": "200",
- "JgPointId": 201,
- "JgContains": "201,202",
+ "PlcType": "S7_1500",
+ "DbNumber": 100,
+ "HeartByte": 32,
+ "HeartBit": 0,
"LineType": "单牵引下线机构"
}
]
diff --git a/StandardScene.Signal/Logic/CarScanWorker.cs b/StandardScene.Signal/Logic/CarScanWorker.cs
index ddb25b0..0501873 100644
--- a/StandardScene.Signal/Logic/CarScanWorker.cs
+++ b/StandardScene.Signal/Logic/CarScanWorker.cs
@@ -139,13 +139,26 @@ namespace StandardScene.Signal.Logic
release = _release;
}
- PlcStationRuntime Find(string name)
+ PlcStationRuntime Find(string name, string dockId)
{
if (string.IsNullOrWhiteSpace(name))
return null;
var list = _runtimes() ?? Array.Empty();
- return list.FirstOrDefault(x =>
- string.Equals(x.Config.JgName, name, StringComparison.OrdinalIgnoreCase));
+ var ofStation = list.Where(x =>
+ string.Equals(x.JgName, name, StringComparison.OrdinalIgnoreCase)).ToList();
+ if (ofStation.Count == 0)
+ return null;
+ if (!string.IsNullOrWhiteSpace(dockId))
+ {
+ var hit = ofStation.FirstOrDefault(x =>
+ string.Equals(x.DockId, dockId.Trim(), StringComparison.OrdinalIgnoreCase));
+ if (hit != null)
+ return hit;
+ }
+
+ return ofStation.FirstOrDefault(x =>
+ string.Equals(x.DockId, PlcConfigLoader.DefaultDockId, StringComparison.OrdinalIgnoreCase))
+ ?? ofStation[0];
}
foreach (var car in SignalCarAdapter.All())
@@ -192,7 +205,7 @@ namespace StandardScene.Signal.Logic
/// 停稳分发。TimedCharging 优先:充电点不应再走机构逻辑。
/// 未识别的 SignalType 按站点节流打日志,避免刷屏。
///
- private void DispatchStopped(Car car, SignalStopSpec spec, Func find)
+ private void DispatchStopped(Car car, SignalStopSpec spec, Func find)
{
if (spec.Is("TimedCharging"))
{
diff --git a/StandardScene.Signal/Logic/JgControlHandler.cs b/StandardScene.Signal/Logic/JgControlHandler.cs
index e9382d4..7e16f94 100644
--- a/StandardScene.Signal/Logic/JgControlHandler.cs
+++ b/StandardScene.Signal/Logic/JgControlHandler.cs
@@ -30,11 +30,12 @@ namespace StandardScene.Signal.Logic
public static class JgControlHandler
{
/// 停稳:可选限流 → RequestIn 或 Arrived。
- public static void OnStopped(Car car, SignalStopSpec spec, Func find, IDictionary rateFlowLog)
+ public static void OnStopped(Car car, SignalStopSpec spec, Func find, IDictionary rateFlowLog)
{
- var jgName = spec.Part(0).Trim();
- var action = spec.Part(1).Trim();
- var rateFlow = spec.Part(2).Trim();
+ var jgName = spec.JgName;
+ var action = spec.Action;
+ var dockId = spec.DockId;
+ var rateFlow = spec.ExtraParams;
if (string.IsNullOrEmpty(jgName))
{
Diagnosis.Log($"AGV{car.id}停在地标{SignalCarAdapter.PositionId(car)},读取配置机构名称为空", "JGControl", true);
@@ -48,29 +49,25 @@ namespace StandardScene.Signal.Logic
}
if (string.Equals(action, "RequestIn", StringComparison.OrdinalIgnoreCase))
- RequestIn(jgName, car, find);
+ RequestIn(jgName, dockId, car, find);
else if (string.Equals(action, "Arrived", StringComparison.OrdinalIgnoreCase))
- Arrived(jgName, car, find);
+ Arrived(jgName, dockId, car, find);
}
/// 行驶中只做 RequestIn 安全闸:未允许进入且不是本车占用请求槽则停车。
- public static void OnMoving(Car car, SignalStopSpec spec, Func find)
+ public static void OnMoving(Car car, SignalStopSpec spec, Func find)
{
- var jgName = spec.Part(0).Trim();
- var action = spec.Part(1).Trim();
+ var jgName = spec.JgName;
+ var action = spec.Action;
if (string.IsNullOrEmpty(jgName))
return;
if (string.Equals(action, "RequestIn", StringComparison.OrdinalIgnoreCase))
- RequestInJudge(jgName, car, find);
+ RequestInJudge(jgName, spec.DockId, car, find);
}
- ///
- /// 请求进入。锁机构运行态,避免与 PLC Worker 同时改写字节 / 车号。
- /// 后半段与 相同,覆盖「刚放行后又被调度层启动」的竞态。
- ///
- private static void RequestIn(string jgName, Car car, Func find)
+ private static void RequestIn(string jgName, string dockId, Car car, Func find)
{
- var rt = find(jgName);
+ var rt = find(jgName, dockId);
if (rt == null)
{
Diagnosis.Log($"{jgName} 未能找到机构", "JGControl", true);
@@ -83,19 +80,19 @@ namespace StandardScene.Signal.Logic
rt.RequestInAgvNo == 0 &&
rt.ReadWriteSignal == SignalWriteValue.无状态)
{
- if (rt.Config.LineType is SignalLineType.单牵引下线机构 or SignalLineType.双牵引下线机构)
+ if (rt.Station.LineType is SignalLineType.单牵引下线机构 or SignalLineType.双牵引下线机构)
{
- var inside = SignalCarAdapter.CarsAt(rt.Config.JgPointId);
+ var inside = SignalCarAdapter.CarsAt(rt.Dock.JgPointId);
if (inside.Count > 0)
{
Diagnosis.Log(
- $"{jgName} 机构内有AGV【{string.Join(",", inside.Select(x => x.id))}】暂不放行AGV{car.id}",
+ $"{rt.DisplayName} 机构内有AGV【{string.Join(",", inside.Select(x => x.id))}】暂不放行AGV{car.id}",
"JGControl", true);
return;
}
}
- Diagnosis.Log($"{jgName} 机构允许进入,通知AGV{car.id}启动,通知机构AGV进入中", "JGControl", true);
+ Diagnosis.Log($"{rt.DisplayName} 机构允许进入,通知AGV{car.id}启动,通知机构AGV进入中", "JGControl", true);
rt.WriteSignal = SignalWriteValue.进入中;
rt.RequestInAgvNo = car.id;
SignalCarAdapter.StopCharge(car);
@@ -108,15 +105,14 @@ namespace StandardScene.Signal.Logic
SignalCarAdapter.IsMoving(car))
{
SignalCarAdapter.Stop(car);
- Diagnosis.Log($"{jgName} 机构不允许进入,监控到AGV启动,暂停AGV{car.id}", "JGControl", true);
+ Diagnosis.Log($"{rt.DisplayName} 机构不允许进入,监控到AGV启动,暂停AGV{car.id}", "JGControl", true);
}
}
}
- /// 行驶中的 RequestIn 安全闸,条件与 RequestIn 后半段一致。
- private static void RequestInJudge(string jgName, Car car, Func find)
+ private static void RequestInJudge(string jgName, string dockId, Car car, Func find)
{
- var rt = find(jgName);
+ var rt = find(jgName, dockId);
if (rt == null)
{
Diagnosis.Log($"{jgName} 未能找到机构", "JGControl", true);
@@ -130,17 +126,14 @@ namespace StandardScene.Signal.Logic
SignalCarAdapter.IsMoving(car))
{
SignalCarAdapter.Stop(car);
- Diagnosis.Log($"{jgName} 机构不允许进入,监控到AGV启动,暂停AGV{car.id}", "JGControl", true);
+ Diagnosis.Log($"{rt.DisplayName} 机构不允许进入,监控到AGV启动,暂停AGV{car.id}", "JGControl", true);
}
}
}
- ///
- /// 到位。InAgvNo 已是本车则认为已报到,避免每秒重复写「到位」把 WriteSuccess 清掉。
- ///
- private static void Arrived(string jgName, Car car, Func find)
+ private static void Arrived(string jgName, string dockId, Car car, Func find)
{
- var rt = find(jgName);
+ var rt = find(jgName, dockId);
if (rt == null)
{
Diagnosis.Log($"{jgName} 未能找到机构", "JGControl", true);
@@ -151,7 +144,7 @@ namespace StandardScene.Signal.Logic
{
if (rt.InAgvNo == car.id)
return;
- Diagnosis.Log($"AGV{car.id}已到达机构{jgName}", "JGControl", true);
+ Diagnosis.Log($"AGV{car.id}已到达机构{rt.DisplayName}", "JGControl", true);
rt.WriteSignal = SignalWriteValue.到位;
rt.InAgvNo = car.id;
rt.RequestInAgvNo = 0;
diff --git a/StandardScene.Signal/Logic/MagControlAreaGate.cs b/StandardScene.Signal/Logic/MagControlAreaGate.cs
new file mode 100644
index 0000000..943374f
--- /dev/null
+++ b/StandardScene.Signal/Logic/MagControlAreaGate.cs
@@ -0,0 +1,189 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using SimpleLite.RCS.CarTypes;
+
+namespace StandardScene.Signal.Logic
+{
+ ///
+ /// 磁条管控区占用判定:他车占用「当前策略」管控站点列表的数量 ≥ 容量则不放行;本车不计入。
+ /// 磁条交管进程只对停在触发点的 MagCar 调用,满足后发启动。
+ ///
+ public static class MagControlAreaGate
+ {
+ public const byte StartStopPass = 1;
+ public const byte StartStopControlStart = 11;
+
+ private static readonly char[] SiteIdSeparators = { ',', ';', '|', ' ', '\t', '[', ']', '(', ')', '{', '}' };
+
+ public static byte NormalizeReleaseStartStop(byte value)
+ {
+ if (value == StartStopPass || value == StartStopControlStart)
+ return value;
+ return StartStopControlStart;
+ }
+
+ public static int NormalizeCapacity(int capacity)
+ {
+ return capacity < 1 ? 1 : capacity;
+ }
+
+ public static int[] ParseSiteIds(string text, int fallbackSiteId)
+ {
+ var ids = new List();
+ if (!string.IsNullOrWhiteSpace(text))
+ {
+ var parts = text.Split(SiteIdSeparators, StringSplitOptions.RemoveEmptyEntries);
+ foreach (var part in parts)
+ {
+ if (int.TryParse(part.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var id) &&
+ id > 0 && !ids.Contains(id))
+ {
+ ids.Add(id);
+ }
+ }
+ }
+
+ if (ids.Count == 0 && fallbackSiteId > 0)
+ ids.Add(fallbackSiteId);
+
+ return ids.ToArray();
+ }
+
+ public static string NormalizeCompatGroup(string value)
+ {
+ return (value ?? string.Empty).Trim();
+ }
+
+ public static bool SitesIntersect(IReadOnlyList a, IReadOnlyList b)
+ {
+ if (a == null || b == null || a.Count == 0 || b.Count == 0)
+ return false;
+ for (var i = 0; i < a.Count; i++)
+ {
+ var left = a[i];
+ if (left <= 0)
+ continue;
+ for (var j = 0; j < b.Count; j++)
+ {
+ if (b[j] == left)
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public static MagControlReleaseDecision Evaluate(
+ int selfCarId,
+ int triggerSit,
+ IReadOnlyList areaSiteIds,
+ int capacity,
+ byte releaseStartStop,
+ IReadOnlyList<(int CarId, IReadOnlyList OccupiedSiteIds)> occupancies)
+ {
+ var sites = areaSiteIds == null || areaSiteIds.Count == 0
+ ? (triggerSit > 0 ? new[] { triggerSit } : Array.Empty())
+ : areaSiteIds;
+ capacity = NormalizeCapacity(capacity);
+ releaseStartStop = NormalizeReleaseStartStop(releaseStartStop);
+
+ var others = 0;
+ if (occupancies != null)
+ {
+ foreach (var occupancy in occupancies)
+ {
+ if (occupancy.CarId == selfCarId)
+ continue;
+ if (OccupiesListedSites(occupancy.OccupiedSiteIds, sites))
+ others++;
+ }
+ }
+
+ var canRelease = others < capacity;
+ var areaId = string.Join(",", sites);
+ return new MagControlReleaseDecision
+ {
+ RuleFound = true,
+ CanRelease = canRelease,
+ AreaId = areaId,
+ Capacity = capacity,
+ OthersInArea = others,
+ ReleaseStartStop = releaseStartStop,
+ Reason = canRelease
+ ? "area clear"
+ : $"area occupied cars={others}, capacity={capacity}"
+ };
+ }
+
+ /// 统计占用管控区内部站点的车辆数,可排除排队车和在途车。
+ public static int CountOccupyingCars(
+ IReadOnlyList<(int CarId, IReadOnlyList OccupiedSiteIds)> occupancies,
+ IReadOnlyList areaSiteIds,
+ HashSet excludeCarIds)
+ {
+ if (occupancies == null || occupancies.Count == 0 ||
+ areaSiteIds == null || areaSiteIds.Count == 0)
+ return 0;
+
+ var others = 0;
+ foreach (var occupancy in occupancies)
+ {
+ if (excludeCarIds != null && excludeCarIds.Contains(occupancy.CarId))
+ continue;
+ if (OccupiesListedSites(occupancy.OccupiedSiteIds, areaSiteIds))
+ others++;
+ }
+
+ return others;
+ }
+
+ public static bool OccupiesListedSites(IReadOnlyList occupiedSiteIds, IReadOnlyList areaSiteIds)
+ {
+ if (occupiedSiteIds == null || occupiedSiteIds.Count == 0 ||
+ areaSiteIds == null || areaSiteIds.Count == 0)
+ return false;
+
+ for (var i = 0; i < occupiedSiteIds.Count; i++)
+ {
+ var occupied = occupiedSiteIds[i];
+ if (occupied <= 0)
+ continue;
+ for (var j = 0; j < areaSiteIds.Count; j++)
+ {
+ if (areaSiteIds[j] == occupied)
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /// 统计一辆车占用的站点:物理 siteID + holdingLocks。
+ public static IReadOnlyList CollectOccupiedSiteIds(Car car)
+ {
+ var ids = new List();
+ if (car == null)
+ return ids;
+ try
+ {
+ if (car.siteID > 0)
+ ids.Add(car.siteID);
+ }
+ catch
+ {
+ }
+
+ var holding = car.status?.holdingLocks;
+ if (holding == null)
+ return ids;
+ foreach (var lockId in holding)
+ {
+ if (lockId > 0 && !ids.Contains(lockId))
+ ids.Add(lockId);
+ }
+
+ return ids;
+ }
+ }
+}
diff --git a/StandardScene.Signal/Logic/MagControlRuntime.cs b/StandardScene.Signal/Logic/MagControlRuntime.cs
new file mode 100644
index 0000000..c593db5
--- /dev/null
+++ b/StandardScene.Signal/Logic/MagControlRuntime.cs
@@ -0,0 +1,479 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using SimpleCore.Library;
+using SimpleLite.RCS.CarTypes;
+using StandardScene.Signal.Model;
+using StandardScene.Signal.Plc;
+
+namespace StandardScene.Signal.Logic
+{
+ ///
+ /// 磁条交管运行态:按管控区名称分组排队;放行按「本线 + 冲突对向线」站点并集查当前占用。
+ /// 不维护 pending 在途占额。
+ ///
+ public sealed class MagControlRuntime
+ {
+ private readonly object _lock = new object();
+ private Dictionary _byTrigger = new Dictionary();
+ private readonly Dictionary _releaseLog = new Dictionary();
+ private readonly Dictionary _arrivals = new Dictionary();
+
+ public int RuleCount
+ {
+ get
+ {
+ lock (_lock)
+ return _byTrigger.Count;
+ }
+ }
+
+ /// 已不再使用 pending;保留属性避免旧 UI 绑定报错,恒为 0。
+ public int PendingCount => 0;
+
+ public void Replace(IEnumerable rows)
+ {
+ var map = (rows ?? Array.Empty())
+ .Where(x => x != null && x.TriggerSit > 0 && x.IsUse)
+ .GroupBy(x => x.TriggerSit)
+ .ToDictionary(g => g.Key, g => g.First());
+ lock (_lock)
+ {
+ _byTrigger = map;
+ _arrivals.Clear();
+ }
+ }
+
+ ///
+ /// 同名区排队;放行前查本线 + 冲突对向线站点并集上的当前占用。
+ /// 另计:已放行仍停在冲突触发点上的运行中车辆(避免 site 未进区时空窗连放)。
+ ///
+ public (int Waiting, int Released) ScanAndRelease()
+ {
+ List rules;
+ lock (_lock)
+ rules = _byTrigger.Values.ToList();
+
+ if (rules.Count == 0)
+ return (0, 0);
+
+ var snapshots = SnapshotCars();
+ var occupancies = snapshots
+ .Select(s => (s.CarId, s.OccupiedSiteIds))
+ .ToList();
+
+ var groups = rules
+ .GroupBy(AreaKey, StringComparer.OrdinalIgnoreCase)
+ .Select(g => BuildGroup(g.Key, g.ToList()))
+ .ToList();
+
+ lock (_lock)
+ SweepArrivals(snapshots);
+
+ var waiting = 0;
+ var released = 0;
+ foreach (var group in groups)
+ {
+ var waiters = CollectWaiters(group, snapshots);
+ waiting += waiters.Count;
+ if (waiters.Count == 0)
+ continue;
+
+ // 停稳排队的车不计入区内占用;本拍已放行的车单独记冲突占额
+ var exclude = new HashSet(waiters.Select(w => w.CarId));
+ var releasedThisScan = new List();
+
+ waiters.Sort((a, b) => CompareWaiters(a, b, group.WaitSeconds));
+ foreach (var waiter in waiters)
+ {
+ var checkSites = BuildConflictSites(group, waiter);
+ var capacity = MagControlAreaGate.NormalizeCapacity(waiter.Capacity);
+ var others = CountConflictingOccupancy(
+ group, waiter, checkSites, occupancies, snapshots, exclude, releasedThisScan);
+ if (others >= capacity)
+ continue;
+
+ SignalCarAdapter.StopCharge(waiter.Car);
+ SignalCarAdapter.StartMagCar(waiter.Car);
+ released++;
+ exclude.Add(waiter.CarId);
+ releasedThisScan.Add(waiter);
+ NoteRelease(waiter, group, others, capacity, checkSites);
+ }
+ }
+
+ return (waiting, released);
+ }
+
+ ///
+ /// 冲突占用 = 并集站点上的当前车
+ /// + 本区冲突触发点上仍「运行中」的车(刚放行、site 尚未进 ControlArea)
+ /// + 本拍已放行且与本线冲突的车。
+ ///
+ private static int CountConflictingOccupancy(
+ AreaGroup group,
+ Waiter waiter,
+ IReadOnlyList checkSites,
+ List<(int CarId, IReadOnlyList OccupiedSiteIds)> occupancies,
+ List snapshots,
+ HashSet exclude,
+ List releasedThisScan)
+ {
+ var others = MagControlAreaGate.CountOccupyingCars(occupancies, checkSites, exclude);
+ var counted = new HashSet();
+
+ foreach (var snap in snapshots)
+ {
+ if (snap == null || exclude.Contains(snap.CarId) || counted.Contains(snap.CarId))
+ continue;
+ if (!snap.IsMagCar)
+ continue;
+
+ // 已在并集站点上:CountOccupyingCars 已计,这里补「运行中仍在冲突触发点」
+ if (snap.IsStopped)
+ continue;
+ if (snap.Sit <= 0 || !group.Triggers.Contains(snap.Sit))
+ continue;
+ if (!group.RouteByTrigger.TryGetValue(snap.Sit, out var route))
+ continue;
+ if (!Conflicts(waiter.CompatGroup, waiter.AreaSites, route.CompatGroup, route.AreaSites))
+ continue;
+
+ // 若 site 已在 checkSites 里,上面 others 可能已计(触发点一般不在列表)
+ if (MagControlAreaGate.OccupiesListedSites(snap.OccupiedSiteIds, checkSites))
+ continue;
+
+ counted.Add(snap.CarId);
+ others++;
+ }
+
+ foreach (var releasedCar in releasedThisScan)
+ {
+ if (releasedCar == null || counted.Contains(releasedCar.CarId))
+ continue;
+ if (!Conflicts(
+ waiter.CompatGroup, waiter.AreaSites,
+ releasedCar.CompatGroup, releasedCar.AreaSites))
+ continue;
+ counted.Add(releasedCar.CarId);
+ others++;
+ }
+
+ return others;
+ }
+
+ /// 单车判定:本线 + 同名区内冲突对向线站点并集的当前占用。
+ public MagControlReleaseDecision Decide(int carId, int triggerSit)
+ {
+ MagControlAreaModel rule;
+ List sameArea;
+ lock (_lock)
+ {
+ if (triggerSit <= 0 || !_byTrigger.TryGetValue(triggerSit, out rule) || rule == null)
+ {
+ return new MagControlReleaseDecision
+ {
+ RuleFound = false,
+ Reason = "no mag-control rule"
+ };
+ }
+
+ var key = AreaKey(rule);
+ sameArea = _byTrigger.Values
+ .Where(r => string.Equals(AreaKey(r), key, StringComparison.OrdinalIgnoreCase))
+ .ToList();
+ }
+
+ var group = BuildGroup(AreaKey(rule), sameArea);
+ if (!group.RouteByTrigger.TryGetValue(triggerSit, out var route))
+ {
+ return new MagControlReleaseDecision
+ {
+ RuleFound = false,
+ Reason = "no mag-control route"
+ };
+ }
+
+ var waiter = new Waiter
+ {
+ CarId = carId,
+ TriggerSit = triggerSit,
+ CompatGroup = route.CompatGroup,
+ AreaSites = route.AreaSites,
+ Capacity = route.Capacity
+ };
+ var checkSites = BuildConflictSites(group, waiter);
+ var occupancies = new List<(int CarId, IReadOnlyList OccupiedSiteIds)>();
+ foreach (var car in SignalCarAdapter.All())
+ {
+ if (car == null)
+ continue;
+ occupancies.Add((car.id, MagControlAreaGate.CollectOccupiedSiteIds(car)));
+ }
+
+ return MagControlAreaGate.Evaluate(
+ carId,
+ triggerSit,
+ checkSites,
+ rule.ControlCapacity,
+ rule.ControlReleaseStartStop,
+ occupancies);
+ }
+
+ internal static string AreaKey(MagControlAreaModel rule)
+ {
+ if (rule == null)
+ return "#sit:0";
+ var name = (rule.AreaName ?? string.Empty).Trim();
+ return name.Length > 0 ? name : "#sit:" + rule.TriggerSit;
+ }
+
+ internal static int CompareWaiters(Waiter a, Waiter b, int waitSeconds)
+ {
+ if (waitSeconds > 0)
+ {
+ if (a.Priority < b.Priority &&
+ (b.ArrivedAt - a.ArrivedAt).TotalSeconds > waitSeconds)
+ return -1;
+ if (b.Priority < a.Priority &&
+ (a.ArrivedAt - b.ArrivedAt).TotalSeconds > waitSeconds)
+ return 1;
+ }
+
+ var byPriority = b.Priority.CompareTo(a.Priority);
+ if (byPriority != 0)
+ return byPriority;
+ var byArrival = a.ArrivedAt.CompareTo(b.ArrivedAt);
+ if (byArrival != 0)
+ return byArrival;
+ return a.CarId.CompareTo(b.CarId);
+ }
+
+ internal static bool Conflicts(
+ string compatA,
+ IReadOnlyList sitesA,
+ string compatB,
+ IReadOnlyList sitesB)
+ {
+ if (string.Equals(compatA ?? "", compatB ?? "", StringComparison.OrdinalIgnoreCase))
+ return true;
+ return MagControlAreaGate.SitesIntersect(sitesA, sitesB);
+ }
+
+ ///
+ /// 本线站点 + 同名区内所有与本线冲突的对向/交叉线路站点并集。
+ /// 例如西环 NS-W 会并入东/北/南列表,100/101/104 有车时都能挡住触发点 4。
+ ///
+ private static IReadOnlyList BuildConflictSites(AreaGroup group, Waiter waiter)
+ {
+ var set = new HashSet();
+ if (waiter?.AreaSites != null)
+ {
+ foreach (var site in waiter.AreaSites)
+ {
+ if (site > 0)
+ set.Add(site);
+ }
+ }
+
+ if (group?.RouteByTrigger == null)
+ return set.OrderBy(x => x).ToList();
+
+ foreach (var route in group.RouteByTrigger.Values)
+ {
+ if (route?.AreaSites == null)
+ continue;
+ if (!Conflicts(waiter.CompatGroup, waiter.AreaSites, route.CompatGroup, route.AreaSites))
+ continue;
+ foreach (var site in route.AreaSites)
+ {
+ if (site > 0)
+ set.Add(site);
+ }
+ }
+
+ return set.OrderBy(x => x).ToList();
+ }
+
+ private static AreaGroup BuildGroup(string key, List rows)
+ {
+ var ordered = rows.OrderBy(r => r.TriggerSit).ToList();
+ var triggers = new HashSet(ordered.Select(r => r.TriggerSit));
+ var waitSeconds = 0;
+ var routeByTrigger = new Dictionary();
+ foreach (var row in ordered)
+ {
+ waitSeconds = Math.Max(waitSeconds, row.WaitSeconds);
+ var sites = MagControlAreaGate.ParseSiteIds(row.ControlArea, row.TriggerSit);
+ routeByTrigger[row.TriggerSit] = new RouteInfo
+ {
+ CompatGroup = MagControlAreaGate.NormalizeCompatGroup(row.CompatGroup),
+ Capacity = MagControlAreaGate.NormalizeCapacity(row.ControlCapacity),
+ AreaSites = sites,
+ Priority = row.Priority
+ };
+ }
+
+ return new AreaGroup
+ {
+ Key = key,
+ WaitSeconds = waitSeconds,
+ Triggers = triggers,
+ RouteByTrigger = routeByTrigger
+ };
+ }
+
+ private List SnapshotCars()
+ {
+ var list = new List();
+ foreach (var car in SignalCarAdapter.All())
+ {
+ if (car == null)
+ continue;
+ list.Add(new CarSnapshot
+ {
+ Car = car,
+ CarId = car.id,
+ OccupiedSiteIds = MagControlAreaGate.CollectOccupiedSiteIds(car),
+ IsMagCar = SignalCarAdapter.IsMagCar(car),
+ IsStopped = SignalCarAdapter.IsStopped(car),
+ Sit = SignalCarAdapter.PositionId(car)
+ });
+ }
+
+ return list;
+ }
+
+ private List CollectWaiters(AreaGroup group, List snapshots)
+ {
+ var waiters = new List();
+ var now = DateTime.Now;
+ foreach (var snap in snapshots)
+ {
+ if (!snap.IsMagCar || !snap.IsStopped)
+ continue;
+ if (snap.Sit <= 0 || !group.Triggers.Contains(snap.Sit))
+ continue;
+
+ DateTime arrivedAt;
+ lock (_lock)
+ {
+ if (!_arrivals.TryGetValue(snap.CarId, out var arrival) || arrival.TriggerSit != snap.Sit)
+ {
+ arrival = new Arrival { TriggerSit = snap.Sit, ArrivedAt = now };
+ _arrivals[snap.CarId] = arrival;
+ }
+
+ arrivedAt = arrival.ArrivedAt;
+ }
+
+ if (!group.RouteByTrigger.TryGetValue(snap.Sit, out var route))
+ continue;
+
+ waiters.Add(new Waiter
+ {
+ Car = snap.Car,
+ CarId = snap.CarId,
+ TriggerSit = snap.Sit,
+ AreaKey = group.Key,
+ CompatGroup = route.CompatGroup,
+ Capacity = route.Capacity,
+ AreaSites = route.AreaSites,
+ Priority = route.Priority,
+ ArrivedAt = arrivedAt
+ });
+ }
+
+ return waiters;
+ }
+
+ private void SweepArrivals(List snapshots)
+ {
+ var present = new HashSet();
+ foreach (var snap in snapshots)
+ {
+ if (!snap.IsMagCar || !snap.IsStopped || snap.Sit <= 0)
+ continue;
+ if (_arrivals.TryGetValue(snap.CarId, out var arrival) && arrival.TriggerSit == snap.Sit)
+ present.Add(snap.CarId);
+ }
+
+ var stale = _arrivals.Keys.Where(id => !present.Contains(id)).ToList();
+ foreach (var id in stale)
+ _arrivals.Remove(id);
+ }
+
+ private void NoteRelease(
+ Waiter waiter,
+ AreaGroup group,
+ int others,
+ int capacity,
+ IReadOnlyList checkSites)
+ {
+ var groupName = string.IsNullOrEmpty(waiter.CompatGroup) ? "-" : waiter.CompatGroup;
+ var carName = waiter.Car?.name;
+ var namePart = string.IsNullOrWhiteSpace(carName) ? "" : $" name={carName}";
+ var sites = waiter.AreaSites == null || waiter.AreaSites.Count == 0
+ ? "-"
+ : string.Join(",", waiter.AreaSites);
+ var conflict = checkSites == null || checkSites.Count == 0
+ ? "-"
+ : string.Join(",", checkSites);
+ var message =
+ $"放行 MagCar={waiter.CarId}{namePart} 区={group.Key} 组={groupName} 触发点={waiter.TriggerSit} " +
+ $"本线={sites} 冲突并集={conflict} 当前占用={others}/{capacity}";
+
+ MagTrafficFileLogger.Write(carName, waiter.CarId, message);
+
+ if (_releaseLog.TryGetValue(waiter.CarId, out var last) && (DateTime.Now - last).TotalSeconds < 5)
+ return;
+ _releaseLog[waiter.CarId] = DateTime.Now;
+ Diagnosis.Log($"磁条交管{message}", "MagTraffic", true);
+ }
+
+ internal sealed class Waiter
+ {
+ public Car Car;
+ public int CarId;
+ public int TriggerSit;
+ public string AreaKey;
+ public string CompatGroup;
+ public int Capacity;
+ public IReadOnlyList AreaSites;
+ public int Priority;
+ public DateTime ArrivedAt;
+ }
+
+ private sealed class Arrival
+ {
+ public int TriggerSit;
+ public DateTime ArrivedAt;
+ }
+
+ private sealed class RouteInfo
+ {
+ public string CompatGroup;
+ public int Capacity;
+ public IReadOnlyList AreaSites;
+ public int Priority;
+ }
+
+ private sealed class AreaGroup
+ {
+ public string Key;
+ public int WaitSeconds;
+ public HashSet Triggers;
+ public Dictionary RouteByTrigger;
+ }
+
+ private sealed class CarSnapshot
+ {
+ public Car Car;
+ public int CarId;
+ public IReadOnlyList OccupiedSiteIds;
+ public bool IsMagCar;
+ public bool IsStopped;
+ public int Sit;
+ }
+ }
+}
diff --git a/StandardScene.Signal/Logic/MagTrafficFileLogger.cs b/StandardScene.Signal/Logic/MagTrafficFileLogger.cs
new file mode 100644
index 0000000..fe58f52
--- /dev/null
+++ b/StandardScene.Signal/Logic/MagTrafficFileLogger.cs
@@ -0,0 +1,136 @@
+using System;
+using System.Collections.Concurrent;
+using System.IO;
+using System.Linq;
+using System.Text;
+
+namespace StandardScene.Signal.Logic
+{
+ ///
+ /// 磁条交管放行留档,按车名分目录:log/mag-traffic/car{name}/release_yyyyMMdd.log
+ /// 进程启停等写到 log/mag-traffic/_sys/sys_yyyyMMdd.log
+ ///
+ public static class MagTrafficFileLogger
+ {
+ private static readonly ConcurrentDictionary CarLocks = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase);
+ private static readonly object SysLock = new object();
+ private static readonly char[] InvalidNameChars = Path.GetInvalidFileNameChars();
+ private static string _baseDirectory = "log";
+ private static bool _enabled = true;
+
+ public static void Configure(string baseDirectory, bool enabled)
+ {
+ _baseDirectory = string.IsNullOrWhiteSpace(baseDirectory) ? "log" : baseDirectory.Trim();
+ _enabled = enabled;
+ }
+
+ public static string GetLogDirectory()
+ {
+ return Path.Combine(ResolveBaseDirectory(), "mag-traffic");
+ }
+
+ /// 目录名 car{name};名称为空时回退 car{id}。
+ public static string GetCarFolderKey(string carName, int carId)
+ {
+ var safe = SanitizeName(carName);
+ if (string.IsNullOrWhiteSpace(safe))
+ {
+ safe = carId > 0 ? carId.ToString() : "unknown";
+ }
+
+ return "car" + safe;
+ }
+
+ public static string GetCarLogDirectory(string carName, int carId = 0)
+ {
+ return Path.Combine(GetLogDirectory(), GetCarFolderKey(carName, carId));
+ }
+
+ public static string GetCurrentCarLogFilePath(string carName, int carId = 0)
+ {
+ return Path.Combine(GetCarLogDirectory(carName, carId), $"release_{DateTime.Now:yyyyMMdd}.log");
+ }
+
+ public static string GetCurrentSysLogFilePath()
+ {
+ return Path.Combine(GetLogDirectory(), "_sys", $"sys_{DateTime.Now:yyyyMMdd}.log");
+ }
+
+ /// 按车名写入放行记录。
+ public static void Write(string carName, int carId, string message)
+ {
+ if (!_enabled || string.IsNullOrWhiteSpace(message))
+ {
+ return;
+ }
+
+ var folderKey = GetCarFolderKey(carName, carId);
+ var carLock = CarLocks.GetOrAdd(folderKey, _ => new object());
+ lock (carLock)
+ {
+ try
+ {
+ var directory = Path.Combine(GetLogDirectory(), folderKey);
+ Directory.CreateDirectory(directory);
+ var path = Path.Combine(directory, $"release_{DateTime.Now:yyyyMMdd}.log");
+ File.AppendAllText(
+ path,
+ $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] {message}{Environment.NewLine}",
+ new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
+ }
+ catch
+ {
+ }
+ }
+ }
+
+ /// 进程启停、重载配置等非单车事件。
+ public static void WriteSystem(string message)
+ {
+ if (!_enabled || string.IsNullOrWhiteSpace(message))
+ {
+ return;
+ }
+
+ lock (SysLock)
+ {
+ try
+ {
+ var path = GetCurrentSysLogFilePath();
+ Directory.CreateDirectory(Path.GetDirectoryName(path) ?? GetLogDirectory());
+ File.AppendAllText(
+ path,
+ $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] {message}{Environment.NewLine}",
+ new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
+ }
+ catch
+ {
+ }
+ }
+ }
+
+ private static string SanitizeName(string name)
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ return "";
+ }
+
+ var chars = name.Trim().Select(ch => InvalidNameChars.Contains(ch) || ch <= 31 ? '_' : ch).ToArray();
+ var safe = new string(chars).Trim('.', ' ');
+ while (safe.Contains("__"))
+ {
+ safe = safe.Replace("__", "_");
+ }
+
+ return safe.Length > 80 ? safe.Substring(0, 80) : safe;
+ }
+
+ private static string ResolveBaseDirectory()
+ {
+ return Path.IsPathRooted(_baseDirectory)
+ ? _baseDirectory
+ : Path.Combine(AppContext.BaseDirectory, _baseDirectory);
+ }
+ }
+}
diff --git a/StandardScene.Signal/Logic/PointControlHandler.cs b/StandardScene.Signal/Logic/PointControlHandler.cs
index 1ff2fb1..bd7cc14 100644
--- a/StandardScene.Signal/Logic/PointControlHandler.cs
+++ b/StandardScene.Signal/Logic/PointControlHandler.cs
@@ -18,10 +18,10 @@ namespace StandardScene.Signal.Logic
public static class PointControlHandler
{
/// 停稳:先判机构是否允许离开,再下发销钉,全部到位后启动。
- public static void OnStopped(Car car, SignalStopSpec spec, Func find)
+ public static void OnStopped(Car car, SignalStopSpec spec, Func find)
{
- var action = spec.Part(0).Trim();
- var jgName = spec.Part(1).Trim();
+ var action = spec.Action;
+ var jgName = spec.JgName;
if (string.IsNullOrEmpty(action))
{
Diagnosis.Log($"AGV{car.id}停在地标{SignalCarAdapter.PositionId(car)},读取配置特殊点名称为空", "SetPin", true);
@@ -31,7 +31,7 @@ namespace StandardScene.Signal.Logic
var waitLeave = false;
if (!string.IsNullOrEmpty(jgName))
{
- var rt = find(jgName);
+ var rt = find(jgName, spec.DockId);
if (rt != null)
{
lock (rt.Sync)
diff --git a/StandardScene.Signal/Logic/SignalStopSpec.cs b/StandardScene.Signal/Logic/SignalStopSpec.cs
index 03d0e85..4e55b22 100644
--- a/StandardScene.Signal/Logic/SignalStopSpec.cs
+++ b/StandardScene.Signal/Logic/SignalStopSpec.cs
@@ -4,30 +4,24 @@ using StandardScene.Signal.Model;
namespace StandardScene.Signal.Logic
{
///
- /// 一条握手点配置的解析结果。参数格式与反编译 SignalStop.SignalParams 一致。
+ /// 一条握手点配置的解析结果。新列(机构/工位/动作/附加参数)优先,旧 SignalParams 作回退。
///
- ///
- /// 按分号切成 ,缺段时 返回空串。
- /// 各类型约定:
- ///
- /// - JGControl:机构名;RequestIn|Arrived;[区域-车组-限额-点列表]
- /// - PointControl:UP1|UP2|Down1|Down2;机构名
- /// - TimedCharging:最长秒数;上限电量;下限电量
- /// - RateFlowControl:区域;车组;限额;点1,点2
- ///
- ///
public sealed class SignalStopSpec
{
- /// 握手类型名,与 Handler 的 比较时忽略大小写。
public string SignalType { get; init; } = "";
- /// 原始参数串,限流独立类型会整段交给 。
public string SignalParams { get; init; } = "";
- /// 按 ';' 切分,保留空段(与 Split None 一致,方便 Part(n) 按下标取)。
public string[] Parts { get; init; } = Array.Empty();
- /// 取第 段;越界或 null 返回空串。
+ public string JgName { get; init; } = "";
+
+ public string DockId { get; init; } = "";
+
+ public string Action { get; init; } = "";
+
+ public string ExtraParams { get; init; } = "";
+
public string Part(int index)
{
if (index < 0 || index >= Parts.Length)
@@ -35,29 +29,102 @@ namespace StandardScene.Signal.Logic
return Parts[index] ?? "";
}
- /// 从类型名和参数串构造;类型会 Trim,参数保持原样以便日志对照现场 JSON。
public static SignalStopSpec Parse(string signalType, string signalParams)
{
var type = (signalType ?? "").Trim();
var raw = signalParams ?? "";
- return new SignalStopSpec
+ var parsed = new SignalStopSpec
{
SignalType = type,
SignalParams = raw,
Parts = raw.Split(new[] { ';' }, StringSplitOptions.None)
};
+ return EnrichFromParts(parsed);
}
- /// 从握手点 Model 构造;model 为 null 时返回 null。
public static SignalStopSpec From(HandshakePointModel model)
{
if (model == null)
return null;
- return Parse(model.SignalType, model.SignalParams);
+
+ var type = (model.SignalType ?? "").Trim();
+ if (string.Equals(type, "TimedCharging", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(type, "RateFlowControl", StringComparison.OrdinalIgnoreCase))
+ {
+ var raw = First(model.ExtraParams, model.SignalParams);
+ return Parse(type, raw);
+ }
+
+ var parsed = Parse(type, model.SignalParams ?? "");
+ var jg = First(model.JgName, parsed.JgName);
+ var action = First(model.Action, parsed.Action);
+ var dock = First(model.DockId, parsed.DockId);
+ var extra = First(model.ExtraParams, parsed.ExtraParams);
+ return new SignalStopSpec
+ {
+ SignalType = parsed.SignalType,
+ SignalParams = parsed.SignalParams,
+ Parts = parsed.Parts,
+ JgName = jg,
+ DockId = dock,
+ Action = action,
+ ExtraParams = extra
+ };
}
- /// 类型名是否匹配(忽略大小写)。
public bool Is(string type) =>
string.Equals(SignalType, type, StringComparison.OrdinalIgnoreCase);
+
+ private static SignalStopSpec EnrichFromParts(SignalStopSpec parsed)
+ {
+ var type = parsed.SignalType;
+ if (string.Equals(type, "TimedCharging", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(type, "RateFlowControl", StringComparison.OrdinalIgnoreCase))
+ {
+ return new SignalStopSpec
+ {
+ SignalType = parsed.SignalType,
+ SignalParams = parsed.SignalParams,
+ Parts = parsed.Parts,
+ ExtraParams = parsed.SignalParams
+ };
+ }
+
+ var point = string.Equals(type, "PointControl", StringComparison.OrdinalIgnoreCase);
+ var jg = point ? parsed.Part(1).Trim() : parsed.Part(0).Trim();
+ var action = point ? parsed.Part(0).Trim() : parsed.Part(1).Trim();
+ var dock = "";
+ var extra = "";
+ var p2 = parsed.Part(2).Trim();
+ if (LooksLikeDock(p2))
+ dock = p2;
+ else if (LooksLikeRateFlow(p2))
+ extra = p2;
+ if (string.IsNullOrEmpty(extra))
+ extra = parsed.Part(3).Trim();
+ return new SignalStopSpec
+ {
+ SignalType = parsed.SignalType,
+ SignalParams = parsed.SignalParams,
+ Parts = parsed.Parts,
+ JgName = jg,
+ DockId = dock,
+ Action = action,
+ ExtraParams = extra
+ };
+ }
+
+ private static string First(string a, string b)
+ {
+ if (!string.IsNullOrWhiteSpace(a))
+ return a.Trim();
+ return (b ?? "").Trim();
+ }
+
+ private static bool LooksLikeRateFlow(string s) =>
+ !string.IsNullOrEmpty(s) && s.IndexOf('-') >= 0;
+
+ private static bool LooksLikeDock(string s) =>
+ !string.IsNullOrEmpty(s) && s.IndexOf('-') < 0 && s.IndexOf(',') < 0;
}
}
diff --git a/StandardScene.Signal/Mission/MagTrafficMission.cs b/StandardScene.Signal/Mission/MagTrafficMission.cs
new file mode 100644
index 0000000..ca1ea86
--- /dev/null
+++ b/StandardScene.Signal/Mission/MagTrafficMission.cs
@@ -0,0 +1,193 @@
+using System;
+using System.IO;
+using System.Threading;
+using LessokajiWeaverUtilities.Utilities;
+using Newtonsoft.Json;
+using SimpleCore;
+using SimpleCore.Library;
+using SimpleLite.RCS;
+using StandardScene.Signal.Logic;
+using StandardScene.Signal.Model;
+using StandardScene.Signal.Ui;
+using StandardScene.Utils;
+
+namespace StandardScene.Signal
+{
+ ///
+ /// 磁条交管进程:只针对 MagCar。同区名排队后,按「本线 + 冲突对向线」站点并集查占用,容量允许则发启动。
+ ///
+ ///
+ /// 配置 Config/Signal/mag-control-areas.json。
+ /// 同「管控区名称」绑成一个路口排队;放行时合并本线与冲突对向线的管控站点列表统计当前占用。
+ /// 不使用 pending 在途占额。不处理 Mag2Car。
+ ///
+ [MissionType(Name = "磁条交管进程", editor = typeof(MagTrafficMission))]
+ [I18N.DocumentTranslation(Name = "Magnetic Traffic Mission", locale = "en")]
+ public class MagTrafficMission : Mission
+ {
+ /// 扫车周期(毫秒)。停在触发点的 MagCar 每拍排队判定一次。
+ [FieldMember] public int TickMs = 1000;
+
+ /// 管控区表路径。相对路径只解析工作目录 Config/Signal。
+ [FieldMember] public string AreasPath = @"Config\Signal\mag-control-areas.json";
+
+ /// 放行日志根目录。实际按车名落在其下 mag-traffic/car{name}/release_yyyyMMdd.log。
+ [FieldMember] public string LogDirectory = "log";
+
+ /// 是否把交管放行写入本地文件留档。
+ [FieldMember] public bool EnableFileLog = true;
+
+ [JsonIgnore] private bool _started;
+ [JsonIgnore] private readonly MagControlRuntime _runtime = new MagControlRuntime();
+ [JsonIgnore] private CancellationTokenSource _cts;
+ [JsonIgnore] private Thread _thread;
+
+ public class MagTrafficMissionStatus : MissionStatus
+ {
+ public int RuleCount { get; set; }
+ public int WaitingAtTrigger { get; set; }
+ public int PendingInFlight { get; set; }
+ public int ScanTickCount { get; set; }
+ public string AreasFile { get; set; }
+ }
+
+ public override MissionStatus status { get; set; } = new MagTrafficMissionStatus();
+
+ public static Mission Create() => new MagTrafficMission();
+
+ [MethodMember(Name = "启动进程", Description = "加载管控区表,按区名排队后对 MagCar 发放行")]
+ public override void Execute()
+ {
+ try
+ {
+ MagTrafficFileLogger.Configure(LogDirectory, EnableFileLog);
+ ReloadConfig();
+ _started = true;
+ StartWatch();
+ MagTrafficFileLogger.WriteSystem($"进程启动 规则={((MagTrafficMissionStatus)status).RuleCount} 配置={((MagTrafficMissionStatus)status).AreasFile}");
+ Diagnosis.Post(
+ $"磁条交管进程已启动,规则={((MagTrafficMissionStatus)status).RuleCount}(仅 MagCar),放行日志={MagTrafficFileLogger.GetLogDirectory()}",
+ "MagTraffic", true);
+ }
+ catch (Exception ex)
+ {
+ status.status = "启动失败";
+ Diagnosis.Post($"磁条交管进程启动失败:{ExceptionFormatter.FormatEx(ex)}", "MagTraffic", true);
+ throw;
+ }
+ }
+
+ [MethodMember(Name = "重新加载配置", Description = "重新读取 mag-control-areas.json")]
+ public void ReloadConfig()
+ {
+ var file = SignalConfigStore.Resolve(AreasPath);
+ var rows = SignalConfigStore.Load(file);
+ _runtime.Replace(rows);
+
+ var st = (MagTrafficMissionStatus)status;
+ st.AreasFile = file;
+ st.RuleCount = _runtime.RuleCount;
+ st.PendingInFlight = _runtime.PendingCount;
+ status.status = _started ? "运行中" : "配置已加载";
+ MagTrafficFileLogger.Configure(LogDirectory, EnableFileLog);
+ MagTrafficFileLogger.WriteSystem($"重新加载配置 规则={st.RuleCount} 文件={file}");
+ Diagnosis.Post($"磁条交管配置已加载:启用规则 {st.RuleCount} @ {file}", "MagTraffic", true);
+ }
+
+ [MethodMember(Name = "停止进程")]
+ public new void Stop()
+ {
+ _started = false;
+ StopWatch();
+ status.status = "已停止";
+ MagTrafficFileLogger.WriteSystem("进程停止");
+ Diagnosis.Post("磁条交管进程已停止", "MagTraffic", true);
+ }
+
+ [MethodMember(Name = "打开放行日志目录", Description = "打开 mag-traffic 目录(按 car{name} 分子目录),便于按车查阅放行记录")]
+ public void OpenReleaseLogFolder()
+ {
+ try
+ {
+ MagTrafficFileLogger.Configure(LogDirectory, EnableFileLog);
+ var dir = MagTrafficFileLogger.GetLogDirectory();
+ Directory.CreateDirectory(dir);
+ System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
+ {
+ FileName = dir,
+ UseShellExecute = true
+ });
+ }
+ catch (Exception ex)
+ {
+ CycleUiHelper.Alert("错误", $"打开放行日志目录失败:{ex.Message}");
+ }
+ }
+
+ [MethodMember(Name = "打开管控区列表", Description = "编辑区名 / 兼容组 / 优先级 / 等待时长 / 触发点 / 区内站点 / 容量")]
+ public void OpenAreas()
+ {
+ try
+ {
+ var path = SignalConfigStore.Resolve(AreasPath);
+ ModelListPanel.Open(
+ "磁条管控区配置",
+ path,
+ x => x == null ? "" : x.TriggerSit.ToString());
+ }
+ catch (Exception ex)
+ {
+ CycleUiHelper.Alert("错误", $"打开管控区列表失败:{ex.Message}");
+ }
+ }
+
+ private void StartWatch()
+ {
+ StopWatch();
+ _cts = new CancellationTokenSource();
+ _thread = new Thread(WatchLoop)
+ {
+ IsBackground = true,
+ Name = "MagTrafficWatch"
+ };
+ _thread.Start();
+ }
+
+ private void StopWatch()
+ {
+ try { _cts?.Cancel(); }
+ catch { }
+ try { _thread?.Join(2000); }
+ catch { }
+ _thread = null;
+ }
+
+ private void WatchLoop()
+ {
+ var token = _cts.Token;
+ var tick = 0;
+ var interval = Math.Max(200, TickMs);
+ while (!token.IsCancellationRequested)
+ {
+ try
+ {
+ var (waiting, _) = _runtime.ScanAndRelease();
+ var st = (MagTrafficMissionStatus)status;
+ st.WaitingAtTrigger = waiting;
+ st.PendingInFlight = _runtime.PendingCount;
+ st.RuleCount = _runtime.RuleCount;
+ st.ScanTickCount = ++tick;
+ if (_started)
+ status.status = $"运行中 规则{st.RuleCount} 等待{waiting}";
+ }
+ catch (Exception ex)
+ {
+ Diagnosis.Log($"磁条交管扫车异常:{ex.Message}", "MagTraffic", true);
+ }
+
+ try { Thread.Sleep(interval); }
+ catch (ThreadInterruptedException) { break; }
+ }
+ }
+ }
+}
diff --git a/StandardScene.Signal/Mission/SignalInteractMission.cs b/StandardScene.Signal/Mission/SignalInteractMission.cs
index 33db39c..1540e11 100644
--- a/StandardScene.Signal/Mission/SignalInteractMission.cs
+++ b/StandardScene.Signal/Mission/SignalInteractMission.cs
@@ -16,7 +16,7 @@ using StandardScene.Utils;
namespace StandardScene.Signal
{
///
- /// 第三方信号交互进程(场景 scene.signal 的唯一 Mission)。
+ /// 第三方信号交互进程(场景 scene.signal:PLC 握手 / 放行;磁条交管见 MagTrafficMission)。
///
///
///
@@ -65,9 +65,9 @@ namespace StandardScene.Signal
///
/// 西门子系列,传给 IoTClient SiemensVersion。
- /// 常用 S7_1500 / S7_1200 / S7_300;连字符会当成下划线解析。
+ /// 机构表 PLC类型为「跟随进程」时使用本项;机构单独指定则用机构值。
///
- [FieldMember] public string PlcVersion = "S7_1500";
+ [FieldMember] public SignalPlcType PlcVersion = SignalPlcType.S7_1500;
/// 机构未单独填 Port 时使用的 S7 端口,默认 102。
[FieldMember] public int PlcPort = 102;
@@ -84,6 +84,9 @@ namespace StandardScene.Signal
/// 握手点表路径。按站点配置 JGControl / PointControl / TimedCharging / RateFlowControl。
[FieldMember] public string HandshakePointsPath = @"Config\Signal\handshake-points.json";
+ /// 机构工位表。相对路径只解析工作目录 Config/Signal。
+ [FieldMember] public string DocksPath = @"Config\Signal\station-docks.json";
+
/// Execute 成功后为 true;ReloadConfig 仅在已启动时重启 Worker / 扫车。
[JsonIgnore] private bool _started;
@@ -126,6 +129,12 @@ namespace StandardScene.Signal
/// 实际解析到的握手点文件绝对路径。
public string HandshakePointsFile { get; set; }
+ /// 实际解析到的工位文件绝对路径。
+ public string DocksFile { get; set; }
+
+ /// 工位 JSON 条数。
+ public int DockCount { get; set; }
+
/// 各机构运行态快照,供监视列表展示。
public List Stations { get; set; } = new List();
}
@@ -170,23 +179,26 @@ namespace StandardScene.Signal
public void ReloadConfig()
{
var stationsFile = SignalConfigStore.Resolve(StationsPath);
+ var docksFile = SignalConfigStore.Resolve(DocksPath);
var releaseFile = SignalConfigStore.Resolve(ReleasePointsPath);
var handshakeFile = SignalConfigStore.Resolve(HandshakePointsPath);
- var stations = SignalConfigStore.Load(stationsFile);
+ var bundles = PlcConfigLoader.Load(stationsFile, docksFile);
var points = SignalConfigStore.Load(releaseFile);
var handshake = SignalConfigStore.Load(handshakeFile);
var st = (SignalInteractMissionStatus)status;
- st.StationCount = stations.Count;
+ st.StationCount = bundles.Count;
+ st.DockCount = bundles.Sum(b => b.Docks.Count);
st.ReleasePointCount = points.Count;
st.HandshakePointCount = handshake.Count;
st.StationsFile = stationsFile;
+ st.DocksFile = docksFile;
st.ReleasePointsFile = releaseFile;
st.HandshakePointsFile = handshakeFile;
- st.Stations = stations.Select(s => new PlcStationSnapshot { JgName = s.JgName }).ToList();
+ st.Stations = bundles.Select(b => new PlcStationSnapshot { JgName = b.Station.JgName }).ToList();
status.status = _started ? "运行中" : "配置已加载";
Diagnosis.Post(
- $"信号配置已加载:机构 {stations.Count};握手点 {handshake.Count} @ {handshakeFile};放行点 {points.Count} @ {releaseFile}",
+ $"信号配置已加载:机构 {bundles.Count},工位 {st.DockCount} @ {docksFile};握手点 {handshake.Count} @ {handshakeFile};放行点 {points.Count} @ {releaseFile}",
"Signal", true);
if (_started)
@@ -224,6 +236,24 @@ namespace StandardScene.Signal
}
}
+ /// CycleGUI 独立窗体编辑工位表;主键为机构名+工位编号。
+ [MethodMember(Name = "打开工位列表", Description = "按 PlcStationDockModel 增删改工位点位")]
+ public void OpenDocks()
+ {
+ try
+ {
+ var path = SignalConfigStore.Resolve(DocksPath);
+ ModelListPanel.Open(
+ "机构工位配置",
+ path,
+ x => x == null ? "" : $"{x.JgName}/{x.ResolvedDockId}");
+ }
+ catch (Exception ex)
+ {
+ CycleUiHelper.Alert("错误", $"打开工位列表失败:{ex.Message}");
+ }
+ }
+
/// CycleGUI 独立窗体编辑握手点;主键展示为「站点号 + 信号类型」。
[MethodMember(Name = "打开握手点列表", Description = "按站点配置 JGControl / PointControl / TimedCharging / RateFlowControl")]
public void OpenHandshakePoints()
@@ -266,7 +296,7 @@ namespace StandardScene.Signal
get
{
lock (_workerLock)
- return _workers.Select(w => w.Runtime).ToList();
+ return _workers.SelectMany(w => w.Docks).ToList();
}
}
@@ -285,25 +315,28 @@ namespace StandardScene.Signal
///
/// 先停全部旧连接,再按机构表为每个非空名称建 。
- /// 机构 Port<=0 时回退进程级 。
+ /// 机构 Port<=0 时回退进程级 ;PLC类型跟随进程时用 。
///
private void RestartWorkers()
{
StopWorkers();
- var stationsFile = SignalConfigStore.Resolve(StationsPath);
- var stations = SignalConfigStore.Load(stationsFile)
- .Where(s => !string.IsNullOrWhiteSpace(s.JgName))
- .ToList();
+ var bundles = PlcConfigLoader.Load(
+ SignalConfigStore.Resolve(StationsPath),
+ SignalConfigStore.Resolve(DocksPath));
var created = new List();
lock (_workerLock)
{
- foreach (var station in stations)
+ foreach (var bundle in bundles)
{
+ var station = bundle.Station;
var port = station.Port > 0 ? station.Port : PlcPort;
- var runtime = new PlcStationRuntime(station);
- var session = new SiemensPlcSession(PlcVersion, station.Ip, port, PlcSlot);
- var worker = new PlcStationWorker(runtime, session, PlcPollMs, RefreshStatus);
+ var sync = new object();
+ var runtimes = bundle.Docks
+ .Select(d => new PlcStationRuntime(station, d, sync))
+ .ToList();
+ var session = new SiemensPlcSession(ResolvePlcType(station), station.Ip, port, PlcSlot);
+ var worker = new PlcStationWorker(station, runtimes, session, sync, PlcPollMs, RefreshStatus);
_workers.Add(worker);
created.Add(worker);
}
@@ -315,6 +348,15 @@ namespace StandardScene.Signal
PushStatus();
}
+ /// 机构选跟随进程(含旧 JSON 缺省 0)时用进程 PlcVersion;进程若也是跟随进程则按 S7_1500。
+ private string ResolvePlcType(PlcStationModel station)
+ {
+ var type = station.PlcType == SignalPlcType.跟随进程 ? PlcVersion : station.PlcType;
+ if (type == SignalPlcType.跟随进程)
+ type = SignalPlcType.S7_1500;
+ return type.ToString();
+ }
+
/// 清空列表后再 Stop,避免扫车在停连接过程中仍拿到旧 Runtime。
private void StopWorkers()
{
@@ -345,7 +387,7 @@ namespace StandardScene.Signal
{
List snaps;
lock (_workerLock)
- snaps = _workers.Select(w => w.Runtime.ToSnapshot()).ToList();
+ snaps = _workers.SelectMany(w => w.Docks.Select(d => d.ToSnapshot())).ToList();
var st = (SignalInteractMissionStatus)status;
st.Stations = snaps;
diff --git a/StandardScene.Signal/Model/HandshakePointModel.cs b/StandardScene.Signal/Model/HandshakePointModel.cs
index 57b3b9e..3228cc0 100644
--- a/StandardScene.Signal/Model/HandshakePointModel.cs
+++ b/StandardScene.Signal/Model/HandshakePointModel.cs
@@ -1,41 +1,47 @@
using System.ComponentModel;
+using Newtonsoft.Json;
namespace StandardScene.Signal.Model
{
///
- /// 握手点配置。对应老地图点上的 SignalStop(SignalType + SignalParams)。
+ /// 握手点。JGControl / PointControl 填机构、工位、动作;定时充电 / 限流走附加参数。
+ /// 旧 JSON 仅有 SignalParams 时仍可解析。
///
- ///
- /// 站点 fields 若已填写同名键,扫车优先用站点字段,本表该站点不再生效。
- /// SignalParams 按分号切分,各类型格式见 。
- /// 列表主键展示为「站点号 + 类型」,同一站点建议只配一条。
- ///
+ [SignalTable("handshake", "handshake-points.json", "握手点", Order = 3)]
[Category("握手点数据管理")]
[DisplayName("握手点配置列表")]
public class HandshakePointModel
{
- /// 地图站点号,与车 PositionId(siteID / GetLastSite)对应。
[DisplayName("站点")]
public int Sit { get; set; }
- ///
- /// 握手类型:JGControl 机构进出、
- /// PointControl 销钉升降、
- /// TimedCharging 定时充电、
- /// RateFlowControl 区域限流。
- /// 其它字符串会每 10s 打一次「未找到业务模块」日志。
- ///
+ /// JGControl / PointControl / TimedCharging / RateFlowControl。
[DisplayName("信号类型")]
+ [SignalSelect("JGControl", "PointControl", "TimedCharging", "RateFlowControl")]
public string SignalType { get; set; } = "JGControl";
+ [DisplayName("机构名称")]
+ public string JgName { get; set; } = "";
+
+ [DisplayName("工位编号")]
+ public string DockId { get; set; } = "";
+
+ /// JGControl:RequestIn / Arrived;PointControl:UP1 / UP2 / Down1 / Down2。
+ [DisplayName("动作")]
+ [SignalSelect("RequestIn", "Arrived", "UP1", "UP2", "Down1", "Down2")]
+ public string Action { get; set; } = "";
+
///
- /// 类型参数。示例:
- /// JGControl 上线机构1;RequestIn / 上线机构1;Arrived / 第三段限流 缓存区-A-2-60,61;
- /// PointControl UP1;下线机构1;
- /// TimedCharging 300;95;20;
- /// RateFlowControl 缓存区;A;2;60,61,62。
+ /// TimedCharging:最长秒;上限电量;下限电量。
+ /// RateFlowControl:区域;车组;限额;点列表。
+ /// JGControl 可选限流:区域-车组-限额-点列表。
///
- [DisplayName("信号参数")]
- public string SignalParams { get; set; } = "";
+ [DisplayName("附加参数")]
+ public string ExtraParams { get; set; } = "";
+
+ /// 旧字段。新配置可空,加载时由上面几列拼回。
+ [Browsable(false)]
+ [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public string SignalParams { get; set; }
}
}
diff --git a/StandardScene.Signal/Model/MagControlAreaModel.cs b/StandardScene.Signal/Model/MagControlAreaModel.cs
new file mode 100644
index 0000000..85f5ce7
--- /dev/null
+++ b/StandardScene.Signal/Model/MagControlAreaModel.cs
@@ -0,0 +1,60 @@
+using System.ComponentModel;
+
+namespace StandardScene.Signal.Model
+{
+ ///
+ /// 磁条管控区一行。仅 MagCar:车在触发点停稳等待,进程按区名排队后发启动。
+ ///
+ ///
+ /// 主键为 。同 的多行绑成一个路口排队。
+ /// 区名为空时,该触发点自成一区(兼容旧表)。
+ /// 放行时查本行 与同区冲突对向线站点的并集当前占用;不用 pending。
+ /// 保留兼容,MagCar 放行走 FASS 1.0 启动指令,不使用该字节。
+ ///
+ [SignalTable("mag-control", "mag-control-areas.json", "磁条管控区", Order = 5)]
+ [Category("磁条交管")]
+ [DisplayName("管控区配置列表")]
+ public class MagControlAreaModel
+ {
+ /// 管控触发站点。MagCar 到此停稳后进入该区排队。
+ [DisplayName("管控触发站点")]
+ public int TriggerSit { get; set; }
+
+ /// 管控区名称。同名多行绑成一个路口;空则本触发点单独成区。
+ [DisplayName("管控区名称")]
+ public string AreaName { get; set; } = "";
+
+ ///
+ /// 兼容组。同区里互不干扰的线路填不同组名;会打架的填同组。
+ /// 空则与同区其他空组互斥。站点列表有交集时即使组名不同也互斥。
+ ///
+ [DisplayName("兼容组")]
+ public string CompatGroup { get; set; } = "";
+
+ /// 优先级,数值越大越先放。干道高、支路低。只在冲突线路之间生效。
+ [DisplayName("优先级")]
+ public int Priority { get; set; }
+
+ ///
+ /// 等待时长(秒)。同区取最长;≤0 不做插队。只在冲突线路之间生效。
+ ///
+ [DisplayName("等待时长")]
+ public int WaitSeconds { get; set; }
+
+ /// 本线路路口内部站点,如 10,11。不要把排队触发点写进来。空则回退触发点。
+ [DisplayName("管控区站点列表")]
+ public string ControlArea { get; set; } = "";
+
+ /// 本线路区内最多几辆。含占用本行站点的车和与本行冲突的在途车。默认 1。
+ [DisplayName("区内最多几辆")]
+ public int ControlCapacity { get; set; } = 1;
+
+ /// 兼容字段,MagCar 放行不使用。
+ [DisplayName("放行后启停字节")]
+ public byte ControlReleaseStartStop { get; set; } = 11;
+
+ /// false 时该条保留在 JSON 里但不生效。
+ [DisplayName("启用")]
+ public bool IsUse { get; set; } = true;
+ }
+}
diff --git a/StandardScene.Signal/Model/PlcStationDockModel.cs b/StandardScene.Signal/Model/PlcStationDockModel.cs
new file mode 100644
index 0000000..516266d
--- /dev/null
+++ b/StandardScene.Signal/Model/PlcStationDockModel.cs
@@ -0,0 +1,106 @@
+using System.ComponentModel;
+using Newtonsoft.Json;
+
+namespace StandardScene.Signal.Model
+{
+ ///
+ /// 机构工位:一块 DB 上的一组 BOOL 点 + 地标。同一机构可多行(dock1 / dock2 / …)。
+ ///
+ [SignalTable("docks", "station-docks.json", "机构工位", Order = 2)]
+ [Category("PLC数据管理")]
+ [DisplayName("机构工位列表(机构名称+工位编号不能重复)")]
+ public class PlcStationDockModel
+ {
+ /// 外键,对应 。
+ [Category("基本")]
+ [DisplayName("机构名称")]
+ public string JgName { get; set; } = "";
+
+ /// 机构内唯一。空则按 default。
+ [Category("基本")]
+ [DisplayName("工位编号")]
+ public string DockId { get; set; } = "dock1";
+
+ [Category("PLC 读 BOOL")]
+ [DisplayName("允许进入字节")]
+ public int AllowInByte { get; set; }
+
+ [Category("PLC 读 BOOL")]
+ [DisplayName("允许进入位")]
+ public int AllowInBit { get; set; }
+
+ [Category("PLC 读 BOOL")]
+ [DisplayName("允许离开字节")]
+ public int AllowOutByte { get; set; }
+
+ [Category("PLC 读 BOOL")]
+ [DisplayName("允许离开位")]
+ public int AllowOutBit { get; set; } = 1;
+
+ [Category("PLC 写 BOOL")]
+ [DisplayName("进入中字节")]
+ public int EnteringByte { get; set; } = 2;
+
+ [Category("PLC 写 BOOL")]
+ [DisplayName("进入中位")]
+ public int EnteringBit { get; set; }
+
+ [Category("PLC 写 BOOL")]
+ [DisplayName("到位字节")]
+ public int ArrivedByte { get; set; } = 2;
+
+ [Category("PLC 写 BOOL")]
+ [DisplayName("到位位")]
+ public int ArrivedBit { get; set; } = 1;
+
+ [Category("PLC 写 BOOL")]
+ [DisplayName("离开中字节")]
+ public int LeavingByte { get; set; } = 2;
+
+ [Category("PLC 写 BOOL")]
+ [DisplayName("离开中位")]
+ public int LeavingBit { get; set; } = 2;
+
+ [Category("PLC 写 BOOL")]
+ [DisplayName("已离开字节")]
+ public int LeftByte { get; set; } = 2;
+
+ [Category("PLC 写 BOOL")]
+ [DisplayName("已离开位")]
+ public int LeftBit { get; set; } = 3;
+
+ [Category("地标")]
+ [DisplayName("进入地标")]
+ public int JgInPointId { get; set; }
+
+ [Category("地标")]
+ [DisplayName("进入包含地标")]
+ public string JgInContains { get; set; } = "";
+
+ [Category("地标")]
+ [DisplayName("机构地标")]
+ public int JgPointId { get; set; }
+
+ [Category("地标")]
+ [DisplayName("机构包含地标")]
+ public string JgContains { get; set; } = "";
+
+ /// 旧配置合成:按一字节枚举读写,不走 BOOL。
+ [Browsable(false)]
+ [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public bool UseByteIo { get; set; }
+
+ /// 仅 UseByteIo 时使用的读字节地址。
+ [Browsable(false)]
+ [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public string ReadDb { get; set; }
+
+ /// 仅 UseByteIo 时使用的写字节地址。
+ [Browsable(false)]
+ [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public string WriteDb { get; set; }
+
+ public string ResolvedDockId =>
+ string.IsNullOrWhiteSpace(DockId) ? "default" : DockId.Trim();
+ }
+}
diff --git a/StandardScene.Signal/Model/PlcStationModel.cs b/StandardScene.Signal/Model/PlcStationModel.cs
index e215aff..6520722 100644
--- a/StandardScene.Signal/Model/PlcStationModel.cs
+++ b/StandardScene.Signal/Model/PlcStationModel.cs
@@ -1,17 +1,17 @@
using System.ComponentModel;
+using Newtonsoft.Json;
namespace StandardScene.Signal.Model
{
///
- /// PLC 机构静态配置,对应反编译 PLCModel 可存档字段。
- /// 读写运行态(当前字节、机构内车号)在 ,不进本类、不进 JSON。
- /// 机构名称不能重复,列表编辑器用 JgName 做主键。
+ /// PLC 机构:一把 S7 连接 + 一块 DB。点位和地标在 。
///
+ [SignalTable("stations", "stations.json", "PLC机构", Order = 1)]
[Category("PLC数据管理")]
[DisplayName("PLC配置列表(机构名称不能重复)")]
public class PlcStationModel
{
- /// 逻辑名,握手点 SignalParams 第一段、Worker 线程名都用它。不能空、不能重复。
+ /// 逻辑名,握手点机构名、Worker 线程名都用它。不能空、不能重复。
[DisplayName("机构名称")]
public string JgName { get; set; } = "";
@@ -23,53 +23,55 @@ namespace StandardScene.Signal.Model
[DisplayName("端口")]
public int Port { get; set; } = 102;
- ///
- /// 读字节地址(PLC→调度:允许进入/离开)。
- /// 支持 DB1.DBB0 或 Hsl 风格 DB1.0。空则跳过读取。
- ///
- [DisplayName("读地址")]
- public string ReadDb { get; set; } = "";
+ /// 西门子系列。跟随进程则用信号交互进程的 PlcVersion。
+ [DisplayName("PLC类型")]
+ public SignalPlcType PlcType { get; set; } = SignalPlcType.跟随进程;
- ///
- /// 写字节地址(调度→PLC:进入中/到位/离开中/已离开)。
- /// Worker 会先回读再按需写入。空则跳过写和回读。
- ///
- [DisplayName("写地址")]
- public string WriteDb { get; set; } = "";
+ /// DB 块号。工位 BOOL 与心跳都拼到这块 DB。
+ [DisplayName("DB块号")]
+ public int DbNumber { get; set; } = 100;
- ///
- /// 心跳位地址,如 DB1.DBX2.0。
- /// Worker 每拍读当前值再写反码,让 PLC 判断调度进程存活。空则不做心跳。
- ///
- [DisplayName("写心跳地址")]
- public string HeartDb { get; set; } = "";
+ /// 心跳字节偏移。未填 HeartDb 时拼 DB{n}.DBX{HeartByte}.{HeartBit}。
+ [DisplayName("心跳字节")]
+ public int HeartByte { get; set; } = 32;
- /// 机构进入地标(预留,与老 PLCModel 字段对齐;上线跟车主要用 JgInContains)。
- [DisplayName("机构进入地标")]
- public int JgInPointId { get; set; }
-
- ///
- /// 进入过程包含的站点列表(逗号/分号/空格分隔)。
- /// 上线:写字节=进入中 且车已不在此列表 → 视为到位。
- ///
- [DisplayName("机构进入包含地标")]
- public string JgInContains { get; set; } = "";
-
- ///
- /// 机构工作地标。下线 RequestIn 用它查「机构内是否已有车」;
- /// 下线/等待通行离站时,车必须停在此点才响应允许离开。
- ///
- [DisplayName("机构地标")]
- public int JgPointId { get; set; }
-
- ///
- /// 机构占用包含的站点列表。车已不在此列表且写字节还不是已离开 → 写已离开。
- ///
- [DisplayName("机构包含地标")]
- public string JgContains { get; set; } = "";
+ /// 心跳位,0~7。
+ [DisplayName("心跳位")]
+ public int HeartBit { get; set; }
/// 上线 / 单牵引下线 / 双牵引下线 / 等待通行,决定 AutoLeave 分支。
[DisplayName("上下机构")]
public SignalLineType LineType { get; set; } = SignalLineType.未知;
+
+ /// 旧配置读字节地址。有工位表后不再使用;无工位表时合成 default 工位。
+ [Browsable(false)]
+ [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public string ReadDb { get; set; }
+
+ /// 旧配置写字节地址。
+ [Browsable(false)]
+ [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public string WriteDb { get; set; }
+
+ /// 旧配置心跳位整段地址,优先于心跳字节/位。
+ [Browsable(false)]
+ [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public string HeartDb { get; set; }
+
+ [Browsable(false)]
+ [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public int JgInPointId { get; set; }
+
+ [Browsable(false)]
+ [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public string JgInContains { get; set; }
+
+ [Browsable(false)]
+ [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public int JgPointId { get; set; }
+
+ [Browsable(false)]
+ [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public string JgContains { get; set; }
}
}
diff --git a/StandardScene.Signal/Model/ReleasePointModel.cs b/StandardScene.Signal/Model/ReleasePointModel.cs
index ed36a1d..b86df6b 100644
--- a/StandardScene.Signal/Model/ReleasePointModel.cs
+++ b/StandardScene.Signal/Model/ReleasePointModel.cs
@@ -10,6 +10,7 @@ namespace StandardScene.Signal.Model
/// 同一站点不要同时配握手点:扫车先握手再放行,可能把还在等允许进入的车开走。
/// 站点号不能重复,列表编辑器用 Sit 做主键。
///
+ [SignalTable("release", "release-points.json", "放行点", Order = 4)]
[Category("放行点数据管理")]
[DisplayName("放行点配置列表")]
public class ReleasePointModel
diff --git a/StandardScene.Signal/Model/SignalEnums.cs b/StandardScene.Signal/Model/SignalEnums.cs
index 20bb6a3..cea3474 100644
--- a/StandardScene.Signal/Model/SignalEnums.cs
+++ b/StandardScene.Signal/Model/SignalEnums.cs
@@ -38,6 +38,34 @@ namespace StandardScene.Signal.Model
已离开 = 4
}
+ ///
+ /// 西门子系列,名称与 IoTClient SiemensVersion 对齐(不含 None)。
+ /// 机构表选「跟随进程」时用信号交互进程的 PlcVersion。
+ ///
+ public enum SignalPlcType : byte
+ {
+ /// 0:未单独指定,建连时用进程字段 PlcVersion。
+ 跟随进程 = 0,
+
+ /// S7-1500,现场最常见。
+ S7_1500 = 1,
+
+ /// S7-1200。
+ S7_1200 = 2,
+
+ /// S7-300。
+ S7_300 = 3,
+
+ /// S7-400。
+ S7_400 = 4,
+
+ /// S7-200 Smart。
+ S7_200Smart = 5,
+
+ /// S7-200。
+ S7_200 = 6
+ }
+
///
/// 机构类型。对齐反编译 ENUM_JGTYPE。
/// 决定 走哪套离站,以及 RequestIn 要不要查机构内是否有车。
diff --git a/StandardScene.Signal/Model/SignalSelectAttribute.cs b/StandardScene.Signal/Model/SignalSelectAttribute.cs
new file mode 100644
index 0000000..3626111
--- /dev/null
+++ b/StandardScene.Signal/Model/SignalSelectAttribute.cs
@@ -0,0 +1,18 @@
+using System;
+
+namespace StandardScene.Signal.Model
+{
+ ///
+ /// 迷毂数据中心把该字符串字段渲染成下拉。反射列定义时读取。
+ ///
+ [AttributeUsage(AttributeTargets.Property)]
+ public sealed class SignalSelectAttribute : Attribute
+ {
+ public SignalSelectAttribute(params string[] options)
+ {
+ Options = options ?? Array.Empty();
+ }
+
+ public string[] Options { get; }
+ }
+}
diff --git a/StandardScene.Signal/Model/SignalTableAttribute.cs b/StandardScene.Signal/Model/SignalTableAttribute.cs
new file mode 100644
index 0000000..5e94eb4
--- /dev/null
+++ b/StandardScene.Signal/Model/SignalTableAttribute.cs
@@ -0,0 +1,27 @@
+using System;
+
+namespace StandardScene.Signal.Model
+{
+ ///
+ /// 标记一张迷毂数据中心表。迷毂从插件 DLL 反射本特性,不再依赖 signal-tables.json 的列定义。
+ ///
+ [AttributeUsage(AttributeTargets.Class, Inherited = false)]
+ public sealed class SignalTableAttribute : Attribute
+ {
+ public SignalTableAttribute(string id, string fileName, string title)
+ {
+ Id = id ?? "";
+ FileName = fileName ?? "";
+ Title = title ?? "";
+ }
+
+ public string Id { get; }
+
+ public string FileName { get; }
+
+ public string Title { get; }
+
+ /// 侧栏顺序,越小越靠前。
+ public int Order { get; set; }
+ }
+}
diff --git a/StandardScene.Signal/Plc/PlcAddress.cs b/StandardScene.Signal/Plc/PlcAddress.cs
new file mode 100644
index 0000000..64fae20
--- /dev/null
+++ b/StandardScene.Signal/Plc/PlcAddress.cs
@@ -0,0 +1,63 @@
+using System;
+using System.Text.RegularExpressions;
+
+namespace StandardScene.Signal.Plc
+{
+ /// 西门子 DB 字节/位地址拼装与缓冲区位操作。
+ internal static class PlcAddress
+ {
+ private static readonly Regex Addr = new Regex(
+ @"^DB(\d+)\.(?:DBB|DBX|DBW)?(\d+)(?:\.(\d+))?$",
+ RegexOptions.IgnoreCase | RegexOptions.Compiled);
+
+ public static string Bit(int db, int absByte, int bit) =>
+ $"DB{Math.Max(1, db)}.DBX{Math.Max(0, absByte)}.{ClampBit(bit)}";
+
+ public static string Byte(int db, int absByte) =>
+ $"DB{Math.Max(1, db)}.DBB{Math.Max(0, absByte)}";
+
+ public static int ClampBit(int bit)
+ {
+ if (bit < 0) return 0;
+ if (bit > 7) return 7;
+ return bit;
+ }
+
+ public static bool TryParse(string address, out int db, out int absByte, out int bit)
+ {
+ db = 0;
+ absByte = 0;
+ bit = 0;
+ if (string.IsNullOrWhiteSpace(address))
+ return false;
+ var m = Addr.Match(address.Trim());
+ if (!m.Success)
+ return false;
+ db = int.Parse(m.Groups[1].Value);
+ absByte = int.Parse(m.Groups[2].Value);
+ if (m.Groups[3].Success)
+ bit = ClampBit(int.Parse(m.Groups[3].Value));
+ return true;
+ }
+
+ public static bool GetBit(byte[] buffer, int originByte, int absByte, int bit)
+ {
+ var i = absByte - originByte;
+ if (buffer == null || i < 0 || i >= buffer.Length)
+ return false;
+ return (buffer[i] & (1 << ClampBit(bit))) != 0;
+ }
+
+ public static void SetBit(byte[] buffer, int originByte, int absByte, int bit, bool value)
+ {
+ var i = absByte - originByte;
+ if (buffer == null || i < 0 || i >= buffer.Length)
+ return;
+ var mask = (byte)(1 << ClampBit(bit));
+ if (value)
+ buffer[i] |= mask;
+ else
+ buffer[i] = (byte)(buffer[i] & ~mask);
+ }
+ }
+}
diff --git a/StandardScene.Signal/Plc/PlcConfigLoader.cs b/StandardScene.Signal/Plc/PlcConfigLoader.cs
new file mode 100644
index 0000000..3eb9b58
--- /dev/null
+++ b/StandardScene.Signal/Plc/PlcConfigLoader.cs
@@ -0,0 +1,89 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using StandardScene.Signal.Model;
+
+namespace StandardScene.Signal.Plc
+{
+ ///
+ /// 加载机构 + 工位。没有工位表时,用旧机构行的 ReadDb/WriteDb/地标合成 default 工位(一字节枚举)。
+ ///
+ internal static class PlcConfigLoader
+ {
+ public const string DefaultDockId = "default";
+
+ public sealed class StationBundle
+ {
+ public PlcStationModel Station { get; init; }
+ public List Docks { get; init; }
+ }
+
+ public static List Load(string stationsPath, string docksPath)
+ {
+ var stations = SignalConfigStore.Load(stationsPath)
+ .Where(s => s != null && !string.IsNullOrWhiteSpace(s.JgName))
+ .ToList();
+ var docks = SignalConfigStore.Load(docksPath)
+ .Where(d => d != null && !string.IsNullOrWhiteSpace(d.JgName))
+ .ToList();
+
+ var byName = docks
+ .GroupBy(d => d.JgName.Trim(), StringComparer.OrdinalIgnoreCase)
+ .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
+
+ var list = new List();
+ foreach (var station in stations)
+ {
+ if (!byName.TryGetValue(station.JgName.Trim(), out var rows) || rows.Count == 0)
+ rows = HasLegacyIo(station) ? new List { Synthesize(station) } : new List();
+ list.Add(new StationBundle { Station = station, Docks = rows });
+ }
+
+ return list;
+ }
+
+ public static int ResolveDb(PlcStationModel station)
+ {
+ if (station == null)
+ return 100;
+ if (station.DbNumber > 0)
+ return station.DbNumber;
+ if (PlcAddress.TryParse(station.HeartDb, out var db, out _, out _))
+ return db;
+ if (PlcAddress.TryParse(station.ReadDb, out db, out _, out _))
+ return db;
+ if (PlcAddress.TryParse(station.WriteDb, out db, out _, out _))
+ return db;
+ return 100;
+ }
+
+ public static string ResolveHeartAddress(PlcStationModel station)
+ {
+ if (station == null)
+ return "";
+ if (!string.IsNullOrWhiteSpace(station.HeartDb))
+ return station.HeartDb.Trim();
+ return PlcAddress.Bit(ResolveDb(station), station.HeartByte, station.HeartBit);
+ }
+
+ private static bool HasLegacyIo(PlcStationModel station) =>
+ station != null &&
+ (!string.IsNullOrWhiteSpace(station.ReadDb) ||
+ !string.IsNullOrWhiteSpace(station.WriteDb) ||
+ station.JgPointId > 0 ||
+ !string.IsNullOrWhiteSpace(station.JgContains));
+
+ private static PlcStationDockModel Synthesize(PlcStationModel station) => new PlcStationDockModel
+ {
+ JgName = station.JgName,
+ DockId = DefaultDockId,
+ UseByteIo = true,
+ ReadDb = station.ReadDb,
+ WriteDb = station.WriteDb,
+ JgInPointId = station.JgInPointId,
+ JgInContains = station.JgInContains,
+ JgPointId = station.JgPointId,
+ JgContains = station.JgContains
+ };
+ }
+}
diff --git a/StandardScene.Signal/Plc/PlcStationLogic.cs b/StandardScene.Signal/Plc/PlcStationLogic.cs
index 0b09f86..fc4fb29 100644
--- a/StandardScene.Signal/Plc/PlcStationLogic.cs
+++ b/StandardScene.Signal/Plc/PlcStationLogic.cs
@@ -48,8 +48,8 @@ namespace StandardScene.Signal.Plc
/// 按 分发四套离站逻辑;未知类型不处理。
public static void AutoLeave(PlcStationRuntime rt)
{
- if (rt?.Config == null) return;
- switch (rt.Config.LineType)
+ if (rt?.Station == null || rt.Dock == null) return;
+ switch (rt.Station.LineType)
{
case SignalLineType.上线机构:
AutoLeaveUpLine(rt);
@@ -77,12 +77,12 @@ namespace StandardScene.Signal.Plc
if (rt.RequestInAgvNo > 0)
{
Diagnosis.Log(
- $"{rt.Config.JgName} {(fromReadback ? "回读已离开" : "写已离开成功")},清空请求进入车 {rt.RequestInAgvNo}",
+ $"{rt.DisplayName} {(fromReadback ? "回读已离开" : "写已离开成功")},清空请求进入车 {rt.RequestInAgvNo}",
"JGControl", true);
rt.RequestInAgvNo = 0;
}
else
- Diagnosis.Log($"{rt.Config.JgName} 已离开,清空信号", "JGControl", true);
+ Diagnosis.Log($"{rt.DisplayName} 已离开,清空信号", "JGControl", true);
}
///
@@ -102,12 +102,12 @@ namespace StandardScene.Signal.Plc
{
var car = SignalCarAdapter.Find(rt.RequestInAgvNo);
if (car == null) return;
- if (!ContainsSite(rt.Config.JgInContains, SignalCarAdapter.PositionId(car)))
+ if (!ContainsSite(rt.Dock.JgInContains, SignalCarAdapter.PositionId(car)))
{
rt.WriteSignal = SignalWriteValue.到位;
rt.InAgvNo = car.id;
rt.RequestInAgvNo = 0;
- Diagnosis.Log($"AGV{car.id} 已到达机构 {rt.Config.JgName}", "JGControl", true);
+ Diagnosis.Log($"AGV{car.id} 已到达机构 {rt.DisplayName}", "JGControl", true);
}
}
else if (rt.WriteSuccess && rt.WriteSignal == SignalWriteValue.到位)
@@ -119,7 +119,7 @@ namespace StandardScene.Signal.Plc
else if (rt.WriteSuccess && rt.WriteSignal == SignalWriteValue.离开中)
{
var car = SignalCarAdapter.Find(rt.InAgvNo);
- if (car == null || !ContainsSite(rt.Config.JgContains, SignalCarAdapter.PositionId(car)))
+ if (car == null || !ContainsSite(rt.Dock.JgContains, SignalCarAdapter.PositionId(car)))
rt.WriteSignal = SignalWriteValue.已离开;
}
}
@@ -145,7 +145,7 @@ namespace StandardScene.Signal.Plc
if (car == null) return;
var pos = SignalCarAdapter.PositionId(car);
- if (rt.ReadSignal == SignalReadValue.允许离开 && pos == rt.Config.JgPointId)
+ if (rt.ReadSignal == SignalReadValue.允许离开 && pos == rt.Dock.JgPointId)
{
if (rt.WriteSignal == SignalWriteValue.离开中) return;
var pinOk = SignalCarAdapter.IsActionFinish(car, "SetPin1Up")
@@ -156,7 +156,7 @@ namespace StandardScene.Signal.Plc
SignalCarAdapter.StopCharge(car);
SignalCarAdapter.Start(car);
rt.WriteSignal = SignalWriteValue.离开中;
- Diagnosis.Log($"{rt.Config.JgName} 允许离开,通知 AGV{car.id} 启动,写离开中", "JGControl", true);
+ Diagnosis.Log($"{rt.DisplayName} 允许离开,通知 AGV{car.id} 启动,写离开中", "JGControl", true);
}
else
{
@@ -165,8 +165,8 @@ namespace StandardScene.Signal.Plc
SignalCarAdapter.Control(car, "StopCharge");
}
}
- else if (!string.IsNullOrEmpty(rt.Config.JgContains)
- && !ContainsSite(rt.Config.JgContains, pos)
+ else if (!string.IsNullOrEmpty(rt.Dock.JgContains)
+ && !ContainsSite(rt.Dock.JgContains, pos)
&& rt.ReadWriteSignal != SignalWriteValue.已离开)
{
rt.WriteSignal = SignalWriteValue.已离开;
@@ -193,20 +193,20 @@ namespace StandardScene.Signal.Plc
if (car == null) return;
var pos = SignalCarAdapter.PositionId(car);
- if (rt.ReadSignal == SignalReadValue.允许离开 && pos == rt.Config.JgPointId)
+ if (rt.ReadSignal == SignalReadValue.允许离开 && pos == rt.Dock.JgPointId)
{
if (rt.WriteSignal == SignalWriteValue.离开中) return;
if (SignalCarAdapter.IsActionFinish(car, "StopCharge"))
{
SignalCarAdapter.Start(car);
rt.WriteSignal = SignalWriteValue.离开中;
- Diagnosis.Log($"{rt.Config.JgName} 允许离开,通知 AGV{car.id} 启动,写离开中", "JGControl", true);
+ Diagnosis.Log($"{rt.DisplayName} 允许离开,通知 AGV{car.id} 启动,写离开中", "JGControl", true);
}
else
SignalCarAdapter.Control(car, "StopCharge");
}
- else if (!string.IsNullOrEmpty(rt.Config.JgContains)
- && !ContainsSite(rt.Config.JgContains, pos)
+ else if (!string.IsNullOrEmpty(rt.Dock.JgContains)
+ && !ContainsSite(rt.Dock.JgContains, pos)
&& rt.ReadWriteSignal != SignalWriteValue.已离开)
{
rt.WriteSignal = SignalWriteValue.已离开;
diff --git a/StandardScene.Signal/Plc/PlcStationRuntime.cs b/StandardScene.Signal/Plc/PlcStationRuntime.cs
index d949260..7278608 100644
--- a/StandardScene.Signal/Plc/PlcStationRuntime.cs
+++ b/StandardScene.Signal/Plc/PlcStationRuntime.cs
@@ -5,91 +5,88 @@ using StandardScene.Signal.Model;
namespace StandardScene.Signal.Plc
{
///
- /// 单个机构的运行态(不进 JSON)。扫车线程改写目标写字节,PLC Worker 负责真正下发。
+ /// 单个工位运行态(不进 JSON)。扫车改写目标写状态,PLC Worker 负责下发 BOOL(或旧一字节枚举)。
///
- ///
- /// 赋值 会把 置 false,下一拍 Worker 发现
- /// 目标与回读不一致就会重写 PLC。回读与目标一致后再标成功,AutoLeave 才继续往下一状态走。
- /// 所有读写应在 内进行。
- ///
public sealed class PlcStationRuntime
{
private SignalWriteValue _writeSignal;
private SignalReadValue _readSignal;
private SignalWriteValue _readWriteSignal;
- public PlcStationRuntime(PlcStationModel config)
+ public PlcStationRuntime(PlcStationModel station, PlcStationDockModel dock, object sync = null)
{
- Config = config ?? throw new ArgumentNullException(nameof(config));
+ Station = station ?? throw new ArgumentNullException(nameof(station));
+ Dock = dock ?? throw new ArgumentNullException(nameof(dock));
+ Sync = sync ?? new object();
}
- /// 本机构静态配置(IP、DB 地址、地标、机构类型)。
- public PlcStationModel Config { get; }
+ public PlcStationModel Station { get; }
- /// 扫车线程与 PLC Worker 共用运行态,写入时加此锁。
- public object Sync { get; } = new object();
+ public PlcStationDockModel Dock { get; }
+
+ /// 兼容旧代码:机构连接配置(不含工位地标)。
+ public PlcStationModel Config => Station;
+
+ public object Sync { get; }
+
+ public string JgName => Station.JgName ?? "";
+
+ public string DockId => Dock.ResolvedDockId;
+
+ public string DisplayName =>
+ string.IsNullOrEmpty(DockId) || string.Equals(DockId, PlcConfigLoader.DefaultDockId, StringComparison.OrdinalIgnoreCase)
+ ? JgName
+ : $"{JgName}/{DockId}";
- /// PLC→调度:允许进入 / 允许离开 / 无状态。变化时打诊断。
public SignalReadValue ReadSignal
{
get => _readSignal;
set
{
if (_readSignal != value)
- Diagnosis.Log($"{Config.JgName} 读信号 {_readSignal} → {value}", "PLC", true);
+ Diagnosis.Log($"{DisplayName} 读信号 {_readSignal} → {value}", "PLC", true);
_readSignal = value;
}
}
- ///
- /// 调度→PLC 的目标写字节。赋值即视为尚未写成功,驱动 Worker 重写。
- ///
public SignalWriteValue WriteSignal
{
get => _writeSignal;
set
{
if (_writeSignal != value)
- Diagnosis.Log($"{Config.JgName} 写信号 {_writeSignal} → {value}", "PLC", true);
+ Diagnosis.Log($"{DisplayName} 写信号 {_writeSignal} → {value}", "PLC", true);
_writeSignal = value;
WriteSuccess = false;
}
}
- /// 从写地址回读到的当前值,用来确认 PLC 是否已吃进目标字节。
public SignalWriteValue ReadWriteSignal
{
get => _readWriteSignal;
set
{
if (_readWriteSignal != value)
- Diagnosis.Log($"{Config.JgName} 回读写值 {_readWriteSignal} → {value}", "PLC", true);
+ Diagnosis.Log($"{DisplayName} 回读写值 {_readWriteSignal} → {value}", "PLC", true);
_readWriteSignal = value;
}
}
- /// 已报到在机构内的 AGV 号;0 表示没有。Arrived / 上线自动到位 时写入。
public int InAgvNo { get; set; }
- /// 已获准进入、正在往机构开的 AGV 号;到位或离站清空后为 0。
public int RequestInAgvNo { get; set; }
- /// 最近一拍 PLC 读写是否成功。心跳或读写失败会立刻变 false。
public bool IsConnected { get; set; }
- /// 目标写字节是否已与 PLC 回读一致。改 WriteSignal 时自动清掉。
public bool WriteSuccess { get; set; }
- /// 内存侧心跳翻转标志,供监视面板看到 Worker 在跑;真正写 PLC 的是 HeartDb 位翻转。
public bool Heart { get; set; }
- /// 最近一次断线或周期异常原因,连接正常时清空。
public string LastError { get; set; } = "";
- /// 拷贝一份给 MissionStatus / 监视列表,避免 UI 直接绑运行态。
public PlcStationSnapshot ToSnapshot() => new PlcStationSnapshot
{
- JgName = Config.JgName,
+ JgName = DisplayName,
IsConnected = IsConnected,
ReadSignal = ReadSignal.ToString(),
WriteSignal = WriteSignal.ToString(),
diff --git a/StandardScene.Signal/Plc/PlcStationWorker.cs b/StandardScene.Signal/Plc/PlcStationWorker.cs
index 93566b8..0b43600 100644
--- a/StandardScene.Signal/Plc/PlcStationWorker.cs
+++ b/StandardScene.Signal/Plc/PlcStationWorker.cs
@@ -1,4 +1,6 @@
using System;
+using System.Collections.Generic;
+using System.Linq;
using System.Threading;
using SimpleCore.Library;
using StandardScene.Signal.Model;
@@ -6,41 +8,43 @@ using StandardScene.Signal.Model;
namespace StandardScene.Signal.Plc
{
///
- /// 单机构 200ms 循环:心跳、读允许进出、回读写值、按需写入、自动离站。
- /// 对齐反编译 PLCManager.ExcuteThreadMethod。
+ /// 单机构 200ms 循环:心跳、按工位读 BOOL(或旧一字节)、回写变化位、自动离站。
///
- ///
- /// 一拍顺序固定,失败则本拍标断线并 return,避免用脏数据推进状态机:
- /// 1. 翻转内存心跳标志;若配置了 HeartDb,读当前位再写反码(PLC 侧看翻转判断调度活着)。
- /// 2. 读 ReadDb → 。
- /// 3. 回读 WriteDb → ReadWriteSignal;与目标 WriteSignal 一致则 WriteSuccess=true。
- /// 4. 目标与回读不一致且尚未成功时写入目标字节。
- /// 5. 。
- /// 读写均在 内,与扫车 Handler 互斥。
- ///
public sealed class PlcStationWorker : IDisposable
{
private readonly SiemensPlcSession _session;
private readonly int _pollMs;
private readonly Action _onTick;
+ private readonly PlcStationModel _station;
+ private readonly List _docks;
private CancellationTokenSource _cts;
private Thread _thread;
-
- /// 断线日志节流起点,避免 200ms 打满诊断。
private DateTime _lastFailLog = DateTime.MinValue;
- public PlcStationWorker(PlcStationRuntime runtime, SiemensPlcSession session, int pollMs, Action onTick = null)
+ public PlcStationWorker(
+ PlcStationModel station,
+ IEnumerable docks,
+ SiemensPlcSession session,
+ object sync,
+ int pollMs,
+ Action onTick = null)
{
- Runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
+ _station = station ?? throw new ArgumentNullException(nameof(station));
+ _docks = (docks ?? Array.Empty()).Where(d => d != null).ToList();
_session = session ?? throw new ArgumentNullException(nameof(session));
+ Sync = sync ?? new object();
_pollMs = Math.Max(50, pollMs);
_onTick = onTick;
+ foreach (var d in _docks)
+ d.Heart = false;
}
- /// 本机构运行态,扫车线程按 JgName 查找后加 Sync 读写。
- public PlcStationRuntime Runtime { get; }
+ public object Sync { get; }
+
+ public IReadOnlyList Docks => _docks;
+
+ public PlcStationRuntime Runtime => _docks.Count > 0 ? _docks[0] : null;
- /// 启动后台线程。重复 Start 若仍存活则忽略。
public void Start()
{
if (_thread != null && _thread.IsAlive) return;
@@ -48,13 +52,12 @@ namespace StandardScene.Signal.Plc
_thread = new Thread(Loop)
{
IsBackground = true,
- Name = $"PlcStation:{Runtime.Config.JgName}"
+ Name = $"PlcStation:{_station.JgName}"
};
_thread.Start();
- Diagnosis.Log($"{Runtime.Config.JgName} Worker 已启动 → {Runtime.Config.Ip}:{Runtime.Config.Port}", "PLC", true);
+ Diagnosis.Log($"{_station.JgName} Worker 已启动 → {_station.Ip}:{_station.Port} 工位{_docks.Count}", "PLC", true);
}
- /// 取消循环、最多等 2s、关闭 S7 连接并标断线。
public void Stop()
{
try { _cts?.Cancel(); }
@@ -63,8 +66,9 @@ namespace StandardScene.Signal.Plc
catch { }
_thread = null;
_session.Close();
- Runtime.IsConnected = false;
- Diagnosis.Log($"{Runtime.Config.JgName} Worker 已停止", "PLC", true);
+ foreach (var d in _docks)
+ d.IsConnected = false;
+ Diagnosis.Log($"{_station.JgName} Worker 已停止", "PLC", true);
}
public void Dispose() => Stop();
@@ -76,14 +80,17 @@ namespace StandardScene.Signal.Plc
{
try
{
- lock (Runtime.Sync)
+ lock (Sync)
Tick();
}
catch (Exception ex)
{
- Runtime.LastError = ex.Message;
- Runtime.IsConnected = false;
- LogFail($"{Runtime.Config.JgName} 周期异常 {ex.Message}");
+ foreach (var d in _docks)
+ {
+ d.LastError = ex.Message;
+ d.IsConnected = false;
+ }
+ LogFail($"{_station.JgName} 周期异常 {ex.Message}");
}
try { _onTick?.Invoke(); }
@@ -94,17 +101,18 @@ namespace StandardScene.Signal.Plc
}
}
- /// 单拍 PLC 交互。心跳地址为空则跳过心跳;读写地址为空则跳过对应步骤。
internal void Tick()
{
- var cfg = Runtime.Config;
- Runtime.Heart = !Runtime.Heart;
+ var heart = !_docks.Select(d => d.Heart).FirstOrDefault();
+ foreach (var d in _docks)
+ d.Heart = heart;
- if (!string.IsNullOrWhiteSpace(cfg.HeartDb))
+ var heartAddr = PlcConfigLoader.ResolveHeartAddress(_station);
+ if (!string.IsNullOrWhiteSpace(heartAddr))
{
- if (_session.TryReadBool(cfg.HeartDb, out var bit))
+ if (_session.TryReadBool(heartAddr, out var bit))
{
- _session.TryWriteBool(cfg.HeartDb, !bit);
+ _session.TryWriteBool(heartAddr, !bit);
Thread.Sleep(20);
}
else
@@ -114,56 +122,183 @@ namespace StandardScene.Signal.Plc
}
}
+ var boolDocks = _docks.Where(d => !d.Dock.UseByteIo).ToList();
+ var byteDocks = _docks.Where(d => d.Dock.UseByteIo).ToList();
+
+ if (boolDocks.Count > 0 && !TickBoolDocks(boolDocks))
+ return;
+
+ foreach (var rt in byteDocks)
+ {
+ if (!TickByteDock(rt))
+ return;
+ }
+
+ foreach (var rt in _docks)
+ {
+ rt.IsConnected = true;
+ rt.LastError = "";
+ PlcStationLogic.AutoLeave(rt);
+ }
+ }
+
+ private bool TickBoolDocks(List docks)
+ {
+ var db = PlcConfigLoader.ResolveDb(_station);
+ var bytes = new List();
+ foreach (var rt in docks)
+ CollectBytes(rt.Dock, bytes);
+ if (bytes.Count == 0)
+ return true;
+
+ var origin = bytes.Min();
+ var last = bytes.Max();
+ var len = (ushort)(last - origin + 1);
+ if (!_session.TryReadBytes(PlcAddress.Byte(db, origin), len, out var buffer) ||
+ buffer == null || buffer.Length < len)
+ {
+ MarkDisconnected("BOOL 块读失败");
+ return false;
+ }
+
+ var changedBytes = new HashSet();
+ foreach (var rt in docks)
+ {
+ var dock = rt.Dock;
+ var allowIn = PlcAddress.GetBit(buffer, origin, dock.AllowInByte, dock.AllowInBit);
+ var allowOut = PlcAddress.GetBit(buffer, origin, dock.AllowOutByte, dock.AllowOutBit);
+ rt.ReadSignal = allowIn
+ ? SignalReadValue.允许进入
+ : allowOut
+ ? SignalReadValue.允许离开
+ : SignalReadValue.无状态;
+
+ var readWrite = DecodeWrite(buffer, origin, dock);
+ rt.ReadWriteSignal = readWrite;
+ if (readWrite == rt.WriteSignal)
+ rt.WriteSuccess = true;
+
+ if (rt.WriteSignal != readWrite && !rt.WriteSuccess)
+ {
+ Diagnosis.Log(
+ $"AGV→机构 {rt.DisplayName} 写入【{rt.WriteSignal}】请求进入{rt.RequestInAgvNo} 到达{rt.InAgvNo}",
+ "JGControl", true);
+ EncodeWrite(buffer, origin, dock, rt.WriteSignal, changedBytes);
+ }
+ }
+
+ foreach (var abs in changedBytes)
+ {
+ var i = abs - origin;
+ if (i < 0 || i >= buffer.Length)
+ continue;
+ if (!_session.TryWriteByte(PlcAddress.Byte(db, abs), buffer[i]))
+ {
+ MarkDisconnected("BOOL 写失败");
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private bool TickByteDock(PlcStationRuntime rt)
+ {
+ var cfg = rt.Dock;
if (!string.IsNullOrWhiteSpace(cfg.ReadDb))
{
if (_session.TryReadByte(cfg.ReadDb, out var rb))
- Runtime.ReadSignal = PlcStationLogic.ConvertRead(rb);
+ rt.ReadSignal = PlcStationLogic.ConvertRead(rb);
else
{
- MarkDisconnected("读地址失败");
- return;
+ MarkDisconnected($"{rt.DisplayName} 读地址失败");
+ return false;
}
}
- if (!string.IsNullOrWhiteSpace(cfg.WriteDb))
+ if (string.IsNullOrWhiteSpace(cfg.WriteDb))
+ return true;
+
+ if (!_session.TryReadByte(cfg.WriteDb, out var wb))
{
- if (_session.TryReadByte(cfg.WriteDb, out var wb))
- {
- Runtime.ReadWriteSignal = PlcStationLogic.ConvertWrite(wb);
- if (Runtime.ReadWriteSignal == Runtime.WriteSignal)
- Runtime.WriteSuccess = true;
- }
- else
- {
- MarkDisconnected("写地址回读失败");
- return;
- }
-
- if (Runtime.WriteSignal != Runtime.ReadWriteSignal && !Runtime.WriteSuccess)
- {
- Diagnosis.Log(
- $"AGV→机构 {cfg.JgName} 写入【{Runtime.WriteSignal}】请求进入{Runtime.RequestInAgvNo} 到达{Runtime.InAgvNo}",
- "JGControl", true);
- _session.TryWriteByte(cfg.WriteDb, (byte)Runtime.WriteSignal);
- }
+ MarkDisconnected($"{rt.DisplayName} 写地址回读失败");
+ return false;
}
- Runtime.IsConnected = true;
- Runtime.LastError = "";
- PlcStationLogic.AutoLeave(Runtime);
+ rt.ReadWriteSignal = PlcStationLogic.ConvertWrite(wb);
+ if (rt.ReadWriteSignal == rt.WriteSignal)
+ rt.WriteSuccess = true;
+
+ if (rt.WriteSignal != rt.ReadWriteSignal && !rt.WriteSuccess)
+ {
+ Diagnosis.Log(
+ $"AGV→机构 {rt.DisplayName} 写入【{rt.WriteSignal}】请求进入{rt.RequestInAgvNo} 到达{rt.InAgvNo}",
+ "JGControl", true);
+ _session.TryWriteByte(cfg.WriteDb, (byte)rt.WriteSignal);
+ }
+
+ return true;
+ }
+
+ private static void CollectBytes(PlcStationDockModel dock, List bytes)
+ {
+ if (dock == null) return;
+ bytes.Add(dock.AllowInByte);
+ bytes.Add(dock.AllowOutByte);
+ bytes.Add(dock.EnteringByte);
+ bytes.Add(dock.ArrivedByte);
+ bytes.Add(dock.LeavingByte);
+ bytes.Add(dock.LeftByte);
+ }
+
+ private static SignalWriteValue DecodeWrite(byte[] buffer, int origin, PlcStationDockModel dock)
+ {
+ if (PlcAddress.GetBit(buffer, origin, dock.LeftByte, dock.LeftBit))
+ return SignalWriteValue.已离开;
+ if (PlcAddress.GetBit(buffer, origin, dock.LeavingByte, dock.LeavingBit))
+ return SignalWriteValue.离开中;
+ if (PlcAddress.GetBit(buffer, origin, dock.ArrivedByte, dock.ArrivedBit))
+ return SignalWriteValue.到位;
+ if (PlcAddress.GetBit(buffer, origin, dock.EnteringByte, dock.EnteringBit))
+ return SignalWriteValue.进入中;
+ return SignalWriteValue.无状态;
+ }
+
+ private static void EncodeWrite(
+ byte[] buffer,
+ int origin,
+ PlcStationDockModel dock,
+ SignalWriteValue value,
+ HashSet changedBytes)
+ {
+ void Set(int abs, int bit, bool on)
+ {
+ var before = PlcAddress.GetBit(buffer, origin, abs, bit);
+ if (before == on) return;
+ PlcAddress.SetBit(buffer, origin, abs, bit, on);
+ changedBytes.Add(abs);
+ }
+
+ Set(dock.EnteringByte, dock.EnteringBit, value == SignalWriteValue.进入中);
+ Set(dock.ArrivedByte, dock.ArrivedBit, value == SignalWriteValue.到位);
+ Set(dock.LeavingByte, dock.LeavingBit, value == SignalWriteValue.离开中);
+ Set(dock.LeftByte, dock.LeftBit, value == SignalWriteValue.已离开);
}
private void MarkDisconnected(string reason)
{
- Runtime.IsConnected = false;
- Runtime.LastError = reason;
- LogFail($"{Runtime.Config.JgName} {reason}");
+ foreach (var d in _docks)
+ {
+ d.IsConnected = false;
+ d.LastError = reason;
+ }
+ LogFail($"{_station.JgName} {reason}");
}
- /// 同一机构失败日志最少间隔 5 秒。
private void LogFail(string msg)
{
- if ((DateTime.Now - _lastFailLog).TotalSeconds < 5) return;
+ if ((DateTime.Now - _lastFailLog).TotalSeconds < 5)
+ return;
_lastFailLog = DateTime.Now;
Diagnosis.Log(msg, "PLC", true);
}
diff --git a/StandardScene.Signal/Plc/SiemensPlcSession.cs b/StandardScene.Signal/Plc/SiemensPlcSession.cs
index 0063919..8fa591b 100644
--- a/StandardScene.Signal/Plc/SiemensPlcSession.cs
+++ b/StandardScene.Signal/Plc/SiemensPlcSession.cs
@@ -25,7 +25,7 @@ namespace StandardScene.Signal.Plc
private SiemensClient _client;
private readonly object _sync = new object();
- /// 进程字段 PlcVersion,如 S7_1500。
+ /// 西门子系列名,如 S7_1500;空或无法解析时默认 S7_1500。
/// 机构 IP。
/// S7 端口,通常 102。
/// 机架槽号,S7-1500 常见 1。
@@ -103,6 +103,26 @@ namespace StandardScene.Signal.Plc
}
}
+ /// 从起始字节连续读 length 字节,用于工位 BOOL 拆位。
+ public bool TryReadBytes(string startAddress, ushort length, out byte[] data)
+ {
+ data = Array.Empty();
+ if (string.IsNullOrWhiteSpace(startAddress) || length == 0 || !EnsureOpen())
+ return false;
+ try
+ {
+ var r = _client.Read(NormalizeByteAddress(startAddress), length, false);
+ if (r == null || !r.IsSucceed || r.Value == null)
+ return false;
+ data = r.Value;
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
/// 读心跳位。地址不做 DBB 转换。
public bool TryReadBool(string address, out bool value)
{
diff --git a/StandardScene.Signal/Plc/SignalCarAdapter.cs b/StandardScene.Signal/Plc/SignalCarAdapter.cs
index 8c15b50..f6143d7 100644
--- a/StandardScene.Signal/Plc/SignalCarAdapter.cs
+++ b/StandardScene.Signal/Plc/SignalCarAdapter.cs
@@ -224,6 +224,13 @@ namespace StandardScene.Signal.Plc
return 0;
}
+ /// 类型名恰好为 MagCar(不含 Mag2Car)。磁条交管只放行这种车。
+ public static bool IsMagCar(Car car)
+ {
+ if (car == null) return false;
+ return string.Equals(car.GetType().Name, "MagCar", StringComparison.Ordinal);
+ }
+
/// 放行:Mag2 → Mag → SendStart(0)。
public static void Start(Car car)
{
@@ -233,6 +240,15 @@ namespace StandardScene.Signal.Plc
TryInvoke(car, "SendStart", new object[] { (ushort)0 });
}
+ /// 仅 MagCar 发 FASS 1.0 启动(0x01)。不调 StartMag2Car。
+ public static void StartMagCar(Car car)
+ {
+ if (!IsMagCar(car)) return;
+ if (TryInvoke(car, "StartMagCar"))
+ return;
+ TryInvoke(car, "SendStart", new object[] { (ushort)0 });
+ }
+
/// 急停:Mag2 → Mag → SendStop(0)。机构未允许进入时调用。
public static void Stop(Car car)
{
diff --git a/StandardScene.Signal/SignalConfigStore.cs b/StandardScene.Signal/SignalConfigStore.cs
index a5191cc..ac4ef75 100644
--- a/StandardScene.Signal/SignalConfigStore.cs
+++ b/StandardScene.Signal/SignalConfigStore.cs
@@ -7,7 +7,7 @@ using Newtonsoft.Json.Converters;
namespace StandardScene.Signal
{
///
- /// 信号插件三张 JSON 的路径解析与读写。
+ /// 信号插件 JSON 的路径解析与读写。
///
///
/// 只使用 SimpleLite 工作目录 Config/Signal/*.json,不读、不拷 plugins/Config/Signal。
diff --git a/StandardScene.Signal/StandardScene.Signal.csproj b/StandardScene.Signal/StandardScene.Signal.csproj
index db5711c..720eef2 100644
--- a/StandardScene.Signal/StandardScene.Signal.csproj
+++ b/StandardScene.Signal/StandardScene.Signal.csproj
@@ -26,7 +26,8 @@
-
+
+
diff --git a/StandardScene.Signal/StandardScene.Signal.scene.json b/StandardScene.Signal/StandardScene.Signal.scene.json
index 38cb979..f6cd8ad 100644
--- a/StandardScene.Signal/StandardScene.Signal.scene.json
+++ b/StandardScene.Signal/StandardScene.Signal.scene.json
@@ -5,6 +5,6 @@
"coreVersion": ">=1.0.0",
"requiresCore": "StandardScene.dll",
"provides": {
- "missionTypes": [ "SignalInteractMission" ]
+ "missionTypes": [ "SignalInteractMission", "MagTrafficMission" ]
}
}
diff --git a/StandardScene.sln b/StandardScene.sln
index edbff4b..9db748e 100644
--- a/StandardScene.sln
+++ b/StandardScene.sln
@@ -19,6 +19,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandardScene.Magnetic.Test
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandardScene.Signal", "StandardScene.Signal\StandardScene.Signal.csproj", "{7E5050AA-0009-4A11-9C22-0A0B0C0D0E09}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandardScene.MagCarSimulator", "StandardScene.MagCarSimulator\StandardScene.MagCarSimulator.csproj", "{7E5050AA-000A-4A11-9C22-0A0B0C0D0E0A}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -125,6 +127,18 @@ Global
{7E5050AA-0009-4A11-9C22-0A0B0C0D0E09}.Release|x64.Build.0 = Release|Any CPU
{7E5050AA-0009-4A11-9C22-0A0B0C0D0E09}.Release|x86.ActiveCfg = Release|Any CPU
{7E5050AA-0009-4A11-9C22-0A0B0C0D0E09}.Release|x86.Build.0 = Release|Any CPU
+ {7E5050AA-000A-4A11-9C22-0A0B0C0D0E0A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {7E5050AA-000A-4A11-9C22-0A0B0C0D0E0A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {7E5050AA-000A-4A11-9C22-0A0B0C0D0E0A}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {7E5050AA-000A-4A11-9C22-0A0B0C0D0E0A}.Debug|x64.Build.0 = Debug|Any CPU
+ {7E5050AA-000A-4A11-9C22-0A0B0C0D0E0A}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {7E5050AA-000A-4A11-9C22-0A0B0C0D0E0A}.Debug|x86.Build.0 = Debug|Any CPU
+ {7E5050AA-000A-4A11-9C22-0A0B0C0D0E0A}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {7E5050AA-000A-4A11-9C22-0A0B0C0D0E0A}.Release|Any CPU.Build.0 = Release|Any CPU
+ {7E5050AA-000A-4A11-9C22-0A0B0C0D0E0A}.Release|x64.ActiveCfg = Release|Any CPU
+ {7E5050AA-000A-4A11-9C22-0A0B0C0D0E0A}.Release|x64.Build.0 = Release|Any CPU
+ {7E5050AA-000A-4A11-9C22-0A0B0C0D0E0A}.Release|x86.ActiveCfg = Release|Any CPU
+ {7E5050AA-000A-4A11-9C22-0A0B0C0D0E0A}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE