diff --git a/Directory.Build.targets b/Directory.Build.targets index e895908..99b416f 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -1,7 +1,7 @@ $(MSBuildThisFileDirectory) @@ -12,6 +12,7 @@ true true true + true + + + + + + + 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 适用

+ +

1.2 不适用

+ + +

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.Core/Properties/InternalsVisibleTo.cs b/StandardScene.Core/Properties/InternalsVisibleTo.cs index f356e92..550d247 100644 --- a/StandardScene.Core/Properties/InternalsVisibleTo.cs +++ b/StandardScene.Core/Properties/InternalsVisibleTo.cs @@ -6,5 +6,6 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("StandardScene.Devices")] [assembly: InternalsVisibleTo("StandardScene.Magnetic")] [assembly: InternalsVisibleTo("StandardScene.QrLidar")] +[assembly: InternalsVisibleTo("StandardScene.Signal")] // SimpleLite 反射 API 需读取 internal 字段袋(BasicTrackFields / KivaSiteFields 等)的 public 字段定义。 [assembly: InternalsVisibleTo("SimpleLite")] 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 new file mode 100644 index 0000000..2ad4688 --- /dev/null +++ b/StandardScene.Signal/Config/Signal/handshake-points.json @@ -0,0 +1,47 @@ +[ + { + "Sit": 98, + "SignalType": "JGControl", + "JgName": "上线机构1", + "DockId": "dock1", + "Action": "RequestIn" + }, + { + "Sit": 100, + "SignalType": "JGControl", + "JgName": "上线机构1", + "DockId": "dock1", + "Action": "Arrived" + }, + { + "Sit": 108, + "SignalType": "JGControl", + "JgName": "上线机构1", + "DockId": "dock2", + "Action": "RequestIn" + }, + { + "Sit": 199, + "SignalType": "JGControl", + "JgName": "下线机构1", + "DockId": "dock1", + "Action": "RequestIn" + }, + { + "Sit": 201, + "SignalType": "PointControl", + "JgName": "下线机构1", + "DockId": "dock1", + "Action": "UP1" + }, + { + "Sit": 50, + "SignalType": "TimedCharging", + "ExtraParams": "300;95;20" + }, + { + "Sit": 60, + "SignalType": "RateFlowControl", + "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/release-points.json b/StandardScene.Signal/Config/Signal/release-points.json new file mode 100644 index 0000000..d427146 --- /dev/null +++ b/StandardScene.Signal/Config/Signal/release-points.json @@ -0,0 +1,10 @@ +[ + { + "Sit": 10, + "IsUse": true + }, + { + "Sit": 20, + "IsUse": false + } +] 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 new file mode 100644 index 0000000..6259680 --- /dev/null +++ b/StandardScene.Signal/Config/Signal/stations.json @@ -0,0 +1,22 @@ +[ + { + "JgName": "上线机构1", + "Ip": "192.168.1.10", + "Port": 102, + "PlcType": "S7_1500", + "DbNumber": 100, + "HeartByte": 32, + "HeartBit": 0, + "LineType": "上线机构" + }, + { + "JgName": "下线机构1", + "Ip": "192.168.1.11", + "Port": 102, + "PlcType": "S7_1500", + "DbNumber": 100, + "HeartByte": 32, + "HeartBit": 0, + "LineType": "单牵引下线机构" + } +] diff --git a/StandardScene.Signal/Logic/CarScanWorker.cs b/StandardScene.Signal/Logic/CarScanWorker.cs new file mode 100644 index 0000000..0501873 --- /dev/null +++ b/StandardScene.Signal/Logic/CarScanWorker.cs @@ -0,0 +1,241 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using SimpleCore.Library; +using SimpleLite.RCS.CarTypes; +using StandardScene.Signal.Model; +using StandardScene.Signal.Plc; + +namespace StandardScene.Signal.Logic +{ + /// + /// 1s 扫全场车,对齐反编译 DefaultPlugin.ExcuteThreadMethod + StarSignManage。 + /// + /// + /// 每辆车每拍只看当前站点: + /// + /// 停稳:先按握手点类型分发 Handler,再执行放行点(放行点不读 PLC,可能把还在等允许进入的车开走,站点不要两边都配)。 + /// 行驶中:仅 JGControl + RequestIn 调用 RequestInJudge,PLC 未允许进入则停车。 + /// + /// 握手点解析优先站点 fields(),没有再查 JSON。 + /// + public sealed class CarScanWorker : IDisposable + { + /// 取当前全部机构运行态;由 Mission 提供,内部会拷贝列表。 + private readonly Func> _runtimes; + + /// 扫车间隔,下限 100ms,默认跟进程 TickMs=1000。 + private readonly int _tickMs; + + /// 保护握手点 / 放行点字典,热替换配置时与扫车拍互斥。 + private readonly object _configLock = new object(); + + /// 站点 → 握手点。同一站点多条配置只留第一条。 + private Dictionary _handshake = new Dictionary(); + + /// 站点 → 放行点。同一站点多条配置只留第一条。 + private Dictionary _release = new Dictionary(); + + /// 限流区域上次记日志的车号,避免同一区域每秒刷屏。 + private readonly Dictionary _rateFlowLog = new Dictionary(); + + /// 车辆没有 GetChargeTime 时,用首次看到「充电中」的时刻估算秒数。 + private readonly Dictionary _chargeStarted = new Dictionary(); + + /// 未知 SignalType 的日志节流(同站点 10s 一次)。 + private readonly Dictionary _unknownTypeLog = new Dictionary(); + + private CancellationTokenSource _cts; + private Thread _thread; + private int _tickCount; + + public CarScanWorker(Func> runtimes, int tickMs) + { + _runtimes = runtimes ?? throw new ArgumentNullException(nameof(runtimes)); + _tickMs = Math.Max(100, tickMs); + } + + /// 已完成的扫车拍数,写入进程 status 供监视。 + public int TickCount => _tickCount; + + /// + /// 热替换握手点 / 放行点。过滤掉站点号无效或信号类型为空的握手点。 + /// 不中断线程,下一拍生效。 + /// + public void ReplaceConfig(IEnumerable handshake, IEnumerable release) + { + lock (_configLock) + { + _handshake = (handshake ?? Array.Empty()) + .Where(x => x != null && x.Sit > 0 && !string.IsNullOrWhiteSpace(x.SignalType)) + .GroupBy(x => x.Sit) + .ToDictionary(g => g.Key, g => g.First()); + _release = (release ?? Array.Empty()) + .Where(x => x != null && x.Sit > 0) + .GroupBy(x => x.Sit) + .ToDictionary(g => g.Key, g => g.First()); + } + } + + /// 启动后台扫车线程。重复 Start 若线程仍活则忽略。 + public void Start() + { + if (_thread != null && _thread.IsAlive) return; + _cts = new CancellationTokenSource(); + _thread = new Thread(Loop) + { + IsBackground = true, + Name = "SignalScan" + }; + _thread.Start(); + Diagnosis.Log("扫车握手线程已启动", "Signal", true); + } + + /// 取消令牌并最多等 2s 退出。不抛异常,供进程停止时调用。 + public void Stop() + { + try { _cts?.Cancel(); } + catch { } + try { _thread?.Join(2000); } + catch { } + _thread = null; + Diagnosis.Log("扫车握手线程已停止", "Signal", true); + } + + public void Dispose() => Stop(); + + private void Loop() + { + var token = _cts.Token; + while (!token.IsCancellationRequested) + { + try + { + ScanOnce(); + Interlocked.Increment(ref _tickCount); + } + catch (Exception ex) + { + Diagnosis.Log($"检测信号停止的AGV是否可以放行异常:{ex.Message}", "Signal", true); + } + + try { Thread.Sleep(_tickMs); } + catch (ThreadInterruptedException) { break; } + } + } + + /// + /// 一拍:拷贝配置字典 → 按机构名查找闭包 → 遍历全部车。 + /// 查找机构时忽略大小写,与 JSON 里 JgName 对齐。 + /// + internal void ScanOnce() + { + Dictionary handshake; + Dictionary release; + lock (_configLock) + { + handshake = _handshake; + release = _release; + } + + PlcStationRuntime Find(string name, string dockId) + { + if (string.IsNullOrWhiteSpace(name)) + return null; + var list = _runtimes() ?? Array.Empty(); + 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()) + { + if (car == null) continue; + var sit = SignalCarAdapter.PositionId(car); + var spec = ResolveSpec(sit, handshake); + + if (SignalCarAdapter.IsStopped(car)) + { + if (spec != null) + DispatchStopped(car, spec, Find); + ReleasePointHandler.OnStopped(car, sit, release); + } + else if (spec != null && spec.Is("JGControl")) + { + JgControlHandler.OnMoving(car, spec, Find); + } + } + } + + /// + /// 站点 fields 有 SignalType 则用地图点配置(老 SignalStop); + /// 否则用 handshake-points.json。fields 存在但类型为空视为本站不握手。 + /// + private SignalStopSpec ResolveSpec(int sit, IReadOnlyDictionary handshake) + { + if (sit <= 0) return null; + + if (SignalCarAdapter.TryReadSignalStop(sit, out var type, out var parms)) + { + if (string.IsNullOrWhiteSpace(type)) + return null; + return SignalStopSpec.Parse(type, parms); + } + + if (handshake != null && handshake.TryGetValue(sit, out var model)) + return SignalStopSpec.From(model); + + return null; + } + + /// + /// 停稳分发。TimedCharging 优先:充电点不应再走机构逻辑。 + /// 未识别的 SignalType 按站点节流打日志,避免刷屏。 + /// + private void DispatchStopped(Car car, SignalStopSpec spec, Func find) + { + if (spec.Is("TimedCharging")) + { + TimedChargeHandler.OnStopped(car, spec, _chargeStarted); + return; + } + + if (spec.Is("JGControl")) + { + JgControlHandler.OnStopped(car, spec, find, _rateFlowLog); + return; + } + + if (spec.Is("PointControl")) + { + PointControlHandler.OnStopped(car, spec, find); + return; + } + + if (spec.Is("RateFlowControl")) + { + RateFlowHandler.OnStopped(car, spec, _rateFlowLog); + return; + } + + var sit = SignalCarAdapter.PositionId(car); + if (_unknownTypeLog.TryGetValue(sit, out var last) && (DateTime.Now - last).TotalSeconds < 10) + return; + _unknownTypeLog[sit] = DateTime.Now; + Diagnosis.Log($"信号类型【{spec.SignalType}】未找到匹配的业务处理模块", "Signal", true); + } + } +} diff --git a/StandardScene.Signal/Logic/JgControlHandler.cs b/StandardScene.Signal/Logic/JgControlHandler.cs new file mode 100644 index 0000000..7e16f94 --- /dev/null +++ b/StandardScene.Signal/Logic/JgControlHandler.cs @@ -0,0 +1,154 @@ +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 +{ + /// + /// 机构进出站握手。对齐反编译 DefaultPlugin.JGControl / JGJudge / RequestIn / Arrived。 + /// + /// + /// + /// SignalParams 分号三段:机构名;动作;限流。 + /// 动作 RequestIn(请求进入,配在机构口外等待点)或 Arrived(到位,配在机构内地标)。 + /// 第三段可选,格式与独立限流不同:区域-车组-限额-点1,点2(短横分隔,见 )。 + /// + /// + /// RequestIn 放行条件(需同时满足):PLC 读字节=允许进入、尚无请求进入车、写字节=无状态。 + /// 下线机构额外要求 JgPointId 上没有其它车。通过后写「进入中」、记下 RequestInAgvNo、停充并启动。 + /// + /// + /// 车已在跑但本车不是请求进入车、且 PLC 不是允许进入时,立即停车(停稳拍与行驶拍都会判)。 + /// Arrived:若 InAgvNo 还不是本车,写「到位」、登记 InAgvNo、清空 RequestInAgvNo。 + /// 之后的「离开中 / 已离开」由 PLC Worker 的 推进。 + /// + /// + public static class JgControlHandler + { + /// 停稳:可选限流 → RequestIn 或 Arrived。 + public static void OnStopped(Car car, SignalStopSpec spec, Func find, IDictionary rateFlowLog) + { + 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); + return; + } + + if (!string.IsNullOrEmpty(rateFlow) && !RateFlowHandler.AllowPass(car, rateFlow, rateFlowLog)) + { + Diagnosis.Log($"AGV{car.id}停在地标{SignalCarAdapter.PositionId(car)},限流禁止通行", "JGControl", true); + return; + } + + if (string.Equals(action, "RequestIn", StringComparison.OrdinalIgnoreCase)) + RequestIn(jgName, dockId, car, find); + else if (string.Equals(action, "Arrived", StringComparison.OrdinalIgnoreCase)) + Arrived(jgName, dockId, car, find); + } + + /// 行驶中只做 RequestIn 安全闸:未允许进入且不是本车占用请求槽则停车。 + public static void OnMoving(Car car, SignalStopSpec spec, Func find) + { + var jgName = spec.JgName; + var action = spec.Action; + if (string.IsNullOrEmpty(jgName)) + return; + if (string.Equals(action, "RequestIn", StringComparison.OrdinalIgnoreCase)) + RequestInJudge(jgName, spec.DockId, car, find); + } + + private static void RequestIn(string jgName, string dockId, Car car, Func find) + { + var rt = find(jgName, dockId); + if (rt == null) + { + Diagnosis.Log($"{jgName} 未能找到机构", "JGControl", true); + return; + } + + lock (rt.Sync) + { + if (rt.ReadSignal == SignalReadValue.允许进入 && + rt.RequestInAgvNo == 0 && + rt.ReadWriteSignal == SignalWriteValue.无状态) + { + if (rt.Station.LineType is SignalLineType.单牵引下线机构 or SignalLineType.双牵引下线机构) + { + var inside = SignalCarAdapter.CarsAt(rt.Dock.JgPointId); + if (inside.Count > 0) + { + Diagnosis.Log( + $"{rt.DisplayName} 机构内有AGV【{string.Join(",", inside.Select(x => x.id))}】暂不放行AGV{car.id}", + "JGControl", true); + return; + } + } + + Diagnosis.Log($"{rt.DisplayName} 机构允许进入,通知AGV{car.id}启动,通知机构AGV进入中", "JGControl", true); + rt.WriteSignal = SignalWriteValue.进入中; + rt.RequestInAgvNo = car.id; + SignalCarAdapter.StopCharge(car); + SignalCarAdapter.Start(car); + return; + } + + if (rt.ReadSignal != SignalReadValue.允许进入 && + rt.RequestInAgvNo != car.id && + SignalCarAdapter.IsMoving(car)) + { + SignalCarAdapter.Stop(car); + Diagnosis.Log($"{rt.DisplayName} 机构不允许进入,监控到AGV启动,暂停AGV{car.id}", "JGControl", true); + } + } + } + + private static void RequestInJudge(string jgName, string dockId, Car car, Func find) + { + var rt = find(jgName, dockId); + if (rt == null) + { + Diagnosis.Log($"{jgName} 未能找到机构", "JGControl", true); + return; + } + + lock (rt.Sync) + { + if (rt.ReadSignal != SignalReadValue.允许进入 && + rt.RequestInAgvNo != car.id && + SignalCarAdapter.IsMoving(car)) + { + SignalCarAdapter.Stop(car); + Diagnosis.Log($"{rt.DisplayName} 机构不允许进入,监控到AGV启动,暂停AGV{car.id}", "JGControl", true); + } + } + } + + private static void Arrived(string jgName, string dockId, Car car, Func find) + { + var rt = find(jgName, dockId); + if (rt == null) + { + Diagnosis.Log($"{jgName} 未能找到机构", "JGControl", true); + return; + } + + lock (rt.Sync) + { + if (rt.InAgvNo == car.id) + return; + 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 new file mode 100644 index 0000000..bd7cc14 --- /dev/null +++ b/StandardScene.Signal/Logic/PointControlHandler.cs @@ -0,0 +1,90 @@ +using System; +using SimpleCore.Library; +using SimpleLite.RCS.CarTypes; +using StandardScene.Signal.Model; +using StandardScene.Signal.Plc; + +namespace StandardScene.Signal.Logic +{ + /// + /// 销钉 / 牵引棒升降后再放行。对齐反编译 DefaultPlugin.PointControl。 + /// + /// + /// SignalParams动作;机构名。 + /// 动作:UP1 升销钉1,UP2 升销钉1+2,Down1 / Down2 对应下降。 + /// 机构名可选:填了则必须等该机构读字节=允许离开才启动;空则只等销钉动作完成。 + /// 销钉指令通过 反射调用车体 MethodMember(如 SetPin1Up)。 + /// + public static class PointControlHandler + { + /// 停稳:先判机构是否允许离开,再下发销钉,全部到位后启动。 + public static void OnStopped(Car car, SignalStopSpec spec, Func find) + { + var action = spec.Action; + var jgName = spec.JgName; + if (string.IsNullOrEmpty(action)) + { + Diagnosis.Log($"AGV{car.id}停在地标{SignalCarAdapter.PositionId(car)},读取配置特殊点名称为空", "SetPin", true); + return; + } + + var waitLeave = false; + if (!string.IsNullOrEmpty(jgName)) + { + var rt = find(jgName, spec.DockId); + if (rt != null) + { + lock (rt.Sync) + waitLeave = rt.ReadSignal != SignalReadValue.允许离开; + } + } + + var pos = SignalCarAdapter.PositionId(car); + switch (action) + { + case "UP1": + PinThenGo(car, pos, waitLeave, jgName, "SetPin1Up"); + break; + case "UP2": + PinThenGo(car, pos, waitLeave, jgName, "SetPin1Up", "SetPin2Up"); + break; + case "Down1": + PinThenGo(car, pos, waitLeave, jgName, "SetPin1Down"); + break; + case "Down2": + PinThenGo(car, pos, waitLeave, jgName, "SetPin1Down", "SetPin2Down"); + break; + } + } + + /// + /// 任一销钉未完成则本拍只下发指令、不启动; + /// 全部完成且(无需等离开 或 已允许离开)才 Start。 + /// + private static void PinThenGo(Car car, int pos, bool waitLeave, string jgName, params string[] actions) + { + var done = true; + foreach (var a in actions) + { + if (!SignalCarAdapter.IsActionFinish(car, a)) + done = false; + } + + if (!done) + { + foreach (var a in actions) + SignalCarAdapter.Control(car, a); + return; + } + + if (waitLeave) + { + Diagnosis.Log($"AGV{car.id}停在地标{pos},需要读取{jgName}工位的允许离开", "SetPin", true); + return; + } + + SignalCarAdapter.Start(car); + Diagnosis.Log($"AGV{car.id}停在地标{pos},牵引棒动作到位,已通知AGV启动", "SetPin", true); + } + } +} diff --git a/StandardScene.Signal/Logic/RateFlowHandler.cs b/StandardScene.Signal/Logic/RateFlowHandler.cs new file mode 100644 index 0000000..720a5eb --- /dev/null +++ b/StandardScene.Signal/Logic/RateFlowHandler.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using SimpleCore.Library; +using SimpleLite.RCS.CarTypes; +using StandardScene.Signal.Plc; + +namespace StandardScene.Signal.Logic +{ + /// + /// 区域车组限流。对齐反编译 DefaultPlugin.RateFlowControl。 + /// + /// + /// 独立握手类型 RateFlowControl 用分号:区域;车组;限额;点1,点2。 + /// 挂在 JGControl 第三段时用短横:区域-车组-限额-点1,点2(因为前两段机构名/动作已经占用分号)。 + /// 统计「站点列表上、同车组、且不是本车」的数量,小于限额才放行。 + /// 解析异常时放行(return true),避免配错把产线卡死。 + /// + public static class RateFlowHandler + { + /// 独立限流点:通过则停充并启动。 + public static void OnStopped(Car car, SignalStopSpec spec, IDictionary logByArea) + { + if (AllowPass(car, spec.SignalParams, logByArea, sep: ';')) + { + SignalCarAdapter.StopCharge(car); + SignalCarAdapter.Start(car); + } + } + + /// JGControl 第三段限流,短横分隔。不通过时调用方应直接 return,不启动。 + public static bool AllowPass(Car car, string rateFlowString, IDictionary logByArea) => + AllowPass(car, rateFlowString, logByArea, sep: '-'); + + /// 独立类型用 ';',JGControl 挂载用 '-'。 + private static bool AllowPass(Car car, string raw, IDictionary logByArea, char sep) + { + try + { + var parts = (raw ?? "").Split(sep); + var area = parts.Length > 0 ? parts[0] : ""; + var group = parts.Length > 1 ? parts[1] : ""; + var limit = 0; + if (parts.Length > 2) + int.TryParse(parts[2], out limit); + var sites = new List(); + if (parts.Length > 3) + { + foreach (var s in parts[3].Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)) + { + if (int.TryParse(s.Trim(), out var id)) + sites.Add(id); + } + } + + var others = SignalCarAdapter.CarsAt(sites) + .Where(x => x.id != car.id && string.Equals(SignalCarAdapter.GroupId(x), group, StringComparison.Ordinal)) + .ToList(); + var count = others.Count; + if (count < limit) + { + Diagnosis.Log( + $"限流区域{area}限制AGV组{group}流量{limit}辆AGV,当前停放{count},放行AGV{car.id}", + "RateFlow", true); + Note(logByArea, area, car, raw); + return true; + } + + Diagnosis.Log( + $"限流区域{area}限制AGV组{group}流量{limit}辆AGV,当前停放{count},无法放行", + "RateFlow", true); + Note(logByArea, area, car, raw); + return false; + } + catch (Exception ex) + { + Diagnosis.Log($"处理区域限流业务异常:{ex.Message}", "RateFlow", true); + return true; + } + } + + /// 同一限流区域仅在「决策车号变化」时再打一条带原始参数的日志。 + private static void Note(IDictionary logByArea, string area, Car car, string raw) + { + if (logByArea == null) return; + var changed = !logByArea.TryGetValue(area, out var last) || last != car.id; + logByArea[area] = car.id; + if (changed) + Diagnosis.Log($"AGV{car.id}停在地标{SignalCarAdapter.PositionId(car)},配置参数:【{raw}】", "RateFlow", true); + } + } +} diff --git a/StandardScene.Signal/Logic/ReleasePointHandler.cs b/StandardScene.Signal/Logic/ReleasePointHandler.cs new file mode 100644 index 0000000..7e37ed1 --- /dev/null +++ b/StandardScene.Signal/Logic/ReleasePointHandler.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using SimpleLite.RCS.CarTypes; +using StandardScene.Signal.Model; +using StandardScene.Signal.Plc; + +namespace StandardScene.Signal.Logic +{ + /// + /// 无条件放行点。对齐反编译 StarSignManage / StartSignModel。 + /// + /// + /// 车在配置站点停稳且 为 true 时:停充并启动,不读 PLC。 + /// 用于任务在某点停住、充完电或人工暂停后需要自动续跑的站点。 + /// 机构口应配握手点,不要同时配放行点——扫车线程在握手 Handler 之后仍会执行本方法,可能提前开走。 + /// + public static class ReleasePointHandler + { + /// 按当前站点号查放行表;未配置或未启用则什么都不做。 + public static void OnStopped(Car car, int sit, IReadOnlyDictionary points) + { + if (points == null || !points.TryGetValue(sit, out var p) || p == null || !p.IsUse) + return; + SignalCarAdapter.StopCharge(car); + SignalCarAdapter.Start(car); + } + } +} diff --git a/StandardScene.Signal/Logic/SignalStopSpec.cs b/StandardScene.Signal/Logic/SignalStopSpec.cs new file mode 100644 index 0000000..4e55b22 --- /dev/null +++ b/StandardScene.Signal/Logic/SignalStopSpec.cs @@ -0,0 +1,130 @@ +using System; +using StandardScene.Signal.Model; + +namespace StandardScene.Signal.Logic +{ + /// + /// 一条握手点配置的解析结果。新列(机构/工位/动作/附加参数)优先,旧 SignalParams 作回退。 + /// + public sealed class SignalStopSpec + { + public string SignalType { get; init; } = ""; + + public string SignalParams { get; init; } = ""; + + public string[] Parts { get; init; } = Array.Empty(); + + 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) + return ""; + return Parts[index] ?? ""; + } + + public static SignalStopSpec Parse(string signalType, string signalParams) + { + var type = (signalType ?? "").Trim(); + var raw = signalParams ?? ""; + var parsed = new SignalStopSpec + { + SignalType = type, + SignalParams = raw, + Parts = raw.Split(new[] { ';' }, StringSplitOptions.None) + }; + return EnrichFromParts(parsed); + } + + public static SignalStopSpec From(HandshakePointModel model) + { + if (model == null) + return null; + + 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/Logic/TimedChargeHandler.cs b/StandardScene.Signal/Logic/TimedChargeHandler.cs new file mode 100644 index 0000000..8392130 --- /dev/null +++ b/StandardScene.Signal/Logic/TimedChargeHandler.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using SimpleCore.Library; +using SimpleLite.RCS.CarTypes; +using StandardScene.Signal.Plc; + +namespace StandardScene.Signal.Logic +{ + /// + /// 站点定时充电。对齐反编译 DefaultPlugin.TimedCharging。 + /// + /// + /// SignalParams最长秒数;上限电量;下限电量,某段填 0 表示该条件不启用。 + /// 正在充电:电量 ≥ 上限则停充;时长到且电量已过下限则停充;时长到但电量仍 ≤ 下限则继续充。 + /// 已停充且车仍停着:视为充电流程结束,启动放行。 + /// 充电秒数优先反射 GetChargeTime,没有则用 字典估算。 + /// + public static class TimedChargeHandler + { + /// 停稳且本站是 TimedCharging 时由扫车线程调用。 + public static void OnStopped(Car car, SignalStopSpec spec, IDictionary chargeStarted) + { + int.TryParse(spec.Part(0), out var maxSeconds); + int.TryParse(spec.Part(1), out var maxSoc); + int.TryParse(spec.Part(2), out var minSoc); + + var charged = SignalCarAdapter.ChargeSeconds(car, chargeStarted); + var soc = SignalCarAdapter.Soc(car); + var pos = SignalCarAdapter.PositionId(car); + + if (SignalCarAdapter.IsCharging(car)) + { + Diagnosis.Log( + $"AGV{car.id}实时电量{soc}停在地标{pos}上已充电{charged}秒,信号停止类型为定时充电,参数为:{maxSeconds},{maxSoc},{minSoc}", + "Charge", true); + + if (maxSoc != 0 && soc >= maxSoc) + { + Diagnosis.Log($"AGV{car.id}电量大于配置的最高充电电量,故停止充电", "Charge", true); + SignalCarAdapter.StopCharge(car); + return; + } + + if (maxSeconds != 0 && charged >= maxSeconds) + { + if (minSoc != 0 && soc <= minSoc) + { + Diagnosis.Log($"AGV{car.id}充电时长已到达{maxSeconds}秒,但仍未充电至{minSoc}继续充电", "Charge", true); + return; + } + + Diagnosis.Log($"AGV{car.id}充电时长已到达{maxSeconds}秒,故停止充电", "Charge", true); + SignalCarAdapter.StopCharge(car); + } + + return; + } + + if (SignalCarAdapter.IsStopped(car)) + { + Diagnosis.Log($"AGV{car.id}已经停止充电,故放行AGV", "Charge", true); + SignalCarAdapter.Start(car); + } + } + } +} 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 new file mode 100644 index 0000000..1540e11 --- /dev/null +++ b/StandardScene.Signal/Mission/SignalInteractMission.cs @@ -0,0 +1,401 @@ +using System; +using System.Collections.Generic; +using System.Linq; +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.Plc; +using StandardScene.Signal.Ui; +using StandardScene.Utils; + +namespace StandardScene.Signal +{ + /// + /// 第三方信号交互进程(场景 scene.signal:PLC 握手 / 放行;磁条交管见 MagTrafficMission)。 + /// + /// + /// + /// 对齐梅山吉利反编译插件 CoreClass30.A20050.Plugin.DefaultPlugin: + /// 一边用西门子 S7 和机构 PLC 交换进出站字节,一边按站点对停稳 AGV 做握手 / 放行。 + /// + /// + /// 两条独立循环,不要合成一次 HTTP 或任务编排节点: + /// + /// + /// + /// PLC Worker + /// + /// 每个机构一条后台线程,默认 200ms:心跳翻转、读「允许进入/离开」、回读写字节、按需写入、 + /// 推进离站状态机。 + /// + /// + /// + /// 扫车线程 + /// + /// 默认 1s 扫全场车。停稳则按握手点类型分发给 + /// JGControl / PointControl / TimedCharging / RateFlowControl,再执行放行点; + /// 行驶中仅对 JGControl 的 RequestIn 做「未允许进入则急停」。 + /// + /// + /// + /// + /// 配置三张 JSON(工作目录 Config/Signal/,可由迷毂数据中心改,保存后点「重新加载配置」): + /// 机构 stations.json、握手点 handshake-points.json、放行点 release-points.json。 + /// 站点 fields 若已写 SignalType/SignalParams,优先于握手点表。 + /// + /// + /// CycleGUI 进程工具栏必须点「信号交互进程」,不要打开开放泛型编辑器。 + /// 列表窗体只改 JSON,不经过本进程的 HTTP;改完需本进程已启动才会重启 Worker。 + /// + /// + [MissionType(Name = "信号交互进程", editor = typeof(SignalInteractMission))] + [I18N.DocumentTranslation(Name = "Signal Interact Mission", locale = "en")] + public class SignalInteractMission : Mission + { + /// 扫车周期(毫秒)。对齐反编译 DefaultPlugin 主循环,现场一般保持 1000。 + [FieldMember] public int TickMs = 1000; + + /// 单机构 PLC 轮询周期(毫秒)。对齐 PLCManager,现场一般保持 200。 + [FieldMember] public int PlcPollMs = 200; + + /// + /// 西门子系列,传给 IoTClient SiemensVersion。 + /// 机构表 PLC类型为「跟随进程」时使用本项;机构单独指定则用机构值。 + /// + [FieldMember] public SignalPlcType PlcVersion = SignalPlcType.S7_1500; + + /// 机构未单独填 Port 时使用的 S7 端口,默认 102。 + [FieldMember] public int PlcPort = 102; + + /// S7 机架槽号,S7-1500 常见为 1;写入 + [FieldMember] public int PlcSlot = 1; + + /// 机构表路径。相对路径只解析工作目录 Config/Signal,不读插件目录。 + [FieldMember] public string StationsPath = @"Config\Signal\stations.json"; + + /// 放行点表路径。车停在这些站点且启用时,无条件停充并启动,不读 PLC。 + [FieldMember] public string ReleasePointsPath = @"Config\Signal\release-points.json"; + + /// 握手点表路径。按站点配置 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; + + /// 保护 :启动、停止、状态快照、扫车查找机构时共用。 + [JsonIgnore] private readonly object _workerLock = new object(); + + /// 当前在跑的机构 Worker,与 stations.json 一一对应(空名称的机构不会创建)。 + [JsonIgnore] private readonly List _workers = new List(); + + /// 全场扫车握手线程;停止进程时置空。 + [JsonIgnore] private CarScanWorker _scan; + + /// PLC 每 tick 都会回调刷新状态;用计数把 UI 刷新降到约每 5 个 poll 一次。 + [JsonIgnore] private int _statusTick; + + /// 进程监视面板展示的运行摘要(机构连接数、扫车次数、各站读写字节)。 + public class SignalInteractMissionStatus : MissionStatus + { + /// 当前 Worker 数量,等于已加载且名称非空的机构数。 + public int StationCount { get; set; } + + /// 最近一次快照里 IsConnected == true 的机构数。 + public int ConnectedCount { get; set; } + + /// 放行点 JSON 条数(含未启用)。 + public int ReleasePointCount { get; set; } + + /// 握手点 JSON 条数。 + public int HandshakePointCount { get; set; } + + /// ,用于确认扫车线程是否在跑。 + public int ScanTickCount { get; set; } + + /// 实际解析到的机构文件绝对路径。 + public string StationsFile { get; set; } + + /// 实际解析到的放行点文件绝对路径。 + public string ReleasePointsFile { get; set; } + + /// 实际解析到的握手点文件绝对路径。 + public string HandshakePointsFile { get; set; } + + /// 实际解析到的工位文件绝对路径。 + public string DocksFile { get; set; } + + /// 工位 JSON 条数。 + public int DockCount { get; set; } + + /// 各机构运行态快照,供监视列表展示。 + public List Stations { get; set; } = new List(); + } + + public override MissionStatus status { get; set; } = new SignalInteractMissionStatus(); + + /// SimpleLite 按 MissionType 反射创建实例时调用。 + public static Mission Create() => new SignalInteractMission(); + + /// + /// 加载三张 JSON,拉起全部 PLC Worker 和扫车线程。 + /// 重复点击会先停再建;失败时 status 为「启动失败」并记诊断。 + /// + [MethodMember(Name = "启动进程", Description = "加载配置,启动 PLC Worker 与扫车握手")] + public override void Execute() + { + try + { + ReloadConfig(); + _started = true; + RestartWorkers(); + RestartScan(); + var st = (SignalInteractMissionStatus)status; + Diagnosis.Post( + $"信号交互进程已启动,机构={st.StationCount},握手点={st.HandshakePointCount},放行点={st.ReleasePointCount}", + "Signal", true); + } + catch (Exception ex) + { + status.status = "启动失败"; + Diagnosis.Post($"信号交互进程启动失败:{ExceptionFormatter.FormatEx(ex)}", "Signal", true); + throw; + } + } + + /// + /// 重新读 JSON 并刷新 status 里的路径/条数。 + /// 进程已启动时:重建 PLC Worker,并向扫车线程热替换握手点/放行点(不必停进程)。 + /// 迷毂数据中心改完文件后应调本方法,否则内存里仍是旧表。 + /// + [MethodMember(Name = "重新加载配置", Description = "重新读取 JSON;若进程已启动则重启 Worker/扫车")] + public void ReloadConfig() + { + var stationsFile = SignalConfigStore.Resolve(StationsPath); + var docksFile = SignalConfigStore.Resolve(DocksPath); + var releaseFile = SignalConfigStore.Resolve(ReleasePointsPath); + var handshakeFile = SignalConfigStore.Resolve(HandshakePointsPath); + var bundles = PlcConfigLoader.Load(stationsFile, docksFile); + var points = SignalConfigStore.Load(releaseFile); + var handshake = SignalConfigStore.Load(handshakeFile); + + var st = (SignalInteractMissionStatus)status; + 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 = bundles.Select(b => new PlcStationSnapshot { JgName = b.Station.JgName }).ToList(); + status.status = _started ? "运行中" : "配置已加载"; + Diagnosis.Post( + $"信号配置已加载:机构 {bundles.Count},工位 {st.DockCount} @ {docksFile};握手点 {handshake.Count} @ {handshakeFile};放行点 {points.Count} @ {releaseFile}", + "Signal", true); + + if (_started) + { + RestartWorkers(); + _scan?.ReplaceConfig(handshake, points); + } + } + + /// 停扫车、停全部 PLC 连接。隐藏了基类 Stop,工具栏显示本方法。 + [MethodMember(Name = "停止进程")] + public new void Stop() + { + _started = false; + try { _scan?.Stop(); } + catch { } + _scan = null; + StopWorkers(); + status.status = "已停止"; + Diagnosis.Post("信号交互进程已停止", "Signal", true); + } + + /// CycleGUI 独立窗体编辑机构表;保存立刻写 JSON,需「重新加载配置」才进 Worker。 + [MethodMember(Name = "打开机构列表", Description = "按 PlcStationModel 增删改机构配置")] + public void OpenStations() + { + try + { + var path = SignalConfigStore.Resolve(StationsPath); + ModelListPanel.Open("PLC 机构配置", path, x => x?.JgName ?? ""); + } + catch (Exception ex) + { + CycleUiHelper.Alert("错误", $"打开机构列表失败:{ex.Message}"); + } + } + + /// 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() + { + try + { + var path = SignalConfigStore.Resolve(HandshakePointsPath); + ModelListPanel.Open( + "握手点配置", + path, + x => x == null ? "" : $"{x.Sit} {x.SignalType}"); + } + catch (Exception ex) + { + CycleUiHelper.Alert("错误", $"打开握手点列表失败:{ex.Message}"); + } + } + + /// CycleGUI 独立窗体编辑放行点;主键为站点号。 + [MethodMember(Name = "打开放行点列表", Description = "按 ReleasePointModel 增删改放行点")] + public void OpenReleasePoints() + { + try + { + var path = SignalConfigStore.Resolve(ReleasePointsPath); + ModelListPanel.Open("放行点配置", path, x => x == null ? "" : x.Sit.ToString()); + } + catch (Exception ex) + { + CycleUiHelper.Alert("错误", $"打开放行点列表失败:{ex.Message}"); + } + } + + /// + /// 扫车 Handler 用机构名查找运行态。返回拷贝列表,避免长时间持锁; + /// 运行态对象本身仍由各 Worker 持有,读写需 。 + /// + internal IReadOnlyList Runtimes + { + get + { + lock (_workerLock) + return _workers.SelectMany(w => w.Docks).ToList(); + } + } + + /// 停旧扫车线程,按当前 JSON 新建并启动。 + private void RestartScan() + { + try { _scan?.Stop(); } + catch { } + + var handshake = SignalConfigStore.Load(SignalConfigStore.Resolve(HandshakePointsPath)); + var release = SignalConfigStore.Load(SignalConfigStore.Resolve(ReleasePointsPath)); + _scan = new CarScanWorker(() => Runtimes, TickMs); + _scan.ReplaceConfig(handshake, release); + _scan.Start(); + } + + /// + /// 先停全部旧连接,再按机构表为每个非空名称建 。 + /// 机构 Port<=0 时回退进程级 ;PLC类型跟随进程时用 。 + /// + private void RestartWorkers() + { + StopWorkers(); + var bundles = PlcConfigLoader.Load( + SignalConfigStore.Resolve(StationsPath), + SignalConfigStore.Resolve(DocksPath)); + + var created = new List(); + lock (_workerLock) + { + foreach (var bundle in bundles) + { + var station = bundle.Station; + var port = station.Port > 0 ? station.Port : PlcPort; + 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); + } + } + + foreach (var worker in created) + worker.Start(); + + 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() + { + List copy; + lock (_workerLock) + { + copy = _workers.ToList(); + _workers.Clear(); + } + + foreach (var w in copy) + { + try { w.Stop(); } + catch { } + } + } + + /// PLC Worker 每周期回调;约每 5 次才刷一次监视面板,避免 200ms 打满 UI。 + private void RefreshStatus() + { + if (Interlocked.Increment(ref _statusTick) % 5 != 0) + return; + PushStatus(); + } + + /// 把各机构快照和扫车次数写进 MissionStatus,供 SimpleLite 进程监视。 + private void PushStatus() + { + List snaps; + lock (_workerLock) + snaps = _workers.SelectMany(w => w.Docks.Select(d => d.ToSnapshot())).ToList(); + + var st = (SignalInteractMissionStatus)status; + st.Stations = snaps; + st.ConnectedCount = snaps.Count(s => s.IsConnected); + st.StationCount = snaps.Count; + st.ScanTickCount = _scan?.TickCount ?? 0; + if (_started) + status.status = $"运行中 {st.ConnectedCount}/{st.StationCount} 已连接 扫车{_scan?.TickCount ?? 0}"; + } + } +} diff --git a/StandardScene.Signal/Model/HandshakePointModel.cs b/StandardScene.Signal/Model/HandshakePointModel.cs new file mode 100644 index 0000000..3228cc0 --- /dev/null +++ b/StandardScene.Signal/Model/HandshakePointModel.cs @@ -0,0 +1,47 @@ +using System.ComponentModel; +using Newtonsoft.Json; + +namespace StandardScene.Signal.Model +{ + /// + /// 握手点。JGControl / PointControl 填机构、工位、动作;定时充电 / 限流走附加参数。 + /// 旧 JSON 仅有 SignalParams 时仍可解析。 + /// + [SignalTable("handshake", "handshake-points.json", "握手点", Order = 3)] + [Category("握手点数据管理")] + [DisplayName("握手点配置列表")] + public class HandshakePointModel + { + [DisplayName("站点")] + public int Sit { get; set; } + + /// 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; } = ""; + + /// + /// TimedCharging:最长秒;上限电量;下限电量。 + /// RateFlowControl:区域;车组;限额;点列表。 + /// JGControl 可选限流:区域-车组-限额-点列表。 + /// + [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 new file mode 100644 index 0000000..6520722 --- /dev/null +++ b/StandardScene.Signal/Model/PlcStationModel.cs @@ -0,0 +1,77 @@ +using System.ComponentModel; +using Newtonsoft.Json; + +namespace StandardScene.Signal.Model +{ + /// + /// PLC 机构:一把 S7 连接 + 一块 DB。点位和地标在 。 + /// + [SignalTable("stations", "stations.json", "PLC机构", Order = 1)] + [Category("PLC数据管理")] + [DisplayName("PLC配置列表(机构名称不能重复)")] + public class PlcStationModel + { + /// 逻辑名,握手点机构名、Worker 线程名都用它。不能空、不能重复。 + [DisplayName("机构名称")] + public string JgName { get; set; } = ""; + + /// 西门子 PLC IP。 + [DisplayName("机构IP")] + public string Ip { get; set; } = "127.0.0.1"; + + /// S7 端口。填 0 则用进程字段 PlcPort(默认 102)。 + [DisplayName("端口")] + public int Port { get; set; } = 102; + + /// 西门子系列。跟随进程则用信号交互进程的 PlcVersion。 + [DisplayName("PLC类型")] + public SignalPlcType PlcType { get; set; } = SignalPlcType.跟随进程; + + /// DB 块号。工位 BOOL 与心跳都拼到这块 DB。 + [DisplayName("DB块号")] + public int DbNumber { get; set; } = 100; + + /// 心跳字节偏移。未填 HeartDb 时拼 DB{n}.DBX{HeartByte}.{HeartBit}。 + [DisplayName("心跳字节")] + public int HeartByte { get; set; } = 32; + + /// 心跳位,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/PlcStationSnapshot.cs b/StandardScene.Signal/Model/PlcStationSnapshot.cs new file mode 100644 index 0000000..5a5b827 --- /dev/null +++ b/StandardScene.Signal/Model/PlcStationSnapshot.cs @@ -0,0 +1,41 @@ +using System.ComponentModel; + +namespace StandardScene.Signal.Model +{ + /// + /// 单个机构运行态快照,供 MissionStatus / 监视面板展示。 + /// 由 拷贝,UI 不要直接绑 Runtime。 + /// + public class PlcStationSnapshot + { + /// 机构逻辑名。 + [DisplayName("机构")] public string JgName { get; set; } = ""; + + /// 最近一拍 PLC 读写是否成功。 + [DisplayName("连接")] public bool IsConnected { get; set; } + + /// PLC→调度读字节的枚举名(允许进入 / 允许离开 / 无状态)。 + [DisplayName("读信号")] public string ReadSignal { get; set; } = ""; + + /// 调度目标写字节的枚举名。 + [DisplayName("写信号")] public string WriteSignal { get; set; } = ""; + + /// 从写地址回读到的枚举名,用于确认 PLC 是否已吃进目标值。 + [DisplayName("回读写值")] public string ReadWriteSignal { get; set; } = ""; + + /// 已报到在机构内的 AGV 号,0 表示没有。 + [DisplayName("机构内车")] public int InAgvNo { get; set; } + + /// 已获准进入、正在往机构开的 AGV 号,0 表示没有。 + [DisplayName("请求进入车")] public int RequestInAgvNo { get; set; } + + /// 目标写字节是否已与回读一致。 + [DisplayName("写入成功")] public bool WriteSuccess { get; set; } + + /// Worker 内存心跳翻转,表示线程在跑。 + [DisplayName("心跳")] public bool Heart { get; set; } + + /// 最近一次断线或周期异常原因。 + [DisplayName("最近错误")] public string LastError { get; set; } = ""; + } +} diff --git a/StandardScene.Signal/Model/ReleasePointModel.cs b/StandardScene.Signal/Model/ReleasePointModel.cs new file mode 100644 index 0000000..b86df6b --- /dev/null +++ b/StandardScene.Signal/Model/ReleasePointModel.cs @@ -0,0 +1,26 @@ +using System.ComponentModel; + +namespace StandardScene.Signal.Model +{ + /// + /// 放行点配置。对应反编译 StartSignModel。 + /// + /// + /// 车停在 为 true 时,进程停充并启动,不读 PLC。 + /// 同一站点不要同时配握手点:扫车先握手再放行,可能把还在等允许进入的车开走。 + /// 站点号不能重复,列表编辑器用 Sit 做主键。 + /// + [SignalTable("release", "release-points.json", "放行点", Order = 4)] + [Category("放行点数据管理")] + [DisplayName("放行点配置列表")] + public class ReleasePointModel + { + /// 要自动放行的地图站点号。 + [DisplayName("放行站点")] + public int Sit { get; set; } + + /// false 时该条保留在 JSON 里但不生效,方便现场临时关掉某个点。 + [DisplayName("启用")] + public bool IsUse { get; set; } = true; + } +} diff --git a/StandardScene.Signal/Model/SignalEnums.cs b/StandardScene.Signal/Model/SignalEnums.cs new file mode 100644 index 0000000..cea3474 --- /dev/null +++ b/StandardScene.Signal/Model/SignalEnums.cs @@ -0,0 +1,90 @@ +namespace StandardScene.Signal.Model +{ + /// + /// PLC → 调度:读字节含义。对齐反编译 ENUM_READ_SIGNAL。 + /// 机构用这一个字节告诉 AGV 现在能不能进、能不能出。 + /// + public enum SignalReadValue : byte + { + /// 0:空闲 / 未允许。RequestIn 必须看到写字节也是无状态才放行。 + 无状态 = 0, + + /// 1:允许进入机构。扫车 RequestIn 的放行条件之一。 + 允许进入 = 1, + + /// 2:允许离开机构。下线升销钉、等待通行机构、PointControl 等会等这个值。 + 允许离开 = 2 + } + + /// + /// 调度 → PLC:写字节含义。对齐反编译 ENUM_WRITE_SIGNAL。 + /// AGV 用这一个字节向机构汇报当前处于进站流程的哪一步。 + /// + public enum SignalWriteValue : byte + { + /// 0:空闲。离站清空后回到此值,下一辆才能 RequestIn。 + 无状态 = 0, + + /// 1:已获准进入、正在往机构开。RequestIn 成功时写入。 + 进入中 = 1, + + /// 2:已到达机构内地标。Arrived 或上线 AutoLeave 检测到离开进入包含区时写入。 + 到位 = 2, + + /// 3:正在离开机构。上线看到车在跑、下线/等待通行启动后写入。 + 离开中 = 3, + + /// 4:已离开包含地标。PLC 确认后调度清零写字节和车号。 + 已离开 = 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 要不要查机构内是否有车。 + /// + public enum SignalLineType : byte + { + /// 0:未配置,AutoLeave 不处理。 + 未知 = 0, + + /// 1:上线。进站靠扫车 RequestIn/Arrived,离站跟车位自动推进到位/离开中/已离开。 + 上线机构 = 1, + + /// 2:单牵引下线。RequestIn 时若 JgPointId 上已有车则不放行;离站要升销钉1。 + 单牵引下线机构 = 2, + + /// 3:双牵引下线。同单牵引,离站还需销钉2到位。 + 双牵引下线机构 = 3, + + /// 4:等待通行。无销钉,允许离开后停充启动即可。 + 等待通行机构 = 4 + } +} 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 new file mode 100644 index 0000000..fc4fb29 --- /dev/null +++ b/StandardScene.Signal/Plc/PlcStationLogic.cs @@ -0,0 +1,216 @@ +using System; +using System.Linq; +using SimpleCore.Library; +using StandardScene.Signal.Model; + +namespace StandardScene.Signal.Plc +{ + /// + /// PLC 字节含义转换,以及按机构类型自动推进离站状态机。对齐反编译 PLCManager。 + /// + /// + /// 由 每 200ms 在已持有 时调用。 + /// 写字节含义见 :无状态 → 进入中 → 到位 → 离开中 → 已离开 → 清零。 + /// 「已离开」可由本侧写成功,或 PLC 回读写值变成已离开(对方清过)触发清空。 + /// + public static class PlcStationLogic + { + /// PLC 读字节 → 调度侧枚举。仅 1=允许进入、2=允许离开,其余当无状态。 + public static SignalReadValue ConvertRead(byte val) => val switch + { + 1 => SignalReadValue.允许进入, + 2 => SignalReadValue.允许离开, + _ => SignalReadValue.无状态 + }; + + /// PLC 写地址回读字节 → 调度侧枚举。1~4 对应进入中/到位/离开中/已离开。 + public static SignalWriteValue ConvertWrite(byte val) => val switch + { + 1 => SignalWriteValue.进入中, + 2 => SignalWriteValue.到位, + 3 => SignalWriteValue.离开中, + 4 => SignalWriteValue.已离开, + _ => SignalWriteValue.无状态 + }; + + /// + /// 判断站点是否落在机构配置的包含列表里。 + /// 列表支持逗号、分号、空格分隔,与 JSON 里 JgInContains / JgContains 一致。 + /// + public static bool ContainsSite(string csv, int siteId) + { + if (string.IsNullOrWhiteSpace(csv) || siteId <= 0) + return false; + return csv.Split(new[] { ',', ';', ' ' }, StringSplitOptions.RemoveEmptyEntries) + .Any(s => int.TryParse(s.Trim(), out var n) && n == siteId); + } + + /// 分发四套离站逻辑;未知类型不处理。 + public static void AutoLeave(PlcStationRuntime rt) + { + if (rt?.Station == null || rt.Dock == null) return; + switch (rt.Station.LineType) + { + case SignalLineType.上线机构: + AutoLeaveUpLine(rt); + break; + case SignalLineType.单牵引下线机构: + AutoLeaveDownLine(rt, pin2: false); + break; + case SignalLineType.双牵引下线机构: + AutoLeaveDownLine(rt, pin2: true); + break; + case SignalLineType.等待通行机构: + AutoLeaveWaitSit(rt); + break; + } + } + + /// + /// 离站完成:写字节清无状态、清空机构内车号。 + /// 若仍挂着 RequestInAgvNo(上线途中 PLC 已报已离开),一并清掉以免卡死下一辆。 + /// + private static void ClearLeft(PlcStationRuntime rt, bool fromReadback) + { + rt.WriteSignal = SignalWriteValue.无状态; + rt.InAgvNo = 0; + if (rt.RequestInAgvNo > 0) + { + Diagnosis.Log( + $"{rt.DisplayName} {(fromReadback ? "回读已离开" : "写已离开成功")},清空请求进入车 {rt.RequestInAgvNo}", + "JGControl", true); + rt.RequestInAgvNo = 0; + } + else + Diagnosis.Log($"{rt.DisplayName} 已离开,清空信号", "JGControl", true); + } + + /// + /// 上线机构:进站过程由扫车 RequestIn/Arrived 启动,离站由本方法跟车位推进。 + /// 进入中且车已离开 JgInContains → 视为到位; + /// 到位且车在跑 → 离开中; + /// 离开中且车已不在 JgContains → 已离开。 + /// + private static void AutoLeaveUpLine(PlcStationRuntime rt) + { + if (rt.WriteSuccess && rt.WriteSignal == SignalWriteValue.已离开) + ClearLeft(rt, fromReadback: false); + if (rt.ReadWriteSignal == SignalWriteValue.已离开) + ClearLeft(rt, fromReadback: true); + + if (rt.WriteSuccess && rt.WriteSignal == SignalWriteValue.进入中) + { + var car = SignalCarAdapter.Find(rt.RequestInAgvNo); + if (car == null) return; + if (!ContainsSite(rt.Dock.JgInContains, SignalCarAdapter.PositionId(car))) + { + rt.WriteSignal = SignalWriteValue.到位; + rt.InAgvNo = car.id; + rt.RequestInAgvNo = 0; + Diagnosis.Log($"AGV{car.id} 已到达机构 {rt.DisplayName}", "JGControl", true); + } + } + else if (rt.WriteSuccess && rt.WriteSignal == SignalWriteValue.到位) + { + var car = SignalCarAdapter.Find(rt.InAgvNo); + if (car == null || SignalCarAdapter.IsRunning(car)) + rt.WriteSignal = SignalWriteValue.离开中; + } + else if (rt.WriteSuccess && rt.WriteSignal == SignalWriteValue.离开中) + { + var car = SignalCarAdapter.Find(rt.InAgvNo); + if (car == null || !ContainsSite(rt.Dock.JgContains, SignalCarAdapter.PositionId(car))) + rt.WriteSignal = SignalWriteValue.已离开; + } + } + + /// + /// 下线机构:车已在机构内(InAgvNo),等允许离开后升销钉、停充、启动,写离开中; + /// 车离开 JgContains 后写已离开。 + /// 为 true 时双牵引,需销钉1+2都到位。 + /// + private static void AutoLeaveDownLine(PlcStationRuntime rt, bool pin2) + { + if (rt.WriteSuccess && rt.WriteSignal == SignalWriteValue.已离开) + { + ClearLeft(rt, fromReadback: false); + return; + } + + if (rt.ReadWriteSignal == SignalWriteValue.已离开) + ClearLeft(rt, fromReadback: true); + + if (rt.InAgvNo == 0) return; + var car = SignalCarAdapter.Find(rt.InAgvNo); + if (car == null) return; + + var pos = SignalCarAdapter.PositionId(car); + if (rt.ReadSignal == SignalReadValue.允许离开 && pos == rt.Dock.JgPointId) + { + if (rt.WriteSignal == SignalWriteValue.离开中) return; + var pinOk = SignalCarAdapter.IsActionFinish(car, "SetPin1Up") + && (!pin2 || SignalCarAdapter.IsActionFinish(car, "SetPin2Up")) + && SignalCarAdapter.IsActionFinish(car, "StopCharge"); + if (pinOk) + { + SignalCarAdapter.StopCharge(car); + SignalCarAdapter.Start(car); + rt.WriteSignal = SignalWriteValue.离开中; + Diagnosis.Log($"{rt.DisplayName} 允许离开,通知 AGV{car.id} 启动,写离开中", "JGControl", true); + } + else + { + SignalCarAdapter.Control(car, "SetPin1Up"); + if (pin2) SignalCarAdapter.Control(car, "SetPin2Up"); + SignalCarAdapter.Control(car, "StopCharge"); + } + } + else if (!string.IsNullOrEmpty(rt.Dock.JgContains) + && !ContainsSite(rt.Dock.JgContains, pos) + && rt.ReadWriteSignal != SignalWriteValue.已离开) + { + rt.WriteSignal = SignalWriteValue.已离开; + } + } + + /// + /// 等待通行机构:无销钉,允许离开且停在 JgPointId 时停充并启动,写离开中; + /// 离开包含地标后写已离开。 + /// + private static void AutoLeaveWaitSit(PlcStationRuntime rt) + { + if (rt.WriteSuccess && rt.WriteSignal == SignalWriteValue.已离开) + { + ClearLeft(rt, fromReadback: false); + return; + } + + if (rt.ReadWriteSignal == SignalWriteValue.已离开) + ClearLeft(rt, fromReadback: true); + + if (rt.InAgvNo == 0) return; + var car = SignalCarAdapter.Find(rt.InAgvNo); + if (car == null) return; + + var pos = SignalCarAdapter.PositionId(car); + 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.DisplayName} 允许离开,通知 AGV{car.id} 启动,写离开中", "JGControl", true); + } + else + SignalCarAdapter.Control(car, "StopCharge"); + } + 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 new file mode 100644 index 0000000..7278608 --- /dev/null +++ b/StandardScene.Signal/Plc/PlcStationRuntime.cs @@ -0,0 +1,101 @@ +using System; +using SimpleCore.Library; +using StandardScene.Signal.Model; + +namespace StandardScene.Signal.Plc +{ + /// + /// 单个工位运行态(不进 JSON)。扫车改写目标写状态,PLC Worker 负责下发 BOOL(或旧一字节枚举)。 + /// + public sealed class PlcStationRuntime + { + private SignalWriteValue _writeSignal; + private SignalReadValue _readSignal; + private SignalWriteValue _readWriteSignal; + + public PlcStationRuntime(PlcStationModel station, PlcStationDockModel dock, object sync = null) + { + Station = station ?? throw new ArgumentNullException(nameof(station)); + Dock = dock ?? throw new ArgumentNullException(nameof(dock)); + Sync = sync ?? new object(); + } + + public PlcStationModel Station { get; } + + 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}"; + + public SignalReadValue ReadSignal + { + get => _readSignal; + set + { + if (_readSignal != value) + Diagnosis.Log($"{DisplayName} 读信号 {_readSignal} → {value}", "PLC", true); + _readSignal = value; + } + } + + public SignalWriteValue WriteSignal + { + get => _writeSignal; + set + { + if (_writeSignal != value) + Diagnosis.Log($"{DisplayName} 写信号 {_writeSignal} → {value}", "PLC", true); + _writeSignal = value; + WriteSuccess = false; + } + } + + public SignalWriteValue ReadWriteSignal + { + get => _readWriteSignal; + set + { + if (_readWriteSignal != value) + Diagnosis.Log($"{DisplayName} 回读写值 {_readWriteSignal} → {value}", "PLC", true); + _readWriteSignal = value; + } + } + + public int InAgvNo { get; set; } + + public int RequestInAgvNo { get; set; } + + public bool IsConnected { get; set; } + + public bool WriteSuccess { get; set; } + + public bool Heart { get; set; } + + public string LastError { get; set; } = ""; + + public PlcStationSnapshot ToSnapshot() => new PlcStationSnapshot + { + JgName = DisplayName, + IsConnected = IsConnected, + ReadSignal = ReadSignal.ToString(), + WriteSignal = WriteSignal.ToString(), + ReadWriteSignal = ReadWriteSignal.ToString(), + InAgvNo = InAgvNo, + RequestInAgvNo = RequestInAgvNo, + WriteSuccess = WriteSuccess, + Heart = Heart, + LastError = LastError ?? "" + }; + } +} diff --git a/StandardScene.Signal/Plc/PlcStationWorker.cs b/StandardScene.Signal/Plc/PlcStationWorker.cs new file mode 100644 index 0000000..0b43600 --- /dev/null +++ b/StandardScene.Signal/Plc/PlcStationWorker.cs @@ -0,0 +1,306 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using SimpleCore.Library; +using StandardScene.Signal.Model; + +namespace StandardScene.Signal.Plc +{ + /// + /// 单机构 200ms 循环:心跳、按工位读 BOOL(或旧一字节)、回写变化位、自动离站。 + /// + 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; + private DateTime _lastFailLog = DateTime.MinValue; + + public PlcStationWorker( + PlcStationModel station, + IEnumerable docks, + SiemensPlcSession session, + object sync, + int pollMs, + Action onTick = null) + { + _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; + } + + public object Sync { get; } + + public IReadOnlyList Docks => _docks; + + public PlcStationRuntime Runtime => _docks.Count > 0 ? _docks[0] : null; + + public void Start() + { + if (_thread != null && _thread.IsAlive) return; + _cts = new CancellationTokenSource(); + _thread = new Thread(Loop) + { + IsBackground = true, + Name = $"PlcStation:{_station.JgName}" + }; + _thread.Start(); + Diagnosis.Log($"{_station.JgName} Worker 已启动 → {_station.Ip}:{_station.Port} 工位{_docks.Count}", "PLC", true); + } + + public void Stop() + { + try { _cts?.Cancel(); } + catch { } + try { _thread?.Join(2000); } + catch { } + _thread = null; + _session.Close(); + foreach (var d in _docks) + d.IsConnected = false; + Diagnosis.Log($"{_station.JgName} Worker 已停止", "PLC", true); + } + + public void Dispose() => Stop(); + + private void Loop() + { + var token = _cts.Token; + while (!token.IsCancellationRequested) + { + try + { + lock (Sync) + Tick(); + } + catch (Exception ex) + { + foreach (var d in _docks) + { + d.LastError = ex.Message; + d.IsConnected = false; + } + LogFail($"{_station.JgName} 周期异常 {ex.Message}"); + } + + try { _onTick?.Invoke(); } + catch { } + + try { Thread.Sleep(_pollMs); } + catch (ThreadInterruptedException) { break; } + } + } + + internal void Tick() + { + var heart = !_docks.Select(d => d.Heart).FirstOrDefault(); + foreach (var d in _docks) + d.Heart = heart; + + var heartAddr = PlcConfigLoader.ResolveHeartAddress(_station); + if (!string.IsNullOrWhiteSpace(heartAddr)) + { + if (_session.TryReadBool(heartAddr, out var bit)) + { + _session.TryWriteBool(heartAddr, !bit); + Thread.Sleep(20); + } + else + { + MarkDisconnected("心跳读写失败"); + return; + } + } + + 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)) + rt.ReadSignal = PlcStationLogic.ConvertRead(rb); + else + { + MarkDisconnected($"{rt.DisplayName} 读地址失败"); + return false; + } + } + + if (string.IsNullOrWhiteSpace(cfg.WriteDb)) + return true; + + if (!_session.TryReadByte(cfg.WriteDb, out var wb)) + { + MarkDisconnected($"{rt.DisplayName} 写地址回读失败"); + return false; + } + + 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) + { + foreach (var d in _docks) + { + d.IsConnected = false; + d.LastError = reason; + } + LogFail($"{_station.JgName} {reason}"); + } + + private void LogFail(string msg) + { + 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 new file mode 100644 index 0000000..8fa591b --- /dev/null +++ b/StandardScene.Signal/Plc/SiemensPlcSession.cs @@ -0,0 +1,195 @@ +using System; +using System.Text.RegularExpressions; +using IoTClient.Clients.PLC; +using IoTClient.Common.Enums; + +namespace StandardScene.Signal.Plc +{ + /// + /// 西门子 S7 长连接封装(IoTClient)。一个机构一个实例,由对应 Worker 独占。 + /// + /// + /// 字节地址兼容老 Hsl 写法 DB1.0 与 IoTClient 写法 DB1.DBB0; + /// 心跳位地址保持原样(如 DB1.DBX2.0),不做 DBB 转换。 + /// 连接失败时读/写返回 false,由 Worker 标断线,不在这里抛给扫车线程。 + /// + public sealed class SiemensPlcSession : IDisposable + { + /// 匹配 Hsl 风格「DB块号.字节偏移」,捕获组 1=DB,2=偏移。 + private static readonly Regex BareDbByte = new Regex(@"^DB(\d+)\.(\d+)$", RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private readonly SiemensVersion _version; + private readonly string _ip; + private readonly int _port; + private readonly byte _slot; + private SiemensClient _client; + private readonly object _sync = new object(); + + /// 西门子系列名,如 S7_1500;空或无法解析时默认 S7_1500。 + /// 机构 IP。 + /// S7 端口,通常 102。 + /// 机架槽号,S7-1500 常见 1。 + public SiemensPlcSession(string version, string ip, int port, int slot) + { + _version = ParseVersion(version); + _ip = ip ?? ""; + _port = port > 0 ? port : 102; + _slot = (byte)Math.Max(0, slot); + } + + /// 当前客户端是否已 Open 成功。未创建客户端时为 false。 + public bool Connected + { + get + { + lock (_sync) return _client?.Connected == true; + } + } + + /// 惰性创建客户端并 Open。已连接则直接 true;失败返回 false 不抛。 + public bool EnsureOpen() + { + lock (_sync) + { + try + { + if (_client == null) + _client = new SiemensClient(_version, _ip, _port, _slot); + if (_client.Connected) + return true; + var r = _client.Open(); + return r != null && r.IsSucceed; + } + catch + { + return false; + } + } + } + + /// 读一字节。地址会先规范化;未连接或失败时 value=0 且返回 false。 + public bool TryReadByte(string address, out byte value) + { + value = 0; + if (string.IsNullOrWhiteSpace(address) || !EnsureOpen()) + return false; + try + { + var r = _client.ReadByte(NormalizeByteAddress(address)); + if (r == null || !r.IsSucceed) + return false; + value = r.Value; + return true; + } + catch + { + return false; + } + } + + /// 写一字节(进入中/到位等)。失败返回 false。 + public bool TryWriteByte(string address, byte value) + { + if (string.IsNullOrWhiteSpace(address) || !EnsureOpen()) + return false; + try + { + var r = _client.Write(NormalizeByteAddress(address), value); + return r != null && r.IsSucceed; + } + catch + { + return false; + } + } + + /// 从起始字节连续读 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) + { + value = false; + if (string.IsNullOrWhiteSpace(address) || !EnsureOpen()) + return false; + try + { + var r = _client.ReadBoolean(address.Trim()); + if (r == null || !r.IsSucceed) + return false; + value = r.Value; + return true; + } + catch + { + return false; + } + } + + /// 写心跳位(通常写当前值的反码,让 PLC 看到翻转)。 + public bool TryWriteBool(string address, bool value) + { + if (string.IsNullOrWhiteSpace(address) || !EnsureOpen()) + return false; + try + { + var r = _client.Write(address.Trim(), value); + return r != null && r.IsSucceed; + } + catch + { + return false; + } + } + + /// 关闭并丢弃客户端,下次 EnsureOpen 会新建。 + public void Close() + { + lock (_sync) + { + try { _client?.Close(); } + catch { } + _client = null; + } + } + + public void Dispose() => Close(); + + /// + /// DB1.0DB1.DBB0;已是 DBB/DBW/DBX 的地址原样返回。 + /// + internal static string NormalizeByteAddress(string address) + { + var addr = address.Trim(); + var m = BareDbByte.Match(addr); + return m.Success ? $"DB{m.Groups[1].Value}.DBB{m.Groups[2].Value}" : addr; + } + + /// 空或无法解析时默认 S7_1500。允许把 S7-1500 写成带连字符。 + internal static SiemensVersion ParseVersion(string version) + { + if (string.IsNullOrWhiteSpace(version)) + return SiemensVersion.S7_1500; + return Enum.TryParse(version.Replace("-", "_"), true, out SiemensVersion parsed) + ? parsed + : SiemensVersion.S7_1500; + } + } +} diff --git a/StandardScene.Signal/Plc/SignalCarAdapter.cs b/StandardScene.Signal/Plc/SignalCarAdapter.cs new file mode 100644 index 0000000..f6143d7 --- /dev/null +++ b/StandardScene.Signal/Plc/SignalCarAdapter.cs @@ -0,0 +1,325 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Reflection; +using SimpleCore; +using SimpleCore.PropType; +using SimpleLite.RCS; +using SimpleLite.RCS.CarTypes; +using StandardScene.Signal.Site; + +namespace StandardScene.Signal.Plc +{ + /// + /// 信号插件访问 AGV / 站点的适配层。不引用 Magnetic.dll,按方法名反射启停和牵引棒。 + /// + /// + /// MagCar / Mag2Car 上有对应 MethodMember(StartMag2Car、SetPin1Up 等)时生效; + /// 没有 IsActionFinish 则视为动作已完成,避免非磁导航车卡在销钉等待。 + /// 启停回退顺序:StartMag2Car / StartMagCar → SendStart(0)。 + /// + public static class SignalCarAdapter + { + /// 当前内核里的全部车;SimpleLib 异常时返回空,避免扫车线程崩掉。 + public static IEnumerable All() + { + try + { + return SimpleLib.GetAllCars().OfType(); + } + catch + { + return Array.Empty(); + } + } + + /// 按车号取车;id≤0 返回 null。 + public static Car Find(int id) + { + if (id <= 0) return null; + return SimpleLib.GetCar(id) as Car; + } + + /// + /// 当前站点:优先 siteID,否则 GetLastSite()。 + /// 握手 / 放行 / 限流都用这个号去对 JSON 里的 Sit。 + /// + public static int PositionId(Car car) + { + if (car == null) return 0; + try + { + if (car.siteID > 0) return car.siteID; + var last = car.GetLastSite(); + return last > 0 ? last : 0; + } + catch + { + return 0; + } + } + + /// 读车 status.enums["State"],没有则空串。 + public static string GetState(Car car) + { + if (car?.status?.enums != null && + car.status.enums.TryGetValue("State", out var state) && + !string.IsNullOrWhiteSpace(state)) + return state; + return ""; + } + + /// State 为「运行中」或 Running。 + public static bool IsMoving(Car car) + { + var state = GetState(car); + return state == "运行中" || + string.Equals(state, "Running", StringComparison.OrdinalIgnoreCase); + } + + /// + /// 对齐反编译 RunStatus==停止:非运行中即视为可握手,含充电中 / 停止中 / WaitPass。 + /// 充电点必须把「充电中」算停稳,否则 TimedCharging 不会进 Handler。 + /// + public static bool IsStopped(Car car) + { + if (car == null) return false; + if (IsMoving(car)) return false; + var state = GetState(car); + if (state == "停止中" || state == "充电中" || + string.Equals(state, "Stopping", StringComparison.OrdinalIgnoreCase) || + string.Equals(state, "Charging", StringComparison.OrdinalIgnoreCase) || + string.Equals(state, "WaitPass", StringComparison.OrdinalIgnoreCase)) + return true; + return !IsRunning(car); + } + + /// 正在充电(中英文 State)。 + public static bool IsCharging(Car car) + { + var state = GetState(car); + return state == "充电中" || + string.Equals(state, "Charging", StringComparison.OrdinalIgnoreCase); + } + + /// + /// 车仍占资源:运行中,或有 pendingLocks,或 tags 含 occupied。 + /// 上线机构「到位后开始离开」用这个判断,比单纯看 State 更早切到离开中。 + /// + public static bool IsRunning(Car car) + { + if (car == null) return false; + if (IsMoving(car)) return true; + if (car.status?.pendingLocks != null && car.status.pendingLocks.Length > 0) + return true; + if (car.tags != null && car.tags.Contains("occupied")) + return true; + return false; + } + + /// 电量百分比,读 enums["Soc"];解析失败当 0。 + public static double Soc(Car car) + { + if (car?.status?.enums != null && + car.status.enums.TryGetValue("Soc", out var soc) && + double.TryParse(soc, NumberStyles.Float, CultureInfo.InvariantCulture, out var value)) + return value; + return 0; + } + + /// 限流用的车组:fields["group"] 或 ["GroupID"]。 + public static string GroupId(Car car) + { + if (car?.fields == null) return ""; + if (car.fields.TryGetValue("group", out var g) && !string.IsNullOrWhiteSpace(g)) + return g; + if (car.fields.TryGetValue("GroupID", out var g2) && !string.IsNullOrWhiteSpace(g2)) + return g2; + return ""; + } + + /// 当前停在给定站点集合上的全部车(按 PositionId)。 + public static IReadOnlyList CarsAt(IEnumerable siteIds) + { + var set = new HashSet(siteIds ?? Array.Empty()); + if (set.Count == 0) + return Array.Empty(); + return All().Where(c => set.Contains(PositionId(c))).ToList(); + } + + /// 当前停在单个站点上的车。下线 RequestIn 用 JgPointId 查机构内是否已有车。 + public static IReadOnlyList CarsAt(int siteId) + { + if (siteId <= 0) return Array.Empty(); + return CarsAt(new[] { siteId }); + } + + /// 读站点 fields 指定键。站点不存在或值为空返回 false。 + public static bool TryReadSiteField(int siteId, string key, out string value) + { + value = ""; + if (siteId <= 0 || string.IsNullOrWhiteSpace(key)) + return false; + try + { + var site = SimpleLib.GetSite(siteId); + if (site?.fields == null) + return false; + if (!site.fields.TryGetValue(key, out var raw) || string.IsNullOrWhiteSpace(raw)) + return false; + value = raw; + return true; + } + catch + { + return false; + } + } + + /// + /// 地图点是否自带 SignalStop(SignalType 字段存在且非空)。 + /// SignalParams 可缺省。有类型则扫车不再查 handshake-points.json。 + /// + public static bool TryReadSignalStop(int siteId, out string signalType, out string signalParams) + { + signalType = ""; + signalParams = ""; + if (!TryReadSiteField(siteId, SignalSiteFields.SignalType, out signalType)) + return false; + TryReadSiteField(siteId, SignalSiteFields.SignalParams, out signalParams); + return true; + } + + /// + /// 已充电秒数。优先反射 GetChargeTime(int/double/TimeSpan); + /// 没有则从首次看到充电中的时刻算,离开充电态时清字典。 + /// + public static int ChargeSeconds(Car car, IDictionary startedAt) + { + if (car == null) return 0; + var mi = car.GetType().GetMethod("GetChargeTime", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (mi != null) + { + try + { + var r = mi.Invoke(car, null); + if (r is int i) return i; + if (r is double d) return (int)d; + if (r is TimeSpan ts) return (int)ts.TotalSeconds; + } + catch + { + } + } + + if (IsCharging(car)) + { + if (!startedAt.ContainsKey(car.id)) + startedAt[car.id] = DateTime.Now; + return (int)(DateTime.Now - startedAt[car.id]).TotalSeconds; + } + + startedAt.Remove(car.id); + 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) + { + if (car == null) return; + if (TryInvoke(car, "StartMag2Car") || TryInvoke(car, "StartMagCar")) + return; + 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) + { + if (car == null) return; + if (TryInvoke(car, "StopMag2Car") || TryInvoke(car, "StopMagCar")) + return; + TryInvoke(car, "SendStop", new object[] { (ushort)0 }); + } + + /// 停充。握手放行前通常先停充再启动。 + public static void StopCharge(Car car) + { + if (car == null) return; + TryInvoke(car, "StopCharge"); + } + + /// + /// 销钉/停充等动作是否完成。车类型没有 IsActionFinish 时返回 true(非磁导航不阻塞)。 + /// 反射异常也当完成,避免把产线卡死。 + /// + public static bool IsActionFinish(Car car, string action) + { + if (car == null) return false; + if (string.IsNullOrWhiteSpace(action)) return true; + var mi = car.GetType().GetMethod("IsActionFinish", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (mi == null) + return true; + try + { + var r = mi.Invoke(car, new object[] { action }); + return r is true; + } + catch + { + return true; + } + } + + /// 下发动作:先按动作名直接调 MethodMember,没有再 ImmediateCommand(action)。 + public static void Control(Car car, string action) + { + if (car == null || string.IsNullOrWhiteSpace(action)) return; + if (TryInvoke(car, action)) return; + TryInvoke(car, "ImmediateCommand", new object[] { action }); + } + + /// + /// 按名称找实例方法。无参与带参分开匹配,重载参数个数不对就跳过。 + /// 调用抛错返回 false,让上层试下一个名字。 + /// + private static bool TryInvoke(object target, string name, object[] args = null) + { + var type = target.GetType(); + var methods = type.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .Where(m => string.Equals(m.Name, name, StringComparison.Ordinal)); + foreach (var mi in methods) + { + var ps = mi.GetParameters(); + if (args == null) + { + if (ps.Length != 0) continue; + try { mi.Invoke(target, null); return true; } + catch { return false; } + } + + if (ps.Length != args.Length) continue; + try { mi.Invoke(target, args); return true; } + catch { return false; } + } + + return false; + } + } +} diff --git a/StandardScene.Signal/SignalConfigStore.cs b/StandardScene.Signal/SignalConfigStore.cs new file mode 100644 index 0000000..ac4ef75 --- /dev/null +++ b/StandardScene.Signal/SignalConfigStore.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace StandardScene.Signal +{ + /// + /// 信号插件 JSON 的路径解析与读写。 + /// + /// + /// 只使用 SimpleLite 工作目录 Config/Signal/*.json,不读、不拷 plugins/Config/Signal。 + /// 文件不存在时 Load 返回空表;Save 会创建目录。 + /// 迷毂数据中心走同一套文件;保存后需在本进程点「重新加载配置」。 + /// 枚举按名字序列化(),与样例里 "上线机构" 一致。 + /// + public static class SignalConfigStore + { + /// 保存时串行写盘,避免 CycleGUI 与迷毂 API 同时 Save 互相覆盖一半。 + private static readonly object FileLock = new object(); + + private static readonly JsonSerializerSettings JsonSettings = new JsonSerializerSettings + { + Formatting = Formatting.Indented, + NullValueHandling = NullValueHandling.Ignore, + Converters = { new StringEnumConverter() } + }; + + /// + /// 相对路径固定落到工作目录(SimpleLite.exe 所在目录)下的 Config/Signal。 + /// 已是绝对路径则原样返回。文件不存在也返回该路径,不回退插件目录。 + /// + public static string Resolve(string configuredPath) + { + if (string.IsNullOrWhiteSpace(configuredPath)) + configuredPath = Path.Combine("Config", "Signal", "stations.json"); + + if (Path.IsPathRooted(configuredPath)) + return configuredPath; + + return Path.Combine(AppDomain.CurrentDomain.BaseDirectory, configuredPath); + } + + /// + /// 反序列化为列表。文件不存在、空、或 JSON 损坏时返回空列表,不抛给启动流程。 + /// + public static List Load(string path) where T : class + { + try + { + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + return new List(); + var json = File.ReadAllText(path); + if (string.IsNullOrWhiteSpace(json)) + return new List(); + return JsonConvert.DeserializeObject>(json, JsonSettings) ?? new List(); + } + catch + { + return new List(); + } + } + + /// 缩进写入;自动建目录。与 Load 共用枚举按名序列化。 + public static void Save(string path, IReadOnlyList items) + { + var json = JsonConvert.SerializeObject(items ?? Array.Empty(), JsonSettings); + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir)) + Directory.CreateDirectory(dir); + lock (FileLock) + File.WriteAllText(path, json); + } + } +} diff --git a/StandardScene.Signal/Site/SignalSiteFields.cs b/StandardScene.Signal/Site/SignalSiteFields.cs new file mode 100644 index 0000000..3dca11a --- /dev/null +++ b/StandardScene.Signal/Site/SignalSiteFields.cs @@ -0,0 +1,19 @@ +namespace StandardScene.Signal.Site +{ + /// + /// 站点 fields 键名,替代老地图 SignalStop 对象。 + /// + /// + /// 地图编辑器若未挂字段袋,仍可手工在站点 fields 写入这两个键。 + /// 扫车解析顺序:站点有非空 SignalType → 用地图点;否则回退 handshake-points.json。 + /// 键名必须与迷毂/地图侧约定完全一致,改这里要同步改地图字段袋。 + /// + public static class SignalSiteFields + { + /// 握手类型,取值同 + public const string SignalType = "SignalType"; + + /// 握手参数,取值同 + public const string SignalParams = "SignalParams"; + } +} diff --git a/StandardScene.Signal/StandardScene.Signal.csproj b/StandardScene.Signal/StandardScene.Signal.csproj new file mode 100644 index 0000000..720eef2 --- /dev/null +++ b/StandardScene.Signal/StandardScene.Signal.csproj @@ -0,0 +1,65 @@ + + + + + net8.0-windows + Library + StandardScene.Signal + StandardScene.Signal + latest + true + AnyCPU;x64 + x64 + true + false + disable + disable + false + $(NoWarn);NU1701;CS0618;CS0612;MSB3277;CA1416 + {HintPathFromItem};{TargetFrameworkDirectory};{RawFileName};{GAC} + + + + + + + + + + + + + + + + $(StandRefDir)CommonUsage.dll + + + $(StandRefDir)CycleGUI.dll + false + + + $(StandRefDir)LessokajiWeaverUtilities.dll + + + $(StandRefDir)SimpleCore.dll + + + $(StandSimpleLiteDir)SimpleLite.dll + + + $(StandRefDir)Topaz.dll + + + + + + + + + diff --git a/StandardScene.Signal/StandardScene.Signal.scene.json b/StandardScene.Signal/StandardScene.Signal.scene.json new file mode 100644 index 0000000..f6cd8ad --- /dev/null +++ b/StandardScene.Signal/StandardScene.Signal.scene.json @@ -0,0 +1,10 @@ +{ + "id": "scene.signal", + "displayName": "第三方信号交互", + "assembly": "StandardScene.Signal.dll", + "coreVersion": ">=1.0.0", + "requiresCore": "StandardScene.dll", + "provides": { + "missionTypes": [ "SignalInteractMission", "MagTrafficMission" ] + } +} diff --git a/StandardScene.Signal/Ui/ModelListPanel.cs b/StandardScene.Signal/Ui/ModelListPanel.cs new file mode 100644 index 0000000..6038cfd --- /dev/null +++ b/StandardScene.Signal/Ui/ModelListPanel.cs @@ -0,0 +1,55 @@ +using System; +using CycleGUI; + +namespace StandardScene.Signal.Ui +{ + /// + /// CycleGUI 独立窗口打开某一类 Model 列表(进程工具栏「打开机构/握手点/放行点列表」触发)。 + /// + /// + /// 每种 T 共用一个静态窗口:已打开则前置,关掉后下次再 DeclarePanel。 + /// 真正的增删改在 ,保存直接写 JSON, + /// 不自动 Reload 进程内 Worker,需再到进程上点「重新加载配置」。 + /// + public static class ModelListPanel where T : class, new() + { + /// 当前已声明的面板;关闭或异常后置 null,允许再次 Open。 + private static Panel _panel; + + private static readonly ModelTableEditor Editor = new ModelTableEditor(); + + /// 窗口标题。 + /// JSON 绝对路径(应先经 SignalConfigStore.Resolve)。 + /// 主键,添加/保存时查重(机构名、站点号等)。 + public static void Open(string title, string path, Func keySelector) + { + Editor.Bind(path, keySelector ?? (_ => ""), typeof(T).Name); + + if (_panel != null) + { + try { _panel.BringToFront(); return; } + catch { _panel = null; } + } + + var panel = GUI.DeclarePanel() + .ShowTitle(title) + .SetDefaultDocking(Panel.Docking.None) + .InitSize(1100, 720) + .InitPos(false, 0, 0, 0.5f, 0.5f, 0.5f, 0.5f); + _panel = panel; + panel.IfTerminalQuit(() => { if (_panel == panel) _panel = null; }); + + panel.Define(pb => + { + if (pb.Closing()) + { + panel.Exit(); + _panel = null; + return; + } + + Editor.Render(pb); + }); + } + } +} diff --git a/StandardScene.Signal/Ui/ModelTableEditor.cs b/StandardScene.Signal/Ui/ModelTableEditor.cs new file mode 100644 index 0000000..ec516f8 --- /dev/null +++ b/StandardScene.Signal/Ui/ModelTableEditor.cs @@ -0,0 +1,355 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Reflection; +using CycleGUI; +using Newtonsoft.Json; +using StandardScene.Utils; + +namespace StandardScene.Signal.Ui +{ + /// + /// 按 Model 的 反射生成可增删改表格。 + /// + /// + /// 列顺序取属性 MetadataToken(源码声明顺序)。 + /// 跳过不可写、索引器、 为 false、 的属性。 + /// 编辑区用独立字典暂存,点「添加」或「保存当前行」才反写对象并 。 + /// CycleGUI 控件 id 带 _idPrefix,三种表同时开窗时不会抢同一组控件状态。 + /// + public sealed class ModelTableEditor where T : class, new() + { + private static PropertyInfo[] Props => _props ??= Discover(); + private static PropertyInfo[] _props; + + private List _items = new List(); + + /// -1 表示未选中,此时「保存/删除」会提示先选行,「添加」写入新行。 + private int _selected = -1; + + private string _status = ""; + private string _path = ""; + private string _idPrefix = typeof(T).Name; + private Func _keySelector = _ => ""; + + /// 字符串/数值编辑缓存,键为属性名。 + private readonly Dictionary _text = new Dictionary(); + + private readonly Dictionary _bools = new Dictionary(); + + /// 枚举下拉当前下标,写入时按 Enum.GetNames 转回枚举值。 + private readonly Dictionary _enumIdx = new Dictionary(); + + public int Count => _items.Count; + + public string Status => _status; + + /// 绑定 JSON 路径并立即 Reload。同一编辑器换文件时调用。 + public void Bind(string path, Func keySelector, string idPrefix) + { + _path = path ?? ""; + _keySelector = keySelector ?? (_ => ""); + _idPrefix = string.IsNullOrWhiteSpace(idPrefix) ? typeof(T).Name : idPrefix; + Reload(); + } + + /// 从磁盘重读,丢弃未保存的编辑区内容。 + public void Reload() + { + _items = SignalConfigStore.Load(_path); + _selected = -1; + _status = $"已加载 {_items.Count} 条 · {_path}"; + ClearEditor(); + } + + /// 列表 + 编辑区 + 添加/保存/删除/清空/刷新。每帧由面板 Define 调用。 + public void Render(PanelBuilder pb) + { + var headers = Props.Select(DisplayOf).Concat(new[] { "行操作" }).ToArray(); + pb.SeparatorText($"列表({_items.Count})"); + pb.Table($"dc-list-{_idPrefix}", headers, _items.Count, (row, i) => + { + var item = _items[i]; + foreach (var p in Props) + row.Label(FormatCell(p.GetValue(item))); + if (row.ButtonGroup(new[] { "选择" }, new[] { "选择该行" }) == 0) + { + _selected = i; + LoadEditor(item); + } + }, height: 10, enableSearch: true); + + pb.SeparatorText(_selected >= 0 ? $"编辑第 {_selected + 1} 行" : "编辑(未选中则「添加」写入新行)"); + DrawEditor(pb); + + if (pb.Button("添加", distinct: $"dc-{_idPrefix}-add")) Add(); + pb.SameLine(8); + if (pb.Button("保存当前行", distinct: $"dc-{_idPrefix}-save")) SaveRow(); + pb.SameLine(8); + if (pb.Button("删除当前行", distinct: $"dc-{_idPrefix}-del")) ConfirmDelete(); + pb.SameLine(8); + if (pb.Button("清空编辑区", distinct: $"dc-{_idPrefix}-clear")) + { + _selected = -1; + ClearEditor(); + _status = "已清空编辑区,可添加新行"; + } + pb.SameLine(8); + if (pb.Button("从文件刷新", distinct: $"dc-{_idPrefix}-reload")) + Reload(); + + if (!string.IsNullOrEmpty(_status)) + { + pb.Separator(); + pb.Label(_status); + } + } + + /// 发现可编辑列。Browsable=false 或 JsonIgnore 的运行态字段不会进表。 + private static PropertyInfo[] Discover() + { + return typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.CanRead && p.CanWrite && p.GetIndexParameters().Length == 0) + .Where(p => p.GetCustomAttribute()?.Browsable != false) + .Where(p => p.GetCustomAttribute() == null) + .OrderBy(p => p.MetadataToken) + .ToArray(); + } + + private static string DisplayOf(PropertyInfo p) => + p.GetCustomAttribute()?.DisplayName ?? p.Name; + + private static string FormatCell(object value) + { + if (value == null) return ""; + if (value is bool b) return b ? "是" : "否"; + return Convert.ToString(value) ?? ""; + } + + private void DrawEditor(PanelBuilder pb) + { + for (var i = 0; i < Props.Length; i++) + { + var p = Props[i]; + var label = $"{_idPrefix}-{i + 1}. {DisplayOf(p)}"; + var type = p.PropertyType; + if (type == typeof(bool)) + { + if (!_bools.ContainsKey(p.Name)) _bools[p.Name] = false; + var v = _bools[p.Name]; + pb.CheckBox(label, ref v); + _bools[p.Name] = v; + } + else if (type.IsEnum) + { + var names = Enum.GetNames(type); + if (names.Length == 0) continue; + if (!_enumIdx.ContainsKey(p.Name)) _enumIdx[p.Name] = 0; + var idx = _enumIdx[p.Name]; + if (idx < 0 || idx >= names.Length) idx = 0; + pb.DropdownBox(label, names, ref idx); + _enumIdx[p.Name] = idx; + } + else + { + if (!_text.ContainsKey(p.Name)) _text[p.Name] = ""; + var (text, _) = pb.TextInput(label, _text[p.Name], alwaysReturnString: true); + _text[p.Name] = text ?? ""; + } + } + } + + private void LoadEditor(T item) + { + foreach (var p in Props) + { + var value = p.GetValue(item); + if (p.PropertyType == typeof(bool)) + _bools[p.Name] = value is true; + else if (p.PropertyType.IsEnum) + { + var names = Enum.GetNames(p.PropertyType); + var name = value?.ToString() ?? names.FirstOrDefault() ?? ""; + var idx = Array.IndexOf(names, name); + _enumIdx[p.Name] = idx >= 0 ? idx : 0; + } + else + _text[p.Name] = value == null ? "" : Convert.ToString(value) ?? ""; + } + } + + private void ClearEditor() + { + _text.Clear(); + _bools.Clear(); + _enumIdx.Clear(); + foreach (var p in Props) + { + if (p.PropertyType == typeof(bool)) + _bools[p.Name] = false; + else if (p.PropertyType.IsEnum) + _enumIdx[p.Name] = 0; + else if (p.PropertyType == typeof(int) || p.PropertyType == typeof(byte) || p.PropertyType == typeof(ushort)) + _text[p.Name] = "0"; + else + _text[p.Name] = DefaultText(p); + } + } + + private static string DefaultText(PropertyInfo p) + { + try + { + var fresh = new T(); + var v = p.GetValue(fresh); + return v == null ? "" : Convert.ToString(v) ?? ""; + } + catch + { + return ""; + } + } + + /// 把编辑区打成对象。主键空、整数解析失败时返回 false 并给出中文 err。 + private bool TryBuild(out T item, out string err) + { + item = new T(); + err = ""; + foreach (var p in Props) + { + try + { + object boxed; + if (p.PropertyType == typeof(bool)) + boxed = _bools.TryGetValue(p.Name, out var b) && b; + else if (p.PropertyType.IsEnum) + { + var names = Enum.GetNames(p.PropertyType); + var idx = _enumIdx.TryGetValue(p.Name, out var e) ? e : 0; + if (idx < 0 || idx >= names.Length) idx = 0; + boxed = Enum.Parse(p.PropertyType, names[idx]); + } + else if (p.PropertyType == typeof(int)) + { + var raw = _text.TryGetValue(p.Name, out var t) ? t : "0"; + if (!int.TryParse(string.IsNullOrWhiteSpace(raw) ? "0" : raw.Trim(), out var n)) + { + err = $"{DisplayOf(p)} 必须是整数"; + return false; + } + boxed = n; + } + else if (p.PropertyType == typeof(ushort)) + { + var raw = _text.TryGetValue(p.Name, out var t) ? t : "0"; + if (!ushort.TryParse(string.IsNullOrWhiteSpace(raw) ? "0" : raw.Trim(), out var n)) + { + err = $"{DisplayOf(p)} 必须是 0-65535"; + return false; + } + boxed = n; + } + else if (p.PropertyType == typeof(byte)) + { + var raw = _text.TryGetValue(p.Name, out var t) ? t : "0"; + if (!byte.TryParse(string.IsNullOrWhiteSpace(raw) ? "0" : raw.Trim(), out var n)) + { + err = $"{DisplayOf(p)} 必须是 0-255"; + return false; + } + boxed = n; + } + else + boxed = _text.TryGetValue(p.Name, out var s) ? s ?? "" : ""; + p.SetValue(item, boxed); + } + catch (Exception ex) + { + err = $"{DisplayOf(p)} 写入失败:{ex.Message}"; + return false; + } + } + + var key = (_keySelector(item) ?? "").Trim(); + if (string.IsNullOrEmpty(key)) + { + err = "主键不能为空"; + return false; + } + + return true; + } + + /// 主键已存在则拒绝。成功后立刻写盘并把选中行指到新行。 + private void Add() + { + if (!TryBuild(out var item, out var err)) + { + CycleUiHelper.Alert("错误", err); + return; + } + + var key = _keySelector(item); + if (_items.Any(x => string.Equals(_keySelector(x), key, StringComparison.OrdinalIgnoreCase))) + { + CycleUiHelper.Alert("错误", $"主键「{key}」已存在"); + return; + } + + _items.Add(item); + _selected = _items.Count - 1; + SignalConfigStore.Save(_path, _items); + _status = $"已添加 {key}"; + } + + /// 覆盖当前选中行。主键与其它行冲突则拒绝。 + private void SaveRow() + { + if (_selected < 0 || _selected >= _items.Count) + { + CycleUiHelper.Alert("提示", "请先选择要保存的行,或用「添加」写入新行"); + return; + } + + if (!TryBuild(out var item, out var err)) + { + CycleUiHelper.Alert("错误", err); + return; + } + + var key = _keySelector(item); + if (_items.Where((_, i) => i != _selected) + .Any(x => string.Equals(_keySelector(x), key, StringComparison.OrdinalIgnoreCase))) + { + CycleUiHelper.Alert("错误", $"主键「{key}」已存在"); + return; + } + + _items[_selected] = item; + SignalConfigStore.Save(_path, _items); + _status = $"已保存 {key}"; + CycleUiHelper.Alert("提示", "保存成功"); + } + + /// 确认框通过后删除选中行并写盘。 + private void ConfirmDelete() + { + if (_selected < 0 || _selected >= _items.Count) + { + CycleUiHelper.Alert("提示", "请先选择要删除的行"); + return; + } + + var key = _keySelector(_items[_selected]); + CycleUiHelper.ConfirmThen($"删除「{key}」?", () => + { + _items.RemoveAt(_selected); + _selected = -1; + ClearEditor(); + SignalConfigStore.Save(_path, _items); + _status = $"已删除 {key}"; + }); + } + } +} diff --git a/StandardScene.sln b/StandardScene.sln index 50d1de0..9db748e 100644 --- a/StandardScene.sln +++ b/StandardScene.sln @@ -17,6 +17,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandardScene.Fass2Simulato EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandardScene.Magnetic.Tests", "StandardScene.Magnetic.Tests\StandardScene.Magnetic.Tests.csproj", "{7C13685A-D666-4404-8CD7-19AA9BFC907B}" 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 @@ -111,6 +115,30 @@ Global {7C13685A-D666-4404-8CD7-19AA9BFC907B}.Release|x64.Build.0 = Release|Any CPU {7C13685A-D666-4404-8CD7-19AA9BFC907B}.Release|x86.ActiveCfg = Release|Any CPU {7C13685A-D666-4404-8CD7-19AA9BFC907B}.Release|x86.Build.0 = Release|Any CPU + {7E5050AA-0009-4A11-9C22-0A0B0C0D0E09}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7E5050AA-0009-4A11-9C22-0A0B0C0D0E09}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7E5050AA-0009-4A11-9C22-0A0B0C0D0E09}.Debug|x64.ActiveCfg = Debug|Any CPU + {7E5050AA-0009-4A11-9C22-0A0B0C0D0E09}.Debug|x64.Build.0 = Debug|Any CPU + {7E5050AA-0009-4A11-9C22-0A0B0C0D0E09}.Debug|x86.ActiveCfg = Debug|Any CPU + {7E5050AA-0009-4A11-9C22-0A0B0C0D0E09}.Debug|x86.Build.0 = Debug|Any CPU + {7E5050AA-0009-4A11-9C22-0A0B0C0D0E09}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7E5050AA-0009-4A11-9C22-0A0B0C0D0E09}.Release|Any CPU.Build.0 = Release|Any CPU + {7E5050AA-0009-4A11-9C22-0A0B0C0D0E09}.Release|x64.ActiveCfg = Release|Any CPU + {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 diff --git a/_inspect_datacenter.csx b/_inspect_datacenter.csx new file mode 100644 index 0000000..717f076 --- /dev/null +++ b/_inspect_datacenter.csx @@ -0,0 +1,18 @@ +#r "D:\工作\stand\SimpleLite\SimpleLite.dll" +using System; +using System.Linq; +using System.Reflection; + +var asm = typeof(SimpleLite.RCS.Mission).Assembly; +Console.WriteLine("asm=" + asm.FullName); +foreach (var t in asm.GetTypes().OrderBy(x => x.FullName)) +{ + var n = t.FullName ?? ""; + if (n.IndexOf("Data", StringComparison.OrdinalIgnoreCase) >= 0 + || n.IndexOf("Json", StringComparison.OrdinalIgnoreCase) >= 0 + || n.IndexOf("Table", StringComparison.OrdinalIgnoreCase) >= 0 + || n.IndexOf("Center", StringComparison.OrdinalIgnoreCase) >= 0 + || n.IndexOf("Store", StringComparison.OrdinalIgnoreCase) >= 0 + || n.Contains("UI")) + Console.WriteLine(n); +}