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. 按区绑定:同一「管控区名称」的多行表示同一个物理路口(或汇合区)。
  2. +
  3. 按线占用:每一行有自己的内部站点列表和容量,占用只统计本行站点,不把整个路口并成一份。
  4. +
  5. 冲突才互斥:同区里,兼容组相同,或两行内部站点有交集,视为冲突,不能同时放;否则可并行。
  6. +
  7. 冲突才排队:优先级、到达时刻、等待时长只在冲突线路之间生效。不冲突的线路互不插队、互不占名额。
  8. +
+
+ 兼容组留空且同区多行都为空时,视为同一组,整区互斥,与早期「同区容量 1 只放一辆」的行为兼容。旧 JSON 不填兼容组即可沿用。 +
+ +

3. 配置表(交管策略字段)

+

主键是管控触发站点。同一触发点多条启用记录只保留第一条。未启用(IsUse=false)的行保留在文件中但不参与判定。

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
字段JSON 键含义与配法
管控触发站点TriggerSitMagCar 必须停稳在此站点后才进入本行排队。应对齐现场停车点,不要把路口内部行驶点当成触发点。
管控区名称AreaName同名多行绑成一个路口,共用等待时长(取最长)。空则该触发点自成一区,相当于单入口限流。
兼容组CompatGroup同区里互不干扰的线路填不同组名(如东西 EW、南北 NS)。会交叉、会抢同一段路的填同组。空字符串与其他空组视为同组。
优先级Priority整数,越大越先放。干道高、支路低。只在冲突线路之间比较。
等待时长WaitSeconds秒。同区多行不一致时取最长。≤0 表示不做插队。用于防止支路被干道一直堵住。
管控区站点列表ControlArea本线路穿过路口的内部站点,逗号分隔,如 10,11。不要把各方向排队触发点写进来。空则回退为触发点本身。
区内最多几辆ControlCapacity本行容量。小于 1 按 1 处理。统计范围:占用本行内部站点的车 + 与本行冲突、已发启动但未进区的在途车。
放行后启停字节ControlReleaseStartStop兼容字段。MagCar 放行发 FASS 1.0 启动指令,不使用该字节。
启用IsUsefalse 时本行不生效。
+

3.1 运行态(不入库)

+ + + + + + + + + + +
名称说明
到达时刻进程第一次扫到该 MagCar 停在该触发点的时间。开走后再回来重新计时。不写入 JSON。
在途占额已对该车发过启动,但尚未占用本行内部站点。避免「刚放行、车还在触发点」时下一拍又放一辆冲突车。
+

3.2 进程参数

+ + + + + +
参数默认说明
TickMs1000扫车周期(毫秒),下限按 200ms 夹紧。
PendingTimeoutSeconds8在途超时(秒)。超时后取消占额,防止启动失败把名额卡死。
AreasPathConfig\Signal\mag-control-areas.json相对路径只解析 SimpleLite 工作目录,不读 plugins 目录。
+ +

4. 交管逻辑(扫车每一拍)

+

进程启动或点「重新加载配置」后,读取启用规则。此后约每秒执行一次 ScanAndRelease

+
+ 单拍流程 +
    +
  1. 快照全场车辆:站点(siteID / GetLastSite)、占用站点(siteID + holdingLocks)、是否 MagCar、是否停稳。
  2. +
  3. 启用规则按管控区名称分组(空名称则按「#sit:触发点」自成一区)。同区等待时长取各行之最长。
  4. +
  5. 清理到达时刻:已离开原触发点的车重新计时。
  6. +
  7. 清理在途:车消失、超时、或已经踩到本行内部站点 → 去掉在途标记(进区后改由占用统计)。
  8. +
  9. 收集本区停在各触发点上的停稳 MagCar,记为等待队列;已在途的不再重复发启动。
  10. +
  11. 按第 6 节规则排序,然后从队头到队尾贪心:本行有空位则发启动并登记在途;无空位则跳过,继续看后面不冲突的车。
  12. +
  13. 放行动作:先尝试停充,再 StartMagCar(FASS 1.0 启动)。
  14. +
+
+

4.1 何谓停稳

+

状态不是「运行中 / Running」。下列视为可排队:停止中、充电中、WaitPass,以及没有 pendingLocks、没有 occupied 标签的非运行状态。充电中的车放行前会尝试 StopCharge。

+

4.2 占用怎么数

+

对某一等待车辆,只统计占用该行 ControlArea 的他车。排队触发点上尚未放行的 MagCar 不计入占用(它们还在区外等候)。本车不计。

+

占用站点来源:车辆当前 siteID,以及 status.holdingLocks。任一命中列表即算 1 辆。

+

4.3 可放行数(空位)

+

空位 = 本行容量 − 占用本行站点的车 − 与本行冲突的在途车

+

空位 ≥ 1 才对该车发启动。容量 1 表示:本线路内部(含冲突在途)最多一辆。

+
+ 在途车若已经进入本行内部站点,只算「占用」,不再重复算「在途」,避免一名额扣两次。 +
+ +

5. 冲突判定(能否并行)

+

只比较同一管控区名称下的两行(或一行与一条在途记录)。不同区互不影响。

+

满足下面任一条件即冲突(互斥):

+
    +
  1. 兼容组相同(不区分大小写,去首尾空格)。两边都为空也算相同 → 整区互斥。
  2. +
  3. 两行的管控区站点列表有交集。
  4. +
+

两组都不同、且站点列表不相交 → 不冲突,可同时放行

+ + + + + + + +
场景兼容组内部站点结果
旧表、未填兼容组都空任意同区全部互斥
东西对向直行都填 EW可不相交同组,互斥(对向若也要并行,应拆成不同组且站点不相交)
东西 / 南北互不交叉EW 与 NS10,11 与 20,21不冲突,可并行
组名不同但抢同一段EW 与 NS都含 15站点相交,仍互斥
左转与对向直行交叉建议同组,或站点列出交叉段有交集或同组互斥
+
+ 若四个方向都复制同一份内部站点(例如都写 10,11,12,13),则即使兼容组不同也会因站点相交而全部互斥。要对向并行,必须按方向拆开内部点,且不要把触发点写进列表。 +
+ +

6. 排队规则(仅冲突线路)

+

同一管控区内,先按下列比较函数排序,再贪心放行。不冲突的车排序先后不影响能否同时过,只影响扫描顺序。

+

6.1 插队(等待时长)

+

当同区等待时长 W > 0,且两车优先级不同:

+

高优先级到达时刻 − 低优先级到达时刻 > W,则低优先级先走。

+

含义:低优先级更早到达,且已经比高优先级早到超过 W 秒,避免支路饿死。差值未超过 W 时,仍让优先级高的先走。

+

使用「>」而不是「≥」。例如 W=10,恰好早到 10 秒时仍按优先级。

+

6.2 默认顺序

+
    +
  1. 未触发插队时:优先级数值大的在前。
  2. +
  3. 优先级相同:到达时刻早的在前。
  4. +
  5. 仍相同:车号小的在前(稳定次序)。
  6. +
+

6.3 贪心,而不是只放队头

+

排序后从前往后看。队头因本线已满或冲突在途没有空位时,跳过队头,继续检查后面的车。这样南北已占用时,队头若是南北,不会挡住后面可以走的东西向车辆。

+ +

7. 放行与在途生命周期

+
    +
  1. 判定可放 → StopCharge(若有)→ StartMagCar。
  2. +
  3. 登记在途:记录区名、触发点、兼容组、本行内部站点、放行时刻。
  4. +
  5. 下一拍该车仍停在触发点:不再重复启动,但作为冲突在途占额。
  6. +
  7. 车进入本行内部站点:清除在途,改为占用统计。
  8. +
  9. 超过 PendingTimeoutSeconds(默认 8 秒)仍未进区:清除在途,避免卡死。车若仍停在触发点,下一拍可再次进入排队。
  10. +
  11. 车离线或从系统消失:清除在途与到达时刻。
  12. +
+

重新加载配置会清空到达时刻和在途表,请避免在路口有车等待时频繁重载。

+ +

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. 现场操作

+
    +
  1. 确认已加载信号插件 StandardScene.Signal.dll,进程栏中有「磁条交管进程」。
  2. +
  3. 将 JSON 放到 SimpleLite 工作目录 Config\Signal\mag-control-areas.json。可用迷毂数据中心「磁条管控区」编辑(含兼容组列,需较新的 MiGu.Server)。
  4. +
  5. 添加并启动磁条交管进程。改表后点「重新加载配置」,或进程内打开列表保存后重载。
  6. +
  7. 状态栏可见:规则条数、触发点等待数、在途数、扫车拍数。
  8. +
  9. 诊断通道 MagTraffic 会记录放行:区名、兼容组、触发点、本线占用/容量、冲突在途。
  10. +
+
+ 交管不会替任务系统停车。请保证 MagCar 到达触发点时处于停止,否则不会进入排队,也不会发启动。 +
+ +

10. 与 Mag2 / 其他交管的边界

+ + + + + + +
机制对象关系
本进程 mag-control-areas.jsonMagCar触发点停稳后发 StartMagCar
地图 Fass2_ControlArea 等Mag2CarStartStop=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