Merge pull request 'Cursor/add scene signal plugin' (#3) from cursor/add-scene-signal-plugin into main

Reviewed-on: http://it.fairylandtech.com:8666/Third_Dev_Dept/StandardSence/pulls/3
This commit was merged in pull request #3.
This commit is contained in:
2026-08-24 15:18:34 +08:00
70 changed files with 9114 additions and 21 deletions
+21 -1
View File
@@ -1,7 +1,7 @@
<Project> <Project>
<!-- <!--
StandardScene 插件工程编译后,自动将产物复制到 build\plugins\SimpleComposer / SimpleLite 宿主加载目录)。 StandardScene 插件工程编译后,自动将产物复制到 build\plugins\SimpleComposer / SimpleLite 宿主加载目录)。
适用:StandardScene.Core + Magnetic / QrLidar / Devices / Protocol.VDA5050 等卫星插件。 适用:StandardScene.Core + Magnetic / QrLidar / Devices / Protocol.VDA5050 / Signal 等卫星插件。
--> -->
<PropertyGroup> <PropertyGroup>
<StandardSceneRepoRoot>$(MSBuildThisFileDirectory)</StandardSceneRepoRoot> <StandardSceneRepoRoot>$(MSBuildThisFileDirectory)</StandardSceneRepoRoot>
@@ -12,6 +12,7 @@
<IsStandardScenePluginCopy Condition="'$(MSBuildProjectName)' == 'StandardScene.QrLidar'">true</IsStandardScenePluginCopy> <IsStandardScenePluginCopy Condition="'$(MSBuildProjectName)' == 'StandardScene.QrLidar'">true</IsStandardScenePluginCopy>
<IsStandardScenePluginCopy Condition="'$(MSBuildProjectName)' == 'StandardScene.Devices'">true</IsStandardScenePluginCopy> <IsStandardScenePluginCopy Condition="'$(MSBuildProjectName)' == 'StandardScene.Devices'">true</IsStandardScenePluginCopy>
<IsStandardScenePluginCopy Condition="'$(MSBuildProjectName)' == 'StandardScene.Protocol.VDA5050'">true</IsStandardScenePluginCopy> <IsStandardScenePluginCopy Condition="'$(MSBuildProjectName)' == 'StandardScene.Protocol.VDA5050'">true</IsStandardScenePluginCopy>
<IsStandardScenePluginCopy Condition="'$(MSBuildProjectName)' == 'StandardScene.Signal'">true</IsStandardScenePluginCopy>
</PropertyGroup> </PropertyGroup>
<Target Name="CopyStandardScenePluginOutputs" <Target Name="CopyStandardScenePluginOutputs"
@@ -28,12 +29,31 @@
<!-- Devices 插件运行时依赖 --> <!-- Devices 插件运行时依赖 -->
<StandardScenePluginCopyFile Include="$(TargetDir)leegKeys-sdk.dll" <StandardScenePluginCopyFile Include="$(TargetDir)leegKeys-sdk.dll"
Condition="Exists('$(TargetDir)leegKeys-sdk.dll')" /> Condition="Exists('$(TargetDir)leegKeys-sdk.dll')" />
<StandardScenePluginCopyFile Include="$(TargetDir)IoTClient.dll"
Condition="Exists('$(TargetDir)IoTClient.dll')" />
<StandardScenePluginCopyFile Include="$(NuGetPackageRoot)iotclient\1.0.40\lib\netstandard2.0\IoTClient.dll"
Condition="'$(MSBuildProjectName)' == 'StandardScene.Signal' and Exists('$(NuGetPackageRoot)iotclient\1.0.40\lib\netstandard2.0\IoTClient.dll')" />
<StandardScenePluginCopyFile Include="$(MSBuildProjectDirectory)\Config\Signal\signal-tables.json"
Condition="'$(MSBuildProjectName)' == 'StandardScene.Signal' and Exists('$(MSBuildProjectDirectory)\Config\Signal\signal-tables.json')" />
</ItemGroup> </ItemGroup>
<Copy SourceFiles="@(StandardScenePluginCopyFile)" <Copy SourceFiles="@(StandardScenePluginCopyFile)"
DestinationFolder="$(StandardScenePluginsDir)" DestinationFolder="$(StandardScenePluginsDir)"
SkipUnchangedFiles="true" /> SkipUnchangedFiles="true" />
<Copy SourceFiles="$(TargetPath)"
DestinationFolder="$(StandSimpleLiteDir)plugins"
SkipUnchangedFiles="true"
Condition="'$(MSBuildProjectName)' == 'StandardScene.Signal' and Exists('$(StandSimpleLiteDir)plugins')" />
<Copy SourceFiles="$(TargetDir)$(TargetName).pdb"
DestinationFolder="$(StandSimpleLiteDir)plugins"
SkipUnchangedFiles="true"
Condition="'$(MSBuildProjectName)' == 'StandardScene.Signal' and Exists('$(TargetDir)$(TargetName).pdb') and Exists('$(StandSimpleLiteDir)plugins')" />
<Copy SourceFiles="$(TargetDir)$(TargetName).scene.json"
DestinationFolder="$(StandSimpleLiteDir)plugins"
SkipUnchangedFiles="true"
Condition="'$(MSBuildProjectName)' == 'StandardScene.Signal' and Exists('$(TargetDir)$(TargetName).scene.json') and Exists('$(StandSimpleLiteDir)plugins')" />
<Message Importance="high" <Message Importance="high"
Text="[StandardScene] 已复制 $(MSBuildProjectName) 插件产物到 $(StandardScenePluginsDir)" /> Text="[StandardScene] 已复制 $(MSBuildProjectName) 插件产物到 $(StandardScenePluginsDir)" />
</Target> </Target>
+355
View File
@@ -0,0 +1,355 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>磁条交管逻辑与策略说明</title>
<style>
@page { size: A4; margin: 18mm 16mm 20mm 16mm; }
* { box-sizing: border-box; }
body {
font-family: "Microsoft YaHei", "PingFang SC", "SimSun", sans-serif;
font-size: 11pt;
line-height: 1.65;
color: #1a1a1a;
margin: 0;
}
h1 { font-size: 22pt; margin: 0 0 8px; letter-spacing: 0.5px; }
h2 {
font-size: 15pt;
margin: 28px 0 10px;
padding-bottom: 4px;
border-bottom: 2px solid #1f4e79;
color: #1f4e79;
page-break-after: avoid;
}
h3 {
font-size: 12.5pt;
margin: 18px 0 8px;
color: #2e75b6;
page-break-after: avoid;
}
h4 { font-size: 11.5pt; margin: 14px 0 6px; color: #333; }
p { margin: 6px 0 10px; text-align: justify; }
.cover {
text-align: center;
padding: 72px 24px 40px;
page-break-after: always;
}
.cover .sub { color: #555; font-size: 13pt; margin-top: 12px; }
.meta { margin-top: 64px; color: #444; font-size: 11pt; line-height: 1.9; }
.badge {
display: inline-block;
border: 1px solid #1f4e79;
color: #1f4e79;
padding: 2px 10px;
font-size: 10pt;
border-radius: 3px;
}
table {
width: 100%;
border-collapse: collapse;
margin: 8px 0 14px;
font-size: 10pt;
page-break-inside: avoid;
}
th, td {
border: 1px solid #b4b4b4;
padding: 6px 8px;
vertical-align: top;
}
th { background: #1f4e79; color: #fff; font-weight: 600; }
tr:nth-child(even) td { background: #f4f7fb; }
code, .mono {
font-family: Consolas, "Courier New", monospace;
font-size: 9.5pt;
background: #f3f3f3;
padding: 0 4px;
}
pre {
background: #f6f6f6;
border: 1px solid #ddd;
padding: 10px 12px;
font-size: 9pt;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-all;
page-break-inside: avoid;
}
ol, ul { margin: 6px 0 12px; padding-left: 22px; }
li { margin: 3px 0; }
.note {
background: #fff8e6;
border-left: 4px solid #e6a23c;
padding: 8px 12px;
margin: 10px 0 14px;
}
.ok {
background: #eef8f0;
border-left: 4px solid #67c23a;
padding: 8px 12px;
margin: 10px 0 14px;
}
.warn {
background: #fdeeee;
border-left: 4px solid #c0392b;
padding: 8px 12px;
margin: 10px 0 14px;
}
.flow {
background: #f7fafc;
border: 1px solid #d0d7de;
padding: 10px 14px;
margin: 8px 0 14px;
page-break-inside: avoid;
}
.toc a { color: #1f4e79; text-decoration: none; }
.toc li { margin: 5px 0; }
.small { font-size: 9.5pt; color: #555; }
.footer-note { margin-top: 28px; color: #777; font-size: 9.5pt; }
</style>
</head>
<body>
<div class="cover">
<p class="badge">FASS 2.0 / scene.signal</p>
<h1>磁条交管逻辑与策略说明</h1>
<p class="sub">MagCar 管控区排队、兼容组并行与扫车放行</p>
<div class="meta">
适用对象:现场实施、调度配置、软件维护<br />
配置文件:SimpleLite 工作目录 <span class="mono">Config/Signal/mag-control-areas.json</span><br />
进程名称:磁条交管进程(MagTrafficMission<br />
文档日期:2026-08-18
</div>
</div>
<h2>1. 文档目的与适用范围</h2>
<p>本文说明 FASS 2.0 磁导航场景中,<b>磁条交管进程</b>如何根据管控区配置表对 <b>MagCar</b>(FASS 1.0 磁条车)进行停车等待、排队和放行。读者按本文即可完成十字路口、汇合点、单入口限流的配表,并理解扫车每一拍在做什么。</p>
<h3>1.1 适用</h3>
<ul>
<li>车型仅 <b>MagCar</b>。类型名必须恰好为 MagCar。</li>
<li>车在配置的<b>管控触发站点</b>停稳后,由本进程发 FASS 1.0 启动指令(<span class="mono">StartMagCar</span>)。</li>
<li>配置表由迷毂数据中心「磁条管控区」或进程内「打开管控区列表」维护,保存到上述 JSON。</li>
</ul>
<h3>1.2 不适用</h3>
<ul>
<li><b>Mag2Car</b> 不走本进程。Mag2 仍使用地图字段 <span class="mono">Fass2_ControlArea</span> / <span class="mono">Fass2_ControlCapacity</span>,以及任务报文 StartStop=12→11,本进程不改 Mag2 报文。</li>
<li>内核 <span class="mono">TrafficControl.TryLock</span> 点锁、交通管制进程 <span class="mono">traffic.json</span>、环线任务的流量字段,与本表独立。</li>
<li>不负责让车在触发点停车。车必须先由任务/站点策略停在触发点,本进程只负责「满足条件后发启动」。</li>
</ul>
<h2>2. 总体策略</h2>
<p>交管策略可以概括为四句话:</p>
<ol>
<li><b>按区绑定</b>:同一「管控区名称」的多行表示同一个物理路口(或汇合区)。</li>
<li><b>按线占用</b>:每一行有自己的内部站点列表和容量,占用只统计本行站点,不把整个路口并成一份。</li>
<li><b>冲突才互斥</b>:同区里,兼容组相同,或两行内部站点有交集,视为冲突,不能同时放;否则可并行。</li>
<li><b>冲突才排队</b>:优先级、到达时刻、等待时长只在冲突线路之间生效。不冲突的线路互不插队、互不占名额。</li>
</ol>
<div class="ok">
兼容组留空且同区多行都为空时,视为同一组,<b>整区互斥</b>,与早期「同区容量 1 只放一辆」的行为兼容。旧 JSON 不填兼容组即可沿用。
</div>
<h2>3. 配置表(交管策略字段)</h2>
<p>主键是<b>管控触发站点</b>。同一触发点多条启用记录只保留第一条。未启用(IsUse=false)的行保留在文件中但不参与判定。</p>
<table>
<tr><th style="width:18%">字段</th><th style="width:16%">JSON 键</th><th>含义与配法</th></tr>
<tr>
<td>管控触发站点</td>
<td class="mono">TriggerSit</td>
<td>MagCar 必须停稳在此站点后才进入本行排队。应对齐现场停车点,不要把路口内部行驶点当成触发点。</td>
</tr>
<tr>
<td>管控区名称</td>
<td class="mono">AreaName</td>
<td>同名多行绑成一个路口,共用等待时长(取最长)。空则该触发点自成一区,相当于单入口限流。</td>
</tr>
<tr>
<td>兼容组</td>
<td class="mono">CompatGroup</td>
<td>同区里互不干扰的线路填<b>不同组名</b>(如东西 EW、南北 NS)。会交叉、会抢同一段路的填<b>同组</b>。空字符串与其他空组视为同组。</td>
</tr>
<tr>
<td>优先级</td>
<td class="mono">Priority</td>
<td>整数,越大越先放。干道高、支路低。只在冲突线路之间比较。</td>
</tr>
<tr>
<td>等待时长</td>
<td class="mono">WaitSeconds</td>
<td>秒。同区多行不一致时取<b>最长</b>。≤0 表示不做插队。用于防止支路被干道一直堵住。</td>
</tr>
<tr>
<td>管控区站点列表</td>
<td class="mono">ControlArea</td>
<td>本线路穿过路口的<b>内部站点</b>,逗号分隔,如 10,11。不要把各方向排队触发点写进来。空则回退为触发点本身。</td>
</tr>
<tr>
<td>区内最多几辆</td>
<td class="mono">ControlCapacity</td>
<td>本行容量。小于 1 按 1 处理。统计范围:占用本行内部站点的车 + 与本行冲突、已发启动但未进区的在途车。</td>
</tr>
<tr>
<td>放行后启停字节</td>
<td class="mono">ControlReleaseStartStop</td>
<td>兼容字段。MagCar 放行发 FASS 1.0 启动指令,不使用该字节。</td>
</tr>
<tr>
<td>启用</td>
<td class="mono">IsUse</td>
<td>false 时本行不生效。</td>
</tr>
</table>
<h3>3.1 运行态(不入库)</h3>
<table>
<tr><th>名称</th><th>说明</th></tr>
<tr>
<td>到达时刻</td>
<td>进程第一次扫到该 MagCar 停在该触发点的时间。开走后再回来重新计时。不写入 JSON。</td>
</tr>
<tr>
<td>在途占额</td>
<td>已对该车发过启动,但尚未占用本行内部站点。避免「刚放行、车还在触发点」时下一拍又放一辆冲突车。</td>
</tr>
</table>
<h3>3.2 进程参数</h3>
<table>
<tr><th>参数</th><th>默认</th><th>说明</th></tr>
<tr><td>TickMs</td><td>1000</td><td>扫车周期(毫秒),下限按 200ms 夹紧。</td></tr>
<tr><td>PendingTimeoutSeconds</td><td>8</td><td>在途超时(秒)。超时后取消占额,防止启动失败把名额卡死。</td></tr>
<tr><td>AreasPath</td><td class="mono">Config\Signal\mag-control-areas.json</td><td>相对路径只解析 SimpleLite 工作目录,不读 plugins 目录。</td></tr>
</table>
<h2>4. 交管逻辑(扫车每一拍)</h2>
<p>进程启动或点「重新加载配置」后,读取启用规则。此后约每秒执行一次 <span class="mono">ScanAndRelease</span></p>
<div class="flow">
<b>单拍流程</b>
<ol>
<li>快照全场车辆:站点(siteID / GetLastSite)、占用站点(siteID + holdingLocks)、是否 MagCar、是否停稳。</li>
<li>启用规则按管控区名称分组(空名称则按「#sit:触发点」自成一区)。同区等待时长取各行之最长。</li>
<li>清理到达时刻:已离开原触发点的车重新计时。</li>
<li>清理在途:车消失、超时、或已经踩到本行内部站点 → 去掉在途标记(进区后改由占用统计)。</li>
<li>收集本区停在各触发点上的停稳 MagCar,记为等待队列;已在途的不再重复发启动。</li>
<li>按第 6 节规则排序,然后<b>从队头到队尾贪心</b>:本行有空位则发启动并登记在途;无空位则跳过,继续看后面不冲突的车。</li>
<li>放行动作:先尝试停充,再 <span class="mono">StartMagCar</span>FASS 1.0 启动)。</li>
</ol>
</div>
<h3>4.1 何谓停稳</h3>
<p>状态不是「运行中 / Running」。下列视为可排队:停止中、充电中、WaitPass,以及没有 pendingLocks、没有 occupied 标签的非运行状态。充电中的车放行前会尝试 StopCharge。</p>
<h3>4.2 占用怎么数</h3>
<p>对某一等待车辆,只统计占用<b>该行 ControlArea</b> 的他车。排队触发点上尚未放行的 MagCar 不计入占用(它们还在区外等候)。本车不计。</p>
<p>占用站点来源:车辆当前 <span class="mono">siteID</span>,以及 <span class="mono">status.holdingLocks</span>。任一命中列表即算 1 辆。</p>
<h3>4.3 可放行数(空位)</h3>
<p class="mono">空位 = 本行容量 − 占用本行站点的车 − 与本行冲突的在途车</p>
<p>空位 ≥ 1 才对该车发启动。容量 1 表示:本线路内部(含冲突在途)最多一辆。</p>
<div class="note">
在途车若已经进入本行内部站点,只算「占用」,不再重复算「在途」,避免一名额扣两次。
</div>
<h2>5. 冲突判定(能否并行)</h2>
<p>只比较<b>同一管控区名称</b>下的两行(或一行与一条在途记录)。不同区互不影响。</p>
<p>满足下面<b>任一</b>条件即冲突(互斥):</p>
<ol>
<li>兼容组相同(不区分大小写,去首尾空格)。两边都为空也算相同 → 整区互斥。</li>
<li>两行的管控区站点列表有交集。</li>
</ol>
<p>两组都不同、且站点列表不相交 → <b>不冲突,可同时放行</b></p>
<table>
<tr><th>场景</th><th>兼容组</th><th>内部站点</th><th>结果</th></tr>
<tr><td>旧表、未填兼容组</td><td>都空</td><td>任意</td><td>同区全部互斥</td></tr>
<tr><td>东西对向直行</td><td>都填 EW</td><td>可不相交</td><td>同组,互斥(对向若也要并行,应拆成不同组且站点不相交)</td></tr>
<tr><td>东西 / 南北互不交叉</td><td>EW 与 NS</td><td>10,11 与 20,21</td><td>不冲突,可并行</td></tr>
<tr><td>组名不同但抢同一段</td><td>EW 与 NS</td><td>都含 15</td><td>站点相交,仍互斥</td></tr>
<tr><td>左转与对向直行交叉</td><td>建议同组,或站点列出交叉段</td><td>有交集或同组</td><td>互斥</td></tr>
</table>
<div class="warn">
若四个方向都复制同一份内部站点(例如都写 10,11,12,13),则即使兼容组不同也会因站点相交而全部互斥。要对向并行,必须按方向拆开内部点,且不要把触发点写进列表。
</div>
<h2>6. 排队规则(仅冲突线路)</h2>
<p>同一管控区内,先按下列比较函数排序,再贪心放行。不冲突的车排序先后不影响能否同时过,只影响扫描顺序。</p>
<h3>6.1 插队(等待时长)</h3>
<p>当同区等待时长 W &gt; 0,且两车优先级不同:</p>
<p><b>高优先级到达时刻 − 低优先级到达时刻 &gt; W</b>,则低优先级先走。</p>
<p>含义:低优先级更早到达,且已经比高优先级早到超过 W 秒,避免支路饿死。差值未超过 W 时,仍让优先级高的先走。</p>
<p>使用「&gt;」而不是「≥」。例如 W=10,恰好早到 10 秒时仍按优先级。</p>
<h3>6.2 默认顺序</h3>
<ol>
<li>未触发插队时:优先级数值大的在前。</li>
<li>优先级相同:到达时刻早的在前。</li>
<li>仍相同:车号小的在前(稳定次序)。</li>
</ol>
<h3>6.3 贪心,而不是只放队头</h3>
<p>排序后从前往后看。队头因本线已满或冲突在途没有空位时,<b>跳过队头</b>,继续检查后面的车。这样南北已占用时,队头若是南北,不会挡住后面可以走的东西向车辆。</p>
<h2>7. 放行与在途生命周期</h2>
<ol>
<li>判定可放 → StopCharge(若有)→ StartMagCar。</li>
<li>登记在途:记录区名、触发点、兼容组、本行内部站点、放行时刻。</li>
<li>下一拍该车仍停在触发点:不再重复启动,但作为冲突在途占额。</li>
<li>车进入本行内部站点:清除在途,改为占用统计。</li>
<li>超过 PendingTimeoutSeconds(默认 8 秒)仍未进区:清除在途,避免卡死。车若仍停在触发点,下一拍可再次进入排队。</li>
<li>车离线或从系统消失:清除在途与到达时刻。</li>
</ol>
<p>重新加载配置会清空到达时刻和在途表,请避免在路口有车等待时频繁重载。</p>
<h2>8. 配置示例</h2>
<h3>8.1 十字路口:东西与南北并行</h3>
<p>区名均为「十字路口A」。东西兼容组 EW,南北 NS。内部站点按方向拆开,容量均为 1。东西对向若现场确实会在同一段交汇,应把对向也拆站点或改为同组。</p>
<pre>[
{ "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 }
]</pre>
<p>效果:东西一辆与南北一辆可同时过;同一兼容组内容量 1,同向或同组第二辆要等;南北优先级较低,但若已等待超过 15 秒且比干道更早到达,可以插队。</p>
<h3>8.2 单入口限流(旧用法)</h3>
<p>只配一行:触发点 8,区名为空,兼容组空,ControlArea 写内部点,容量 1。该点自成一区,他车占用内部点时不放行。</p>
<h3>8.3 支路防饿死</h3>
<p>干道 Priority=10,支路 Priority=1,同区 WaitSeconds=20。支路先到、干道后到且时间差大于 20 秒时放支路;否则放干道。</p>
<h2>9. 现场操作</h2>
<ol>
<li>确认已加载信号插件 <span class="mono">StandardScene.Signal.dll</span>,进程栏中有「磁条交管进程」。</li>
<li>将 JSON 放到 SimpleLite 工作目录 <span class="mono">Config\Signal\mag-control-areas.json</span>。可用迷毂数据中心「磁条管控区」编辑(含兼容组列,需较新的 MiGu.Server)。</li>
<li>添加并<b>启动</b>磁条交管进程。改表后点「重新加载配置」,或进程内打开列表保存后重载。</li>
<li>状态栏可见:规则条数、触发点等待数、在途数、扫车拍数。</li>
<li>诊断通道 <span class="mono">MagTraffic</span> 会记录放行:区名、兼容组、触发点、本线占用/容量、冲突在途。</li>
</ol>
<div class="note">
交管不会替任务系统停车。请保证 MagCar 到达触发点时处于停止,否则不会进入排队,也不会发启动。
</div>
<h2>10. 与 Mag2 / 其他交管的边界</h2>
<table>
<tr><th>机制</th><th>对象</th><th>关系</th></tr>
<tr><td>本进程 mag-control-areas.json</td><td>MagCar</td><td>触发点停稳后发 StartMagCar</td></tr>
<tr><td>地图 Fass2_ControlArea 等</td><td>Mag2Car</td><td>StartStop=12 等待,放行改本车报文,本进程不介入</td></tr>
<tr><td>TrafficControl 点锁</td><td>MagCar / Mag2 路径执行</td><td>锁站点资源,与本表并行存在</td></tr>
<tr><td>交通管制 traffic.json</td><td>区域互斥钩子</td><td>需单独启动「交通管制」进程</td></tr>
</table>
<h2>11. 常见问题</h2>
<h4>十字路口空着,四个方向却一起冲出去?</h4>
<p>兼容组未区分,或内部站点写成同一串。请按方向拆兼容组并拆开 ControlArea。</p>
<h4>四个方向谁也不走?</h4>
<p>把四个停车触发点写进了 ControlArea,容量又为 1,等待车被当成区内占用。触发点不要写入内部列表。</p>
<h4>改了 JSON 不生效?</h4>
<p>必须点「重新加载配置」或重启进程。路径必须是工作目录 Config/Signal,不是插件目录。</p>
<h4>Mag2 在同一路口不停不等?</h4>
<p>本策略不管 Mag2。Mag2 需在地图配置 Fass2_StartStop=12 及管控区字段。</p>
<h4>容量填 0</h4>
<p>当前实现小于 1 按 1 处理,不会变成不限流。</p>
<h4>对向也要并行,但写成了同一兼容组?</h4>
<p>同组即互斥。对向要并行:不同组名,且内部站点不相交。</p>
<h2>12. 逻辑对照(维护用)</h2>
<table>
<tr><th>内容</th><th>位置</th></tr>
<tr><td>表字段</td><td class="mono">StandardScene.Signal / MagControlAreaModel</td></tr>
<tr><td>冲突、占用、容量</td><td class="mono">MagControlAreaGate</td></tr>
<tr><td>扫车、排队、在途、贪心放行</td><td class="mono">MagControlRuntime.ScanAndRelease</td></tr>
<tr><td>进程与重载</td><td class="mono">MagTrafficMission</td></tr>
<tr><td>迷毂列定义</td><td class="mono">MiGu.Server / SignalDataStore(表 id: mag-control</td></tr>
</table>
<p class="footer-note">本文描述 2026-08-18 代码行为。若后续调整兼容组或容量语义,以源码为准并同步修订本文。</p>
</body>
</html>
Binary file not shown.
@@ -0,0 +1,75 @@
using System;
namespace StandardScene
{
/// <summary>
/// 磁条管控区放行判定结果。由信号插件「磁条交管进程」填写,Mag2Car 在 StartStop=12 停稳后消费。
/// </summary>
public sealed class MagControlReleaseDecision
{
/// <summary>当前触发点在交管表里有启用规则。false 时 Mag2Car 回退地图 <c>Fass2_ControlArea</c> 字段。</summary>
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; }
/// <summary>放行后写入本车报文的 StartStop,通常 11(管控启动)或 1(过站)。</summary>
public byte ReleaseStartStop { get; set; } = 11;
public string Reason { get; set; } = string.Empty;
}
/// <summary>
/// Mag2Car 与信号插件之间的管控放行钩子。不让 Magnetic / Signal 互相引用。
/// </summary>
/// <remarks>
/// 磁条交管进程启动时 <see cref="Register"/>,停止时 <see cref="Unregister"/>。
/// Mag2Car 在管控停止点调用 <see cref="TryCheck"/>:有规则用表判定,没有则用地图字段。
/// </remarks>
public static class MagControlReleaseHook
{
private static readonly object Sync = new object();
private static object _owner;
private static Func<int, int, MagControlReleaseDecision> _check;
/// <summary>注册判定函数。<paramref name="owner"/> 用于停止时只清自己的注册。</summary>
public static void Register(object owner, Func<int, int, MagControlReleaseDecision> 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;
}
}
/// <summary>
/// 询问当前注册的交管进程。未注册返回 null;已注册但该触发点无规则时
/// 返回 <see cref="MagControlReleaseDecision.RuleFound"/> = false。
/// </summary>
public static MagControlReleaseDecision TryCheck(int carId, int triggerSiteId)
{
Func<int, int, MagControlReleaseDecision> check;
lock (Sync)
check = _check;
return check?.Invoke(carId, triggerSiteId);
}
}
}
@@ -6,5 +6,6 @@ using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StandardScene.Devices")] [assembly: InternalsVisibleTo("StandardScene.Devices")]
[assembly: InternalsVisibleTo("StandardScene.Magnetic")] [assembly: InternalsVisibleTo("StandardScene.Magnetic")]
[assembly: InternalsVisibleTo("StandardScene.QrLidar")] [assembly: InternalsVisibleTo("StandardScene.QrLidar")]
[assembly: InternalsVisibleTo("StandardScene.Signal")]
// SimpleLite 反射 API 需读取 internal 字段袋(BasicTrackFields / KivaSiteFields 等)的 public 字段定义。 // SimpleLite 反射 API 需读取 internal 字段袋(BasicTrackFields / KivaSiteFields 等)的 public 字段定义。
[assembly: InternalsVisibleTo("SimpleLite")] [assembly: InternalsVisibleTo("SimpleLite")]
@@ -24,7 +24,7 @@ namespace StandardScene.Fass2Simulator
public bool AutoCompleteStationActions { get; set; } public bool AutoCompleteStationActions { get; set; }
public string NodeProfilesPath { get; set; } = "sim-nodes.json"; public string NodeProfilesPath { get; set; } = "sim-nodes.json";
public bool EnableFileLog { get; set; } = true; public bool EnableFileLog { get; set; } = true;
public string LogDirectory { get; set; } = "logs"; public string LogDirectory { get; set; } = "log";
public Dictionary<string, Fass2SimNodeProfile> NodeProfiles { get; set; } = new Dictionary<string, Fass2SimNodeProfile>(); public Dictionary<string, Fass2SimNodeProfile> NodeProfiles { get; set; } = new Dictionary<string, Fass2SimNodeProfile>();
} }
} }
@@ -86,7 +86,7 @@ namespace StandardScene.Fass2Simulator
public static string GetLogDirectory() public static string GetLogDirectory()
{ {
return _logDirectory ?? ResolveLogDirectory("logs"); return _logDirectory ?? ResolveLogDirectory("log");
} }
private static void EnsureDefaultWriter() private static void EnsureDefaultWriter()
@@ -129,7 +129,7 @@ namespace StandardScene.Fass2Simulator
{ {
if (string.IsNullOrWhiteSpace(configuredPath)) if (string.IsNullOrWhiteSpace(configuredPath))
{ {
return Path.Combine(AppContext.BaseDirectory, "logs"); return Path.Combine(AppContext.BaseDirectory, "log");
} }
return Path.IsPathRooted(configuredPath) return Path.IsPathRooted(configuredPath)
@@ -43,7 +43,7 @@ namespace StandardScene.Fass2Simulator
Fass2SimFileLogger.Shutdown(); Fass2SimFileLogger.Shutdown();
}; };
AppendLog("就绪。配置参数后点击「启动模拟」开始联调。完整日志将写入 logs 目录。"); AppendLog("就绪。配置参数后点击「启动模拟」开始联调。完整日志将写入 log 目录。");
} }
private void StartButton_Click(object sender, RoutedEventArgs e) private void StartButton_Click(object sender, RoutedEventArgs e)
@@ -317,7 +317,7 @@ namespace StandardScene.Fass2Simulator
AutoCompleteStationActions = AutoCompleteBox.IsChecked == true, AutoCompleteStationActions = AutoCompleteBox.IsChecked == true,
LogRawFrames = LogRawFramesBox.IsChecked == true, LogRawFrames = LogRawFramesBox.IsChecked == true,
EnableFileLog = _config?.EnableFileLog ?? true, EnableFileLog = _config?.EnableFileLog ?? true,
LogDirectory = _config?.LogDirectory ?? "logs" LogDirectory = _config?.LogDirectory ?? "log"
}; };
} }
@@ -20,6 +20,6 @@
"AutoCompleteStationActions": false, "AutoCompleteStationActions": false,
"NodeProfilesPath": "sim-nodes.json", "NodeProfilesPath": "sim-nodes.json",
"EnableFileLog": true, "EnableFileLog": true,
"LogDirectory": "logs\\car2" "LogDirectory": "log\\car2"
} }
} }
@@ -20,6 +20,6 @@
"AutoCompleteStationActions": false, "AutoCompleteStationActions": false,
"NodeProfilesPath": "sim-nodes.json", "NodeProfilesPath": "sim-nodes.json",
"EnableFileLog": true, "EnableFileLog": true,
"LogDirectory": "logs" "LogDirectory": "log"
} }
} }
@@ -0,0 +1,131 @@
using System;
using System.IO;
using System.Text;
namespace StandardScene.MagCarSimulator
{
/// <summary>
/// 模拟器日志:UI 回调 + 写入 exe 旁 <c>log/</c> 目录。
/// </summary>
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<string> 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);
}
}
}
+630
View File
@@ -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<string> _pendingLogs = new Queue<string>();
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<MagCarSimVehicleConfig> ReadVehiclesFromGrid()
{
var list = new List<MagCarSimVehicleConfig>();
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<int> 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<int>(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<string> batch;
lock (_logLock)
{
if (_pendingLogs.Count == 0)
{
return;
}
batch = new List<string>(_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;
}
}
}
@@ -0,0 +1,83 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using Newtonsoft.Json.Linq;
namespace StandardScene.MagCarSimulator
{
/// <summary>读取 mag-control-areas.json,收集触发点与管控区站点。</summary>
public static class MagControlAreaFile
{
private static readonly char[] SiteSeparators = { ',', ';', '|', ' ', '\t' };
public static HashSet<int> LoadSiteIds(string path)
{
var ids = new HashSet<int>();
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<bool?>("IsUse") == false)
{
continue;
}
AddIfPositive(ids, obj.Value<int?>("TriggerSit") ?? 0);
foreach (var part in (obj.Value<string>("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<int> ids, int id)
{
if (id > 0)
{
ids.Add(id);
}
}
}
}
@@ -0,0 +1,213 @@
using System.Collections.Generic;
using System.Text;
namespace StandardScene.MagCarSimulator
{
public static class MapPathFinder
{
/// <summary>
/// 生成往返循环:起点→终点→起点(终点不重复,回到起点靠下标回绕)。
/// </summary>
public static List<int> 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<int>(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<int> NormalizeLoop(IEnumerable<int> siteIds)
{
var loop = new List<int>();
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<int> 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<int> 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<int> FindPath(SimpleLiteMap map, int fromSiteId, int toSiteId)
{
if (fromSiteId == toSiteId)
{
return new List<int> { fromSiteId };
}
var queue = new Queue<int>();
var prev = new Dictionary<int, int>();
var visited = new HashSet<int> { 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<int> Reconstruct(Dictionary<int, int> prev, int fromSiteId, int toSiteId)
{
var path = new List<int>();
var current = toSiteId;
path.Add(current);
while (current != fromSiteId)
{
current = prev[current];
path.Add(current);
}
path.Reverse();
return path;
}
}
}
@@ -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<string, string> 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; }
}
/// <summary>解析 SimpleLite <c>maps/*.json</c> 的 Sites / Tracks。</summary>
public sealed class SimpleLiteMap
{
public const string NeedStopField = "Mag_NeedStop";
public string FilePath { get; private set; }
public string FileName { get; private set; }
public IReadOnlyDictionary<int, SimpleLiteSite> Sites { get; private set; }
public IReadOnlyList<SimpleLiteTrack> Tracks { get; private set; }
public IReadOnlyDictionary<int, IReadOnlyList<int>> Adjacency { get; private set; }
private readonly HashSet<int> _singleOccupancySites = new HashSet<int>();
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;
}
/// <summary>停止点或交管点:同时只允许一辆。普通站可叠车。</summary>
public bool SingleOccupancy(int siteId)
{
return _singleOccupancySites.Contains(siteId);
}
public IReadOnlyList<int> ListSingleOccupancySiteIds()
{
var list = new List<int>(_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<int> siteIds)
{
if (siteIds == null)
{
return;
}
foreach (var id in siteIds)
{
if (id > 0 && Sites.ContainsKey(id))
{
_singleOccupancySites.Add(id);
}
}
}
public IReadOnlyList<int> ListNeedStopSiteIds()
{
var list = new List<int>();
foreach (var site in Sites.Values)
{
if (site.NeedStop)
{
list.Add(site.Id);
}
}
list.Sort();
return list;
}
private static Dictionary<int, SimpleLiteSite> ParseSites(JObject sitesNode)
{
var result = new Dictionary<int, SimpleLiteSite>();
if (sitesNode == null)
{
return result;
}
foreach (var property in sitesNode.Properties())
{
if (property.Value is not JObject obj)
{
continue;
}
var id = obj.Value<int?>("id") ?? ParseInt(property.Name);
if (id <= 0)
{
continue;
}
var fields = ReadFields(obj["fields"] as JObject);
var site = new SimpleLiteSite
{
Id = id,
Name = obj.Value<string>("name") ?? "",
X = obj.Value<double?>("x") ?? 0,
Y = obj.Value<double?>("y") ?? 0,
TagValue = TryReadInt(fields, "TagValue"),
NeedStop = IsNeedStop(fields),
Fields = fields
};
result[id] = site;
}
return result;
}
private static List<SimpleLiteTrack> ParseTracks(JObject tracksNode)
{
var result = new List<SimpleLiteTrack>();
if (tracksNode == null)
{
return result;
}
foreach (var property in tracksNode.Properties())
{
if (property.Value is not JObject obj)
{
continue;
}
var siteA = obj.Value<int?>("siteA") ?? obj.Value<int?>("_siteA") ?? 0;
var siteB = obj.Value<int?>("siteB") ?? obj.Value<int?>("_siteB") ?? 0;
if (siteA <= 0 || siteB <= 0)
{
continue;
}
result.Add(new SimpleLiteTrack
{
Id = obj.Value<int?>("id") ?? ParseInt(property.Name),
SiteA = siteA,
SiteB = siteB,
Direction = obj.Value<int?>("direction") ?? 0
});
}
return result;
}
private static Dictionary<int, IReadOnlyList<int>> BuildAdjacency(
Dictionary<int, SimpleLiteSite> sites,
List<SimpleLiteTrack> tracks)
{
var mutable = new Dictionary<int, List<int>>();
foreach (var id in sites.Keys)
{
mutable[id] = new List<int>();
}
foreach (var track in tracks)
{
AddEdge(mutable, track.SiteA, track.SiteB, track.Direction);
}
var result = new Dictionary<int, IReadOnlyList<int>>();
foreach (var pair in mutable)
{
result[pair.Key] = pair.Value;
}
return result;
}
private static void AddEdge(Dictionary<int, List<int>> 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<int, List<int>> graph, int from, int to)
{
if (!graph.TryGetValue(from, out var list))
{
list = new List<int>();
graph[from] = list;
}
if (!list.Contains(to))
{
list.Add(to);
}
}
private static Dictionary<string, string> ReadFields(JObject fields)
{
var result = new Dictionary<string, string>(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<string, string> 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<string, string> 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;
}
}
}
@@ -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<byte[], byte[]> _handler;
private readonly object _clientsLock = new object();
private readonly HashSet<TcpClient> _clients = new HashSet<TcpClient>();
private TcpListener _listener;
private CancellationTokenSource _cts;
private int _clientCount;
private bool _running;
public MagCarSimTcpServer(IPAddress address, int port, Func<byte[], byte[]> 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<TcpClient> clients;
lock (_clientsLock)
{
clients = new List<TcpClient>(_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;
}
}
}
+15
View File
@@ -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());
}
}
}
@@ -0,0 +1,144 @@
using System;
namespace StandardScene.MagCarSimulator
{
/// <summary>
/// FASS 1.0 32 字节帧,字段布局与 <c>MagCar.MagCarReport</c> 对齐。
/// </summary>
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; }
}
}
@@ -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;
/// <summary>显式环线站点序列(不含回到起点的重复点)。有值时优先于起点/终点寻路。</summary>
public List<int> 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; }
/// <summary>文件日志根目录(相对 exe 或绝对路径),默认 log。</summary>
public string LogDirectory { get; set; } = "log";
/// <summary>是否写入 log 目录下的 magcar-sim_yyyyMMdd.log。</summary>
public bool EnableFileLog { get; set; } = true;
public int DefaultIntervalMs { get; set; } = 2000;
/// <summary>同一环线后车相对前车的发车间隔。≤0 时用该车 IntervalMs。</summary>
public int LaunchStaggerMs { get; set; }
/// <summary>磁条交管表。空则按地图目录推断 SimpleLite/config/Signal/mag-control-areas.json。</summary>
public string ControlAreasPath { get; set; }
public List<MagCarSimVehicleConfig> Vehicles { get; set; } = new List<MagCarSimVehicleConfig>();
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<MagCarSimConfig>(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
}
}
};
}
}
}
@@ -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<MagCarSimVehicle> _vehicles = new List<MagCarSimVehicle>();
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<MagCarSimVehicle> 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<MagCarSimVehicleConfig> configs)
{
if (IsListening)
{
throw new InvalidOperationException("请先停止模拟再改车辆列表");
}
lock (_syncRoot)
{
DisposeVehiclesUnlocked();
foreach (var config in configs ?? Array.Empty<MagCarSimVehicleConfig>())
{
if (config == null)
{
continue;
}
var vehicle = new MagCarSimVehicle(Clone(config), Config);
if (_map != null)
{
vehicle.AttachMap(_map);
}
_vehicles.Add(vehicle);
}
}
}
public void StartListen()
{
List<MagCarSimVehicle> 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<MagCarSimVehicle> 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<MagCarSimVehicle> 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<MagCarSimVehicleSnapshot> 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<MagCarSimVehicle> 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<int>();
foreach (var vehicle in _vehicles)
{
if (!seen.Add(vehicle.ListenPort))
{
throw new InvalidOperationException($"端口 {vehicle.ListenPort} 被多辆车占用");
}
}
}
private static Dictionary<int, ushort> SnapshotExclusiveOccupancy(
SimpleLiteMap map,
IEnumerable<MagCarSimVehicle> vehicles)
{
var occupancy = new Dictionary<int, ushort>();
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<int>(source.LoopSiteIds)
};
}
}
}
@@ -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; }
}
/// <summary>
/// 单车循环:普通站可叠车;停止点与交管点同时只允许一辆,后车停在上一站并保持运行中;仅 Mag_NeedStop 停车等 0x01。
/// </summary>
public sealed class MagCarSimVehicle : IDisposable
{
private readonly object _syncRoot = new object();
private readonly MagCarSimConfig _hostConfig;
private MagCarSimTcpServer _server;
private SimpleLiteMap _map;
private List<int> _loopSiteIds = new List<int>();
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<int, ushort> 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<int, ushort> 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<int, ushort> 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<int, ushort> occupancy)
{
if (_map == null || occupancy == null || !_map.SingleOccupancy(siteId))
{
return false;
}
return occupancy.TryGetValue(siteId, out var occupier) && occupier != VehicleCode;
}
private void OccupyUnlocked(Dictionary<int, ushort> occupancy, int siteId)
{
if (occupancy == null || siteId <= 0 || _map == null || !_map.SingleOccupancy(siteId))
{
return;
}
occupancy[siteId] = VehicleCode;
}
private void MoveOccupancyUnlocked(Dictionary<int, ushort> 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}");
}
}
}
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<RootNamespace>StandardScene.MagCarSimulator</RootNamespace>
<AssemblyName>StandardScene.MagCarSimulator</AssemblyName>
<LangVersion>latest</LangVersion>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>disable</Nullable>
<Deterministic>true</Deterministic>
<UseWindowsForms>true</UseWindowsForms>
<ApplicationIcon />
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
@@ -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
]
}
]
}
+1 -1
View File
@@ -79,7 +79,7 @@ namespace StandardScene.CarTypes
[FieldMember] public bool EnableDetailLog = true; [FieldMember] public bool EnableDetailLog = true;
[FieldMember] public bool LogRawFrame = true; [FieldMember] public bool LogRawFrame = true;
[FieldMember] public bool EnableFileLog = true; [FieldMember] public bool EnableFileLog = true;
[FieldMember] public string LogDirectory = "logs"; [FieldMember] public string LogDirectory = "log";
[FieldMember] public float CarLength = 1200; [FieldMember] public float CarLength = 1200;
[FieldMember] public float CarWidth = 800; [FieldMember] public float CarWidth = 800;
+259 -6
View File
@@ -17,6 +17,7 @@ using System.Drawing.Drawing2D;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Net;
using System.Net.Sockets; using System.Net.Sockets;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -101,6 +102,9 @@ namespace StandardScene.CarTypes
/// </summary> /// </summary>
private DateTime _lastPollTime = DateTime.MinValue; private DateTime _lastPollTime = DateTime.MinValue;
/// <summary>最近一次与 AGV 通讯成功的时间;用于「断开超过 OfflineAfterMs → 离线」。</summary>
private DateTime _lastCommOkTime = DateTime.Now;
/// <summary> /// <summary>
/// AGV 服务端监听端口,默认 5000。 /// AGV 服务端监听端口,默认 5000。
/// </summary> /// </summary>
@@ -135,6 +139,12 @@ namespace StandardScene.CarTypes
/// </summary> /// </summary>
[FieldMember] public int PollIntervalMs = 1000; [FieldMember] public int PollIntervalMs = 1000;
/// <summary>
/// 连续通讯失败超过该毫秒数后,概况显示「离线」。
/// 默认 20000(20 秒);期间失败会显示「连接中」。
/// </summary>
[FieldMember] public int OfflineAfterMs = 20000;
/// <summary> /// <summary>
/// 移动任务的预留超时时间,单位秒。 /// 移动任务的预留超时时间,单位秒。
/// 当前 MagCar 不下发脚本,保留该字段用于后续任务调度或界面配置兼容。 /// 当前 MagCar 不下发脚本,保留该字段用于后续任务调度或界面配置兼容。
@@ -280,13 +290,30 @@ namespace StandardScene.CarTypes
} }
catch (Exception ex) catch (Exception ex)
{ {
lstatus = IsBenignSocketFault(ex) ? lstatus : "离线"; ApplyOfflineAfterDisconnect(ex);
}
}
/// <summary>
/// 通讯失败时:未满 <see cref="OfflineAfterMs"/> 显示「连接中」,满 20 秒(可配)后显示「离线」。
/// </summary>
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)) if (!IsBenignSocketFault(ex))
{ {
DetailLog($"keepAlive poll failed: {ex.Message}"); DetailLog($"keepAlive poll failed: {ex.Message}");
} }
} }
}
private bool _running; private bool _running;
@@ -590,6 +617,13 @@ namespace StandardScene.CarTypes
try try
{ {
ConnectWithTimeout(client); 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.SendTimeout = SendTimeoutMs;
client.ReceiveTimeout = ReceiveTimeoutMs; client.ReceiveTimeout = ReceiveTimeoutMs;
_persistentClient = client; _persistentClient = client;
@@ -619,7 +653,51 @@ namespace StandardScene.CarTypes
{ {
try 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;
}
}
/// <summary>
/// 检测 Windows/localhost 上「源端口=目标端口」的自连(无真实服务端)。
/// </summary>
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 catch
{ {
@@ -680,6 +758,13 @@ namespace StandardScene.CarTypes
{ {
DetailLog($"short connect begin {address}:{Port}, timeout={ConnectTimeoutMs}ms"); DetailLog($"short connect begin {address}:{Port}, timeout={ConnectTimeoutMs}ms");
ConnectWithTimeout(client); 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}"); DetailLog($"short connect ok {address}:{Port}");
client.SendTimeout = SendTimeoutMs; client.SendTimeout = SendTimeoutMs;
@@ -785,11 +870,17 @@ namespace StandardScene.CarTypes
if (!string.IsNullOrEmpty(message) && if (!string.IsNullOrEmpty(message) &&
(message.Contains("已中止 I/O", StringComparison.Ordinal) || (message.Contains("已中止 I/O", StringComparison.Ordinal) ||
message.Contains("I/O operation", StringComparison.OrdinalIgnoreCase) || 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; return true;
} }
} }
if (current is TimeoutException)
{
return true;
}
} }
return false; return false;
@@ -899,6 +990,7 @@ namespace StandardScene.CarTypes
private void ApplyReport(MagCarReport report) private void ApplyReport(MagCarReport report)
{ {
LastReport = report; LastReport = report;
_lastCommOkTime = DateTime.Now;
lstatus = report.Alarm != 0 ? "报警" : report.State == 0 ? "未准备" : "上线"; lstatus = report.Alarm != 0 ? "报警" : report.State == 0 ? "未准备" : "上线";
th = report.Angle; th = report.Angle;
@@ -925,12 +1017,108 @@ namespace StandardScene.CarTypes
haveCoordination = true; haveCoordination = true;
siteID = site.id; siteID = site.id;
x = site.x; ApplyStackedSitePose(site);
y = site.y;
SyncTrafficFromReport(site, report); 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}"); 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}");
} }
/// <summary>
/// 同一普通站多车时,沿路网方向按车号依次错开坐标,避免 SimpleLite 地图上完全重叠。
/// 站点逻辑仍用 <see cref="Car.siteID"/>,不改变交管占用。
/// </summary>
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<int> SameSiteCarIds(int siteId)
{
var ids = new List<int>();
try
{
foreach (var car in SimpleLib.GetAllCars().OfType<Car>())
{
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);
}
/// <summary> /// <summary>
/// 将 AGV 上报的当前地标同步到交通锁(holdingLocks)。 /// 将 AGV 上报的当前地标同步到交通锁(holdingLocks)。
/// GhostCar 调度依赖 holdingLocks/GetLastSite,仅改 x/y/siteID 会导致“画面在动、初始化占点不动”。 /// GhostCar 调度依赖 holdingLocks/GetLastSite,仅改 x/y/siteID 会导致“画面在动、初始化占点不动”。
@@ -954,6 +1142,25 @@ namespace StandardScene.CarTypes
return; 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 try
{ {
DetailLog($"traffic sync TrafficReset, lock={lockedSite}, targetSite={site.id}, node={report.Node}, state={StateText(report.State)}"); 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<Car>())
{
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<int>(), siteId) < 0;
}
catch (Exception ex)
{
DetailLog($"traffic sync release stale holder failed: car={other.id}, site={siteId}, {ex.Message}");
return false;
}
}
/// <summary> /// <summary>
/// 将 AGV 返回的节点号解析为场景站点。 /// 将 AGV 返回的节点号解析为场景站点。
/// 根据 <see cref="UseTagValueAsNode"/> 决定优先匹配站点 TagValue 还是直接匹配站点 id。 /// 根据 <see cref="UseTagValueAsNode"/> 决定优先匹配站点 TagValue 还是直接匹配站点 id。
@@ -9,6 +9,7 @@ namespace StandardScene.Magnetic.Tasking
{ {
/// <summary> /// <summary>
/// 按 holdingLocks + 物理 siteID 统计管控站点列表上的他车占用。 /// 按 holdingLocks + 物理 siteID 统计管控站点列表上的他车占用。
/// Mag2 仍读地图 Fass2_ControlArea 字段;磁条交管进程只放行 MagCar,不介入本路径。
/// </summary> /// </summary>
internal static class Fass2ControlAreaOccupancy internal static class Fass2ControlAreaOccupancy
{ {
@@ -6,17 +6,17 @@ using System.Text;
namespace StandardScene.Magnetic.Tasking namespace StandardScene.Magnetic.Tasking
{ {
/// <summary> /// <summary>
/// Mag2Car 本地文件日志,按协议车号分目录:logs/car{VehicleCode}/mag2_yyyyMMdd.log /// Mag2Car 本地文件日志,按协议车号分目录:log/car{VehicleCode}/mag2_yyyyMMdd.log
/// </summary> /// </summary>
public static class Mag2CarFileLogger public static class Mag2CarFileLogger
{ {
private static readonly ConcurrentDictionary<ushort, object> CarLocks = new ConcurrentDictionary<ushort, object>(); private static readonly ConcurrentDictionary<ushort, object> CarLocks = new ConcurrentDictionary<ushort, object>();
private static string _baseDirectory = "logs"; private static string _baseDirectory = "log";
private static bool _enabled = true; private static bool _enabled = true;
public static void Configure(string baseDirectory, bool enabled) public static void Configure(string baseDirectory, bool enabled)
{ {
_baseDirectory = string.IsNullOrWhiteSpace(baseDirectory) ? "logs" : baseDirectory.Trim(); _baseDirectory = string.IsNullOrWhiteSpace(baseDirectory) ? "log" : baseDirectory.Trim();
_enabled = enabled; _enabled = enabled;
} }
@@ -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"
}
]
@@ -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
}
]
@@ -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
}
]
@@ -0,0 +1,10 @@
[
{
"Sit": 10,
"IsUse": true
},
{
"Sit": 20,
"IsUse": false
}
]
@@ -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"
}
]
}
@@ -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"
}
]
@@ -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": "单牵引下线机构"
}
]
+241
View File
@@ -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
{
/// <summary>
/// 1s 扫全场车,对齐反编译 <c>DefaultPlugin.ExcuteThreadMethod</c> + <c>StarSignManage</c>。
/// </summary>
/// <remarks>
/// 每辆车每拍只看当前站点:
/// <list type="number">
/// <item>停稳:先按握手点类型分发 Handler,再执行放行点(放行点不读 PLC,可能把还在等允许进入的车开走,站点不要两边都配)。</item>
/// <item>行驶中:仅 JGControl + RequestIn 调用 <c>RequestInJudge</c>PLC 未允许进入则停车。</item>
/// </list>
/// 握手点解析优先站点 fields<see cref="SignalCarAdapter.TryReadSignalStop"/>),没有再查 JSON。
/// </remarks>
public sealed class CarScanWorker : IDisposable
{
/// <summary>取当前全部机构运行态;由 Mission 提供,内部会拷贝列表。</summary>
private readonly Func<IReadOnlyList<PlcStationRuntime>> _runtimes;
/// <summary>扫车间隔,下限 100ms,默认跟进程 TickMs=1000。</summary>
private readonly int _tickMs;
/// <summary>保护握手点 / 放行点字典,热替换配置时与扫车拍互斥。</summary>
private readonly object _configLock = new object();
/// <summary>站点 → 握手点。同一站点多条配置只留第一条。</summary>
private Dictionary<int, HandshakePointModel> _handshake = new Dictionary<int, HandshakePointModel>();
/// <summary>站点 → 放行点。同一站点多条配置只留第一条。</summary>
private Dictionary<int, ReleasePointModel> _release = new Dictionary<int, ReleasePointModel>();
/// <summary>限流区域上次记日志的车号,避免同一区域每秒刷屏。</summary>
private readonly Dictionary<string, int> _rateFlowLog = new Dictionary<string, int>();
/// <summary>车辆没有 GetChargeTime 时,用首次看到「充电中」的时刻估算秒数。</summary>
private readonly Dictionary<int, DateTime> _chargeStarted = new Dictionary<int, DateTime>();
/// <summary>未知 SignalType 的日志节流(同站点 10s 一次)。</summary>
private readonly Dictionary<int, DateTime> _unknownTypeLog = new Dictionary<int, DateTime>();
private CancellationTokenSource _cts;
private Thread _thread;
private int _tickCount;
public CarScanWorker(Func<IReadOnlyList<PlcStationRuntime>> runtimes, int tickMs)
{
_runtimes = runtimes ?? throw new ArgumentNullException(nameof(runtimes));
_tickMs = Math.Max(100, tickMs);
}
/// <summary>已完成的扫车拍数,写入进程 status 供监视。</summary>
public int TickCount => _tickCount;
/// <summary>
/// 热替换握手点 / 放行点。过滤掉站点号无效或信号类型为空的握手点。
/// 不中断线程,下一拍生效。
/// </summary>
public void ReplaceConfig(IEnumerable<HandshakePointModel> handshake, IEnumerable<ReleasePointModel> release)
{
lock (_configLock)
{
_handshake = (handshake ?? Array.Empty<HandshakePointModel>())
.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<ReleasePointModel>())
.Where(x => x != null && x.Sit > 0)
.GroupBy(x => x.Sit)
.ToDictionary(g => g.Key, g => g.First());
}
}
/// <summary>启动后台扫车线程。重复 Start 若线程仍活则忽略。</summary>
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);
}
/// <summary>取消令牌并最多等 2s 退出。不抛异常,供进程停止时调用。</summary>
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; }
}
}
/// <summary>
/// 一拍:拷贝配置字典 → 按机构名查找闭包 → 遍历全部车。
/// 查找机构时忽略大小写,与 JSON 里 JgName 对齐。
/// </summary>
internal void ScanOnce()
{
Dictionary<int, HandshakePointModel> handshake;
Dictionary<int, ReleasePointModel> release;
lock (_configLock)
{
handshake = _handshake;
release = _release;
}
PlcStationRuntime Find(string name, string dockId)
{
if (string.IsNullOrWhiteSpace(name))
return null;
var list = _runtimes() ?? Array.Empty<PlcStationRuntime>();
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);
}
}
}
/// <summary>
/// 站点 fields 有 SignalType 则用地图点配置(老 SignalStop);
/// 否则用 handshake-points.json。fields 存在但类型为空视为本站不握手。
/// </summary>
private SignalStopSpec ResolveSpec(int sit, IReadOnlyDictionary<int, HandshakePointModel> 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;
}
/// <summary>
/// 停稳分发。TimedCharging 优先:充电点不应再走机构逻辑。
/// 未识别的 SignalType 按站点节流打日志,避免刷屏。
/// </summary>
private void DispatchStopped(Car car, SignalStopSpec spec, Func<string, string, PlcStationRuntime> 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);
}
}
}
@@ -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
{
/// <summary>
/// 机构进出站握手。对齐反编译 <c>DefaultPlugin.JGControl</c> / <c>JGJudge</c> / <c>RequestIn</c> / <c>Arrived</c>。
/// </summary>
/// <remarks>
/// <para>
/// <c>SignalParams</c> 分号三段:<c>机构名;动作;限流</c>。
/// 动作 <c>RequestIn</c>(请求进入,配在机构口外等待点)或 <c>Arrived</c>(到位,配在机构内地标)。
/// 第三段可选,格式与独立限流不同:<c>区域-车组-限额-点1,点2</c>(短横分隔,见 <see cref="RateFlowHandler"/>)。
/// </para>
/// <para>
/// RequestIn 放行条件(需同时满足):PLC 读字节=允许进入、尚无请求进入车、写字节=无状态。
/// 下线机构额外要求 <c>JgPointId</c> 上没有其它车。通过后写「进入中」、记下 RequestInAgvNo、停充并启动。
/// </para>
/// <para>
/// 车已在跑但本车不是请求进入车、且 PLC 不是允许进入时,立即停车(停稳拍与行驶拍都会判)。
/// Arrived:若 InAgvNo 还不是本车,写「到位」、登记 InAgvNo、清空 RequestInAgvNo。
/// 之后的「离开中 / 已离开」由 PLC Worker 的 <see cref="PlcStationLogic.AutoLeave"/> 推进。
/// </para>
/// </remarks>
public static class JgControlHandler
{
/// <summary>停稳:可选限流 → RequestIn 或 Arrived。</summary>
public static void OnStopped(Car car, SignalStopSpec spec, Func<string, string, PlcStationRuntime> find, IDictionary<string, int> 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);
}
/// <summary>行驶中只做 RequestIn 安全闸:未允许进入且不是本车占用请求槽则停车。</summary>
public static void OnMoving(Car car, SignalStopSpec spec, Func<string, string, PlcStationRuntime> 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<string, string, PlcStationRuntime> 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<string, string, PlcStationRuntime> 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<string, string, PlcStationRuntime> 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;
}
}
}
}
@@ -0,0 +1,189 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using SimpleLite.RCS.CarTypes;
namespace StandardScene.Signal.Logic
{
/// <summary>
/// 磁条管控区占用判定:他车占用「当前策略」管控站点列表的数量 ≥ 容量则不放行;本车不计入。
/// 磁条交管进程只对停在触发点的 MagCar 调用,满足后发启动。
/// </summary>
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<int>();
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<int> a, IReadOnlyList<int> 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<int> areaSiteIds,
int capacity,
byte releaseStartStop,
IReadOnlyList<(int CarId, IReadOnlyList<int> OccupiedSiteIds)> occupancies)
{
var sites = areaSiteIds == null || areaSiteIds.Count == 0
? (triggerSit > 0 ? new[] { triggerSit } : Array.Empty<int>())
: 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}"
};
}
/// <summary>统计占用管控区内部站点的车辆数,可排除排队车和在途车。</summary>
public static int CountOccupyingCars(
IReadOnlyList<(int CarId, IReadOnlyList<int> OccupiedSiteIds)> occupancies,
IReadOnlyList<int> areaSiteIds,
HashSet<int> 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<int> occupiedSiteIds, IReadOnlyList<int> 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;
}
/// <summary>统计一辆车占用的站点:物理 siteID + holdingLocks。</summary>
public static IReadOnlyList<int> CollectOccupiedSiteIds(Car car)
{
var ids = new List<int>();
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;
}
}
}
@@ -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
{
/// <summary>
/// 磁条交管运行态:按管控区名称分组排队;放行按「本线 + 冲突对向线」站点并集查当前占用。
/// 不维护 pending 在途占额。
/// </summary>
public sealed class MagControlRuntime
{
private readonly object _lock = new object();
private Dictionary<int, MagControlAreaModel> _byTrigger = new Dictionary<int, MagControlAreaModel>();
private readonly Dictionary<int, DateTime> _releaseLog = new Dictionary<int, DateTime>();
private readonly Dictionary<int, Arrival> _arrivals = new Dictionary<int, Arrival>();
public int RuleCount
{
get
{
lock (_lock)
return _byTrigger.Count;
}
}
/// <summary>已不再使用 pending;保留属性避免旧 UI 绑定报错,恒为 0。</summary>
public int PendingCount => 0;
public void Replace(IEnumerable<MagControlAreaModel> rows)
{
var map = (rows ?? Array.Empty<MagControlAreaModel>())
.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();
}
}
/// <summary>
/// 同名区排队;放行前查本线 + 冲突对向线站点并集上的当前占用。
/// 另计:已放行仍停在冲突触发点上的运行中车辆(避免 site 未进区时空窗连放)。
/// </summary>
public (int Waiting, int Released) ScanAndRelease()
{
List<MagControlAreaModel> 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<int>(waiters.Select(w => w.CarId));
var releasedThisScan = new List<Waiter>();
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);
}
/// <summary>
/// 冲突占用 = 并集站点上的当前车
/// + 本区冲突触发点上仍「运行中」的车(刚放行、site 尚未进 ControlArea
/// + 本拍已放行且与本线冲突的车。
/// </summary>
private static int CountConflictingOccupancy(
AreaGroup group,
Waiter waiter,
IReadOnlyList<int> checkSites,
List<(int CarId, IReadOnlyList<int> OccupiedSiteIds)> occupancies,
List<CarSnapshot> snapshots,
HashSet<int> exclude,
List<Waiter> releasedThisScan)
{
var others = MagControlAreaGate.CountOccupyingCars(occupancies, checkSites, exclude);
var counted = new HashSet<int>();
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;
}
/// <summary>单车判定:本线 + 同名区内冲突对向线站点并集的当前占用。</summary>
public MagControlReleaseDecision Decide(int carId, int triggerSit)
{
MagControlAreaModel rule;
List<MagControlAreaModel> 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<int> 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<int> sitesA,
string compatB,
IReadOnlyList<int> sitesB)
{
if (string.Equals(compatA ?? "", compatB ?? "", StringComparison.OrdinalIgnoreCase))
return true;
return MagControlAreaGate.SitesIntersect(sitesA, sitesB);
}
/// <summary>
/// 本线站点 + 同名区内所有与本线冲突的对向/交叉线路站点并集。
/// 例如西环 NS-W 会并入东/北/南列表,100/101/104 有车时都能挡住触发点 4。
/// </summary>
private static IReadOnlyList<int> BuildConflictSites(AreaGroup group, Waiter waiter)
{
var set = new HashSet<int>();
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<MagControlAreaModel> rows)
{
var ordered = rows.OrderBy(r => r.TriggerSit).ToList();
var triggers = new HashSet<int>(ordered.Select(r => r.TriggerSit));
var waitSeconds = 0;
var routeByTrigger = new Dictionary<int, RouteInfo>();
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<CarSnapshot> SnapshotCars()
{
var list = new List<CarSnapshot>();
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<Waiter> CollectWaiters(AreaGroup group, List<CarSnapshot> snapshots)
{
var waiters = new List<Waiter>();
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<CarSnapshot> snapshots)
{
var present = new HashSet<int>();
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<int> 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<int> 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<int> AreaSites;
public int Priority;
}
private sealed class AreaGroup
{
public string Key;
public int WaitSeconds;
public HashSet<int> Triggers;
public Dictionary<int, RouteInfo> RouteByTrigger;
}
private sealed class CarSnapshot
{
public Car Car;
public int CarId;
public IReadOnlyList<int> OccupiedSiteIds;
public bool IsMagCar;
public bool IsStopped;
public int Sit;
}
}
}
@@ -0,0 +1,136 @@
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Linq;
using System.Text;
namespace StandardScene.Signal.Logic
{
/// <summary>
/// 磁条交管放行留档,按车名分目录:log/mag-traffic/car{name}/release_yyyyMMdd.log
/// 进程启停等写到 log/mag-traffic/_sys/sys_yyyyMMdd.log
/// </summary>
public static class MagTrafficFileLogger
{
private static readonly ConcurrentDictionary<string, object> CarLocks = new ConcurrentDictionary<string, object>(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");
}
/// <summary>目录名 car{name};名称为空时回退 car{id}。</summary>
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");
}
/// <summary>按车名写入放行记录。</summary>
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
{
}
}
}
/// <summary>进程启停、重载配置等非单车事件。</summary>
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);
}
}
}
@@ -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
{
/// <summary>
/// 销钉 / 牵引棒升降后再放行。对齐反编译 <c>DefaultPlugin.PointControl</c>。
/// </summary>
/// <remarks>
/// <c>SignalParams</c><c>动作;机构名</c>。
/// 动作:<c>UP1</c> 升销钉1<c>UP2</c> 升销钉1+2<c>Down1</c> / <c>Down2</c> 对应下降。
/// 机构名可选:填了则必须等该机构读字节=允许离开才启动;空则只等销钉动作完成。
/// 销钉指令通过 <see cref="SignalCarAdapter.Control"/> 反射调用车体 MethodMember(如 SetPin1Up)。
/// </remarks>
public static class PointControlHandler
{
/// <summary>停稳:先判机构是否允许离开,再下发销钉,全部到位后启动。</summary>
public static void OnStopped(Car car, SignalStopSpec spec, Func<string, string, PlcStationRuntime> 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;
}
}
/// <summary>
/// 任一销钉未完成则本拍只下发指令、不启动;
/// 全部完成且(无需等离开 或 已允许离开)才 Start。
/// </summary>
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);
}
}
}
@@ -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
{
/// <summary>
/// 区域车组限流。对齐反编译 <c>DefaultPlugin.RateFlowControl</c>。
/// </summary>
/// <remarks>
/// 独立握手类型 <c>RateFlowControl</c> 用分号:<c>区域;车组;限额;点1,点2</c>。
/// 挂在 JGControl 第三段时用短横:<c>区域-车组-限额-点1,点2</c>(因为前两段机构名/动作已经占用分号)。
/// 统计「站点列表上、同车组、且不是本车」的数量,小于限额才放行。
/// 解析异常时放行(return true),避免配错把产线卡死。
/// </remarks>
public static class RateFlowHandler
{
/// <summary>独立限流点:通过则停充并启动。</summary>
public static void OnStopped(Car car, SignalStopSpec spec, IDictionary<string, int> logByArea)
{
if (AllowPass(car, spec.SignalParams, logByArea, sep: ';'))
{
SignalCarAdapter.StopCharge(car);
SignalCarAdapter.Start(car);
}
}
/// <summary>JGControl 第三段限流,短横分隔。不通过时调用方应直接 return,不启动。</summary>
public static bool AllowPass(Car car, string rateFlowString, IDictionary<string, int> logByArea) =>
AllowPass(car, rateFlowString, logByArea, sep: '-');
/// <param name="sep">独立类型用 ';'JGControl 挂载用 '-'。</param>
private static bool AllowPass(Car car, string raw, IDictionary<string, int> 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<int>();
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;
}
}
/// <summary>同一限流区域仅在「决策车号变化」时再打一条带原始参数的日志。</summary>
private static void Note(IDictionary<string, int> 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);
}
}
}
@@ -0,0 +1,27 @@
using System.Collections.Generic;
using SimpleLite.RCS.CarTypes;
using StandardScene.Signal.Model;
using StandardScene.Signal.Plc;
namespace StandardScene.Signal.Logic
{
/// <summary>
/// 无条件放行点。对齐反编译 <c>StarSignManage</c> / <c>StartSignModel</c>。
/// </summary>
/// <remarks>
/// 车在配置站点停稳且 <see cref="ReleasePointModel.IsUse"/> 为 true 时:停充并启动,不读 PLC。
/// 用于任务在某点停住、充完电或人工暂停后需要自动续跑的站点。
/// 机构口应配握手点,不要同时配放行点——扫车线程在握手 Handler 之后仍会执行本方法,可能提前开走。
/// </remarks>
public static class ReleasePointHandler
{
/// <summary>按当前站点号查放行表;未配置或未启用则什么都不做。</summary>
public static void OnStopped(Car car, int sit, IReadOnlyDictionary<int, ReleasePointModel> points)
{
if (points == null || !points.TryGetValue(sit, out var p) || p == null || !p.IsUse)
return;
SignalCarAdapter.StopCharge(car);
SignalCarAdapter.Start(car);
}
}
}
@@ -0,0 +1,130 @@
using System;
using StandardScene.Signal.Model;
namespace StandardScene.Signal.Logic
{
/// <summary>
/// 一条握手点配置的解析结果。新列(机构/工位/动作/附加参数)优先,旧 SignalParams 作回退。
/// </summary>
public sealed class SignalStopSpec
{
public string SignalType { get; init; } = "";
public string SignalParams { get; init; } = "";
public string[] Parts { get; init; } = Array.Empty<string>();
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;
}
}
@@ -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
{
/// <summary>
/// 站点定时充电。对齐反编译 <c>DefaultPlugin.TimedCharging</c>。
/// </summary>
/// <remarks>
/// <c>SignalParams</c><c>最长秒数;上限电量;下限电量</c>,某段填 0 表示该条件不启用。
/// 正在充电:电量 ≥ 上限则停充;时长到且电量已过下限则停充;时长到但电量仍 ≤ 下限则继续充。
/// 已停充且车仍停着:视为充电流程结束,启动放行。
/// 充电秒数优先反射 <c>GetChargeTime</c>,没有则用 <paramref name="chargeStarted"/> 字典估算。
/// </remarks>
public static class TimedChargeHandler
{
/// <summary>停稳且本站是 TimedCharging 时由扫车线程调用。</summary>
public static void OnStopped(Car car, SignalStopSpec spec, IDictionary<int, DateTime> 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);
}
}
}
}
@@ -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
{
/// <summary>
/// 磁条交管进程:只针对 MagCar。同区名排队后,按「本线 + 冲突对向线」站点并集查占用,容量允许则发启动。
/// </summary>
/// <remarks>
/// 配置 <c>Config/Signal/mag-control-areas.json</c>。
/// 同「管控区名称」绑成一个路口排队;放行时合并本线与冲突对向线的管控站点列表统计当前占用。
/// 不使用 pending 在途占额。不处理 Mag2Car。
/// </remarks>
[MissionType(Name = "磁条交管进程", editor = typeof(MagTrafficMission))]
[I18N.DocumentTranslation(Name = "Magnetic Traffic Mission", locale = "en")]
public class MagTrafficMission : Mission
{
/// <summary>扫车周期(毫秒)。停在触发点的 MagCar 每拍排队判定一次。</summary>
[FieldMember] public int TickMs = 1000;
/// <summary>管控区表路径。相对路径只解析工作目录 <c>Config/Signal</c>。</summary>
[FieldMember] public string AreasPath = @"Config\Signal\mag-control-areas.json";
/// <summary>放行日志根目录。实际按车名落在其下 mag-traffic/car{name}/release_yyyyMMdd.log。</summary>
[FieldMember] public string LogDirectory = "log";
/// <summary>是否把交管放行写入本地文件留档。</summary>
[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<MagControlAreaModel>(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<MagControlAreaModel>.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; }
}
}
}
}
@@ -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
{
/// <summary>
/// 第三方信号交互进程(场景 <c>scene.signal</c>PLC 握手 / 放行;磁条交管见 <c>MagTrafficMission</c>)。
/// </summary>
/// <remarks>
/// <para>
/// 对齐梅山吉利反编译插件 <c>CoreClass30.A20050.Plugin.DefaultPlugin</c>
/// 一边用西门子 S7 和机构 PLC 交换进出站字节,一边按站点对停稳 AGV 做握手 / 放行。
/// </para>
/// <para>
/// 两条独立循环,不要合成一次 HTTP 或任务编排节点:
/// </para>
/// <list type="bullet">
/// <item>
/// <term>PLC Worker</term>
/// <description>
/// 每个机构一条后台线程,默认 200ms:心跳翻转、读「允许进入/离开」、回读写字节、按需写入、
/// <see cref="PlcStationLogic.AutoLeave"/> 推进离站状态机。
/// </description>
/// </item>
/// <item>
/// <term>扫车线程</term>
/// <description>
/// <see cref="CarScanWorker"/> 默认 1s 扫全场车。停稳则按握手点类型分发给
/// JGControl / PointControl / TimedCharging / RateFlowControl,再执行放行点;
/// 行驶中仅对 JGControl 的 RequestIn 做「未允许进入则急停」。
/// </description>
/// </item>
/// </list>
/// <para>
/// 配置三张 JSON(工作目录 <c>Config/Signal/</c>,可由迷毂数据中心改,保存后点「重新加载配置」):
/// 机构 <c>stations.json</c>、握手点 <c>handshake-points.json</c>、放行点 <c>release-points.json</c>。
/// 站点 fields 若已写 <c>SignalType</c>/<c>SignalParams</c>,优先于握手点表。
/// </para>
/// <para>
/// CycleGUI 进程工具栏必须点「信号交互进程」,不要打开开放泛型编辑器。
/// 列表窗体只改 JSON,不经过本进程的 HTTP;改完需本进程已启动才会重启 Worker。
/// </para>
/// </remarks>
[MissionType(Name = "信号交互进程", editor = typeof(SignalInteractMission))]
[I18N.DocumentTranslation(Name = "Signal Interact Mission", locale = "en")]
public class SignalInteractMission : Mission
{
/// <summary>扫车周期(毫秒)。对齐反编译 DefaultPlugin 主循环,现场一般保持 1000。</summary>
[FieldMember] public int TickMs = 1000;
/// <summary>单机构 PLC 轮询周期(毫秒)。对齐 PLCManager,现场一般保持 200。</summary>
[FieldMember] public int PlcPollMs = 200;
/// <summary>
/// 西门子系列,传给 IoTClient <c>SiemensVersion</c>。
/// 机构表 PLC类型为「跟随进程」时使用本项;机构单独指定则用机构值。
/// </summary>
[FieldMember] public SignalPlcType PlcVersion = SignalPlcType.S7_1500;
/// <summary>机构未单独填 Port 时使用的 S7 端口,默认 102。</summary>
[FieldMember] public int PlcPort = 102;
/// <summary>S7 机架槽号,S7-1500 常见为 1;写入 <see cref="SiemensPlcSession"/>。</summary>
[FieldMember] public int PlcSlot = 1;
/// <summary>机构表路径。相对路径只解析工作目录 <c>Config/Signal</c>,不读插件目录。</summary>
[FieldMember] public string StationsPath = @"Config\Signal\stations.json";
/// <summary>放行点表路径。车停在这些站点且启用时,无条件停充并启动,不读 PLC。</summary>
[FieldMember] public string ReleasePointsPath = @"Config\Signal\release-points.json";
/// <summary>握手点表路径。按站点配置 JGControl / PointControl / TimedCharging / RateFlowControl。</summary>
[FieldMember] public string HandshakePointsPath = @"Config\Signal\handshake-points.json";
/// <summary>机构工位表。相对路径只解析工作目录 Config/Signal。</summary>
[FieldMember] public string DocksPath = @"Config\Signal\station-docks.json";
/// <summary>Execute 成功后为 trueReloadConfig 仅在已启动时重启 Worker / 扫车。</summary>
[JsonIgnore] private bool _started;
/// <summary>保护 <see cref="_workers"/>:启动、停止、状态快照、扫车查找机构时共用。</summary>
[JsonIgnore] private readonly object _workerLock = new object();
/// <summary>当前在跑的机构 Worker,与 stations.json 一一对应(空名称的机构不会创建)。</summary>
[JsonIgnore] private readonly List<PlcStationWorker> _workers = new List<PlcStationWorker>();
/// <summary>全场扫车握手线程;停止进程时置空。</summary>
[JsonIgnore] private CarScanWorker _scan;
/// <summary>PLC 每 tick 都会回调刷新状态;用计数把 UI 刷新降到约每 5 个 poll 一次。</summary>
[JsonIgnore] private int _statusTick;
/// <summary>进程监视面板展示的运行摘要(机构连接数、扫车次数、各站读写字节)。</summary>
public class SignalInteractMissionStatus : MissionStatus
{
/// <summary>当前 Worker 数量,等于已加载且名称非空的机构数。</summary>
public int StationCount { get; set; }
/// <summary>最近一次快照里 <c>IsConnected == true</c> 的机构数。</summary>
public int ConnectedCount { get; set; }
/// <summary>放行点 JSON 条数(含未启用)。</summary>
public int ReleasePointCount { get; set; }
/// <summary>握手点 JSON 条数。</summary>
public int HandshakePointCount { get; set; }
/// <summary><see cref="CarScanWorker.TickCount"/>,用于确认扫车线程是否在跑。</summary>
public int ScanTickCount { get; set; }
/// <summary>实际解析到的机构文件绝对路径。</summary>
public string StationsFile { get; set; }
/// <summary>实际解析到的放行点文件绝对路径。</summary>
public string ReleasePointsFile { get; set; }
/// <summary>实际解析到的握手点文件绝对路径。</summary>
public string HandshakePointsFile { get; set; }
/// <summary>实际解析到的工位文件绝对路径。</summary>
public string DocksFile { get; set; }
/// <summary>工位 JSON 条数。</summary>
public int DockCount { get; set; }
/// <summary>各机构运行态快照,供监视列表展示。</summary>
public List<PlcStationSnapshot> Stations { get; set; } = new List<PlcStationSnapshot>();
}
public override MissionStatus status { get; set; } = new SignalInteractMissionStatus();
/// <summary>SimpleLite 按 MissionType 反射创建实例时调用。</summary>
public static Mission Create() => new SignalInteractMission();
/// <summary>
/// 加载三张 JSON,拉起全部 PLC Worker 和扫车线程。
/// 重复点击会先停再建;失败时 status 为「启动失败」并记诊断。
/// </summary>
[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;
}
}
/// <summary>
/// 重新读 JSON 并刷新 status 里的路径/条数。
/// 进程已启动时:重建 PLC Worker,并向扫车线程热替换握手点/放行点(不必停进程)。
/// 迷毂数据中心改完文件后应调本方法,否则内存里仍是旧表。
/// </summary>
[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<ReleasePointModel>(releaseFile);
var handshake = SignalConfigStore.Load<HandshakePointModel>(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);
}
}
/// <summary>停扫车、停全部 PLC 连接。隐藏了基类 Stop,工具栏显示本方法。</summary>
[MethodMember(Name = "停止进程")]
public new void Stop()
{
_started = false;
try { _scan?.Stop(); }
catch { }
_scan = null;
StopWorkers();
status.status = "已停止";
Diagnosis.Post("信号交互进程已停止", "Signal", true);
}
/// <summary>CycleGUI 独立窗体编辑机构表;保存立刻写 JSON,需「重新加载配置」才进 Worker。</summary>
[MethodMember(Name = "打开机构列表", Description = "按 PlcStationModel 增删改机构配置")]
public void OpenStations()
{
try
{
var path = SignalConfigStore.Resolve(StationsPath);
ModelListPanel<PlcStationModel>.Open("PLC 机构配置", path, x => x?.JgName ?? "");
}
catch (Exception ex)
{
CycleUiHelper.Alert("错误", $"打开机构列表失败:{ex.Message}");
}
}
/// <summary>CycleGUI 独立窗体编辑工位表;主键为机构名+工位编号。</summary>
[MethodMember(Name = "打开工位列表", Description = "按 PlcStationDockModel 增删改工位点位")]
public void OpenDocks()
{
try
{
var path = SignalConfigStore.Resolve(DocksPath);
ModelListPanel<PlcStationDockModel>.Open(
"机构工位配置",
path,
x => x == null ? "" : $"{x.JgName}/{x.ResolvedDockId}");
}
catch (Exception ex)
{
CycleUiHelper.Alert("错误", $"打开工位列表失败:{ex.Message}");
}
}
/// <summary>CycleGUI 独立窗体编辑握手点;主键展示为「站点号 + 信号类型」。</summary>
[MethodMember(Name = "打开握手点列表", Description = "按站点配置 JGControl / PointControl / TimedCharging / RateFlowControl")]
public void OpenHandshakePoints()
{
try
{
var path = SignalConfigStore.Resolve(HandshakePointsPath);
ModelListPanel<HandshakePointModel>.Open(
"握手点配置",
path,
x => x == null ? "" : $"{x.Sit} {x.SignalType}");
}
catch (Exception ex)
{
CycleUiHelper.Alert("错误", $"打开握手点列表失败:{ex.Message}");
}
}
/// <summary>CycleGUI 独立窗体编辑放行点;主键为站点号。</summary>
[MethodMember(Name = "打开放行点列表", Description = "按 ReleasePointModel 增删改放行点")]
public void OpenReleasePoints()
{
try
{
var path = SignalConfigStore.Resolve(ReleasePointsPath);
ModelListPanel<ReleasePointModel>.Open("放行点配置", path, x => x == null ? "" : x.Sit.ToString());
}
catch (Exception ex)
{
CycleUiHelper.Alert("错误", $"打开放行点列表失败:{ex.Message}");
}
}
/// <summary>
/// 扫车 Handler 用机构名查找运行态。返回拷贝列表,避免长时间持锁;
/// 运行态对象本身仍由各 Worker 持有,读写需 <see cref="PlcStationRuntime.Sync"/>。
/// </summary>
internal IReadOnlyList<PlcStationRuntime> Runtimes
{
get
{
lock (_workerLock)
return _workers.SelectMany(w => w.Docks).ToList();
}
}
/// <summary>停旧扫车线程,按当前 JSON 新建并启动。</summary>
private void RestartScan()
{
try { _scan?.Stop(); }
catch { }
var handshake = SignalConfigStore.Load<HandshakePointModel>(SignalConfigStore.Resolve(HandshakePointsPath));
var release = SignalConfigStore.Load<ReleasePointModel>(SignalConfigStore.Resolve(ReleasePointsPath));
_scan = new CarScanWorker(() => Runtimes, TickMs);
_scan.ReplaceConfig(handshake, release);
_scan.Start();
}
/// <summary>
/// 先停全部旧连接,再按机构表为每个非空名称建 <see cref="PlcStationWorker"/>。
/// 机构 Port&lt;=0 时回退进程级 <see cref="PlcPort"/>PLC类型跟随进程时用 <see cref="PlcVersion"/>。
/// </summary>
private void RestartWorkers()
{
StopWorkers();
var bundles = PlcConfigLoader.Load(
SignalConfigStore.Resolve(StationsPath),
SignalConfigStore.Resolve(DocksPath));
var created = new List<PlcStationWorker>();
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();
}
/// <summary>机构选跟随进程(含旧 JSON 缺省 0)时用进程 PlcVersion;进程若也是跟随进程则按 S7_1500。</summary>
private string ResolvePlcType(PlcStationModel station)
{
var type = station.PlcType == SignalPlcType. ? PlcVersion : station.PlcType;
if (type == SignalPlcType.)
type = SignalPlcType.S7_1500;
return type.ToString();
}
/// <summary>清空列表后再 Stop,避免扫车在停连接过程中仍拿到旧 Runtime。</summary>
private void StopWorkers()
{
List<PlcStationWorker> copy;
lock (_workerLock)
{
copy = _workers.ToList();
_workers.Clear();
}
foreach (var w in copy)
{
try { w.Stop(); }
catch { }
}
}
/// <summary>PLC Worker 每周期回调;约每 5 次才刷一次监视面板,避免 200ms 打满 UI。</summary>
private void RefreshStatus()
{
if (Interlocked.Increment(ref _statusTick) % 5 != 0)
return;
PushStatus();
}
/// <summary>把各机构快照和扫车次数写进 MissionStatus,供 SimpleLite 进程监视。</summary>
private void PushStatus()
{
List<PlcStationSnapshot> 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}";
}
}
}
@@ -0,0 +1,47 @@
using System.ComponentModel;
using Newtonsoft.Json;
namespace StandardScene.Signal.Model
{
/// <summary>
/// 握手点。JGControl / PointControl 填机构、工位、动作;定时充电 / 限流走附加参数。
/// 旧 JSON 仅有 SignalParams 时仍可解析。
/// </summary>
[SignalTable("handshake", "handshake-points.json", "握手点", Order = 3)]
[Category("握手点数据管理")]
[DisplayName("握手点配置列表")]
public class HandshakePointModel
{
[DisplayName("站点")]
public int Sit { get; set; }
/// <summary>JGControl / PointControl / TimedCharging / RateFlowControl。</summary>
[DisplayName("信号类型")]
[SignalSelect("JGControl", "PointControl", "TimedCharging", "RateFlowControl")]
public string SignalType { get; set; } = "JGControl";
[DisplayName("机构名称")]
public string JgName { get; set; } = "";
[DisplayName("工位编号")]
public string DockId { get; set; } = "";
/// <summary>JGControlRequestIn / ArrivedPointControlUP1 / UP2 / Down1 / Down2。</summary>
[DisplayName("动作")]
[SignalSelect("RequestIn", "Arrived", "UP1", "UP2", "Down1", "Down2")]
public string Action { get; set; } = "";
/// <summary>
/// TimedCharging:最长秒;上限电量;下限电量。
/// RateFlowControl:区域;车组;限额;点列表。
/// JGControl 可选限流:区域-车组-限额-点列表。
/// </summary>
[DisplayName("附加参数")]
public string ExtraParams { get; set; } = "";
/// <summary>旧字段。新配置可空,加载时由上面几列拼回。</summary>
[Browsable(false)]
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
public string SignalParams { get; set; }
}
}
@@ -0,0 +1,60 @@
using System.ComponentModel;
namespace StandardScene.Signal.Model
{
/// <summary>
/// 磁条管控区一行。仅 MagCar:车在触发点停稳等待,进程按区名排队后发启动。
/// </summary>
/// <remarks>
/// 主键为 <see cref="TriggerSit"/>。同 <see cref="AreaName"/> 的多行绑成一个路口排队。
/// 区名为空时,该触发点自成一区(兼容旧表)。
/// 放行时查本行 <see cref="ControlArea"/> 与同区冲突对向线站点的并集当前占用;不用 pending。
/// <see cref="ControlReleaseStartStop"/> 保留兼容,MagCar 放行走 FASS 1.0 启动指令,不使用该字节。
/// </remarks>
[SignalTable("mag-control", "mag-control-areas.json", "磁条管控区", Order = 5)]
[Category("磁条交管")]
[DisplayName("管控区配置列表")]
public class MagControlAreaModel
{
/// <summary>管控触发站点。MagCar 到此停稳后进入该区排队。</summary>
[DisplayName("管控触发站点")]
public int TriggerSit { get; set; }
/// <summary>管控区名称。同名多行绑成一个路口;空则本触发点单独成区。</summary>
[DisplayName("管控区名称")]
public string AreaName { get; set; } = "";
/// <summary>
/// 兼容组。同区里互不干扰的线路填不同组名;会打架的填同组。
/// 空则与同区其他空组互斥。站点列表有交集时即使组名不同也互斥。
/// </summary>
[DisplayName("兼容组")]
public string CompatGroup { get; set; } = "";
/// <summary>优先级,数值越大越先放。干道高、支路低。只在冲突线路之间生效。</summary>
[DisplayName("优先级")]
public int Priority { get; set; }
/// <summary>
/// 等待时长(秒)。同区取最长;≤0 不做插队。只在冲突线路之间生效。
/// </summary>
[DisplayName("等待时长")]
public int WaitSeconds { get; set; }
/// <summary>本线路路口内部站点,如 <c>10,11</c>。不要把排队触发点写进来。空则回退触发点。</summary>
[DisplayName("管控区站点列表")]
public string ControlArea { get; set; } = "";
/// <summary>本线路区内最多几辆。含占用本行站点的车和与本行冲突的在途车。默认 1。</summary>
[DisplayName("区内最多几辆")]
public int ControlCapacity { get; set; } = 1;
/// <summary>兼容字段,MagCar 放行不使用。</summary>
[DisplayName("放行后启停字节")]
public byte ControlReleaseStartStop { get; set; } = 11;
/// <summary>false 时该条保留在 JSON 里但不生效。</summary>
[DisplayName("启用")]
public bool IsUse { get; set; } = true;
}
}
@@ -0,0 +1,106 @@
using System.ComponentModel;
using Newtonsoft.Json;
namespace StandardScene.Signal.Model
{
/// <summary>
/// 机构工位:一块 DB 上的一组 BOOL 点 + 地标。同一机构可多行(dock1 / dock2 / …)。
/// </summary>
[SignalTable("docks", "station-docks.json", "机构工位", Order = 2)]
[Category("PLC数据管理")]
[DisplayName("机构工位列表(机构名称+工位编号不能重复)")]
public class PlcStationDockModel
{
/// <summary>外键,对应 <see cref="PlcStationModel.JgName"/>。</summary>
[Category("基本")]
[DisplayName("机构名称")]
public string JgName { get; set; } = "";
/// <summary>机构内唯一。空则按 default。</summary>
[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; } = "";
/// <summary>旧配置合成:按一字节枚举读写,不走 BOOL。</summary>
[Browsable(false)]
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
public bool UseByteIo { get; set; }
/// <summary>仅 UseByteIo 时使用的读字节地址。</summary>
[Browsable(false)]
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
public string ReadDb { get; set; }
/// <summary>仅 UseByteIo 时使用的写字节地址。</summary>
[Browsable(false)]
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
public string WriteDb { get; set; }
public string ResolvedDockId =>
string.IsNullOrWhiteSpace(DockId) ? "default" : DockId.Trim();
}
}
@@ -0,0 +1,77 @@
using System.ComponentModel;
using Newtonsoft.Json;
namespace StandardScene.Signal.Model
{
/// <summary>
/// PLC 机构:一把 S7 连接 + 一块 DB。点位和地标在 <see cref="PlcStationDockModel"/>。
/// </summary>
[SignalTable("stations", "stations.json", "PLC机构", Order = 1)]
[Category("PLC数据管理")]
[DisplayName("PLC配置列表(机构名称不能重复)")]
public class PlcStationModel
{
/// <summary>逻辑名,握手点机构名、Worker 线程名都用它。不能空、不能重复。</summary>
[DisplayName("机构名称")]
public string JgName { get; set; } = "";
/// <summary>西门子 PLC IP。</summary>
[DisplayName("机构IP")]
public string Ip { get; set; } = "127.0.0.1";
/// <summary>S7 端口。填 0 则用进程字段 PlcPort(默认 102)。</summary>
[DisplayName("端口")]
public int Port { get; set; } = 102;
/// <summary>西门子系列。跟随进程则用信号交互进程的 PlcVersion。</summary>
[DisplayName("PLC类型")]
public SignalPlcType PlcType { get; set; } = SignalPlcType.;
/// <summary>DB 块号。工位 BOOL 与心跳都拼到这块 DB。</summary>
[DisplayName("DB块号")]
public int DbNumber { get; set; } = 100;
/// <summary>心跳字节偏移。未填 HeartDb 时拼 DB{n}.DBX{HeartByte}.{HeartBit}。</summary>
[DisplayName("心跳字节")]
public int HeartByte { get; set; } = 32;
/// <summary>心跳位,07。</summary>
[DisplayName("心跳位")]
public int HeartBit { get; set; }
/// <summary>上线 / 单牵引下线 / 双牵引下线 / 等待通行,决定 AutoLeave 分支。</summary>
[DisplayName("上下机构")]
public SignalLineType LineType { get; set; } = SignalLineType.;
/// <summary>旧配置读字节地址。有工位表后不再使用;无工位表时合成 default 工位。</summary>
[Browsable(false)]
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
public string ReadDb { get; set; }
/// <summary>旧配置写字节地址。</summary>
[Browsable(false)]
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
public string WriteDb { get; set; }
/// <summary>旧配置心跳位整段地址,优先于心跳字节/位。</summary>
[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; }
}
}
@@ -0,0 +1,41 @@
using System.ComponentModel;
namespace StandardScene.Signal.Model
{
/// <summary>
/// 单个机构运行态快照,供 MissionStatus / 监视面板展示。
/// 由 <see cref="StandardScene.Signal.Plc.PlcStationRuntime.ToSnapshot"/> 拷贝,UI 不要直接绑 Runtime。
/// </summary>
public class PlcStationSnapshot
{
/// <summary>机构逻辑名。</summary>
[DisplayName("机构")] public string JgName { get; set; } = "";
/// <summary>最近一拍 PLC 读写是否成功。</summary>
[DisplayName("连接")] public bool IsConnected { get; set; }
/// <summary>PLC→调度读字节的枚举名(允许进入 / 允许离开 / 无状态)。</summary>
[DisplayName("读信号")] public string ReadSignal { get; set; } = "";
/// <summary>调度目标写字节的枚举名。</summary>
[DisplayName("写信号")] public string WriteSignal { get; set; } = "";
/// <summary>从写地址回读到的枚举名,用于确认 PLC 是否已吃进目标值。</summary>
[DisplayName("回读写值")] public string ReadWriteSignal { get; set; } = "";
/// <summary>已报到在机构内的 AGV 号,0 表示没有。</summary>
[DisplayName("机构内车")] public int InAgvNo { get; set; }
/// <summary>已获准进入、正在往机构开的 AGV 号,0 表示没有。</summary>
[DisplayName("请求进入车")] public int RequestInAgvNo { get; set; }
/// <summary>目标写字节是否已与回读一致。</summary>
[DisplayName("写入成功")] public bool WriteSuccess { get; set; }
/// <summary>Worker 内存心跳翻转,表示线程在跑。</summary>
[DisplayName("心跳")] public bool Heart { get; set; }
/// <summary>最近一次断线或周期异常原因。</summary>
[DisplayName("最近错误")] public string LastError { get; set; } = "";
}
}
@@ -0,0 +1,26 @@
using System.ComponentModel;
namespace StandardScene.Signal.Model
{
/// <summary>
/// 放行点配置。对应反编译 <c>StartSignModel</c>。
/// </summary>
/// <remarks>
/// 车停在 <see cref="Sit"/> 且 <see cref="IsUse"/> 为 true 时,进程停充并启动,不读 PLC。
/// 同一站点不要同时配握手点:扫车先握手再放行,可能把还在等允许进入的车开走。
/// 站点号不能重复,列表编辑器用 Sit 做主键。
/// </remarks>
[SignalTable("release", "release-points.json", "放行点", Order = 4)]
[Category("放行点数据管理")]
[DisplayName("放行点配置列表")]
public class ReleasePointModel
{
/// <summary>要自动放行的地图站点号。</summary>
[DisplayName("放行站点")]
public int Sit { get; set; }
/// <summary>false 时该条保留在 JSON 里但不生效,方便现场临时关掉某个点。</summary>
[DisplayName("启用")]
public bool IsUse { get; set; } = true;
}
}
+90
View File
@@ -0,0 +1,90 @@
namespace StandardScene.Signal.Model
{
/// <summary>
/// PLC → 调度:读字节含义。对齐反编译 <c>ENUM_READ_SIGNAL</c>。
/// 机构用这一个字节告诉 AGV 现在能不能进、能不能出。
/// </summary>
public enum SignalReadValue : byte
{
/// <summary>0:空闲 / 未允许。RequestIn 必须看到写字节也是无状态才放行。</summary>
= 0,
/// <summary>1:允许进入机构。扫车 RequestIn 的放行条件之一。</summary>
= 1,
/// <summary>2:允许离开机构。下线升销钉、等待通行机构、PointControl 等会等这个值。</summary>
= 2
}
/// <summary>
/// 调度 → PLC:写字节含义。对齐反编译 <c>ENUM_WRITE_SIGNAL</c>。
/// AGV 用这一个字节向机构汇报当前处于进站流程的哪一步。
/// </summary>
public enum SignalWriteValue : byte
{
/// <summary>0:空闲。离站清空后回到此值,下一辆才能 RequestIn。</summary>
= 0,
/// <summary>1:已获准进入、正在往机构开。RequestIn 成功时写入。</summary>
= 1,
/// <summary>2:已到达机构内地标。Arrived 或上线 AutoLeave 检测到离开进入包含区时写入。</summary>
= 2,
/// <summary>3:正在离开机构。上线看到车在跑、下线/等待通行启动后写入。</summary>
= 3,
/// <summary>4:已离开包含地标。PLC 确认后调度清零写字节和车号。</summary>
= 4
}
/// <summary>
/// 西门子系列,名称与 IoTClient <c>SiemensVersion</c> 对齐(不含 <c>None</c>)。
/// 机构表选「跟随进程」时用信号交互进程的 <c>PlcVersion</c>。
/// </summary>
public enum SignalPlcType : byte
{
/// <summary>0:未单独指定,建连时用进程字段 PlcVersion。</summary>
= 0,
/// <summary>S7-1500,现场最常见。</summary>
S7_1500 = 1,
/// <summary>S7-1200。</summary>
S7_1200 = 2,
/// <summary>S7-300。</summary>
S7_300 = 3,
/// <summary>S7-400。</summary>
S7_400 = 4,
/// <summary>S7-200 Smart。</summary>
S7_200Smart = 5,
/// <summary>S7-200。</summary>
S7_200 = 6
}
/// <summary>
/// 机构类型。对齐反编译 <c>ENUM_JGTYPE</c>。
/// 决定 <see cref="StandardScene.Signal.Plc.PlcStationLogic.AutoLeave"/> 走哪套离站,以及 RequestIn 要不要查机构内是否有车。
/// </summary>
public enum SignalLineType : byte
{
/// <summary>0:未配置,AutoLeave 不处理。</summary>
= 0,
/// <summary>1:上线。进站靠扫车 RequestIn/Arrived,离站跟车位自动推进到位/离开中/已离开。</summary>
线 = 1,
/// <summary>2:单牵引下线。RequestIn 时若 JgPointId 上已有车则不放行;离站要升销钉1。</summary>
线 = 2,
/// <summary>3:双牵引下线。同单牵引,离站还需销钉2到位。</summary>
线 = 3,
/// <summary>4:等待通行。无销钉,允许离开后停充启动即可。</summary>
= 4
}
}
@@ -0,0 +1,18 @@
using System;
namespace StandardScene.Signal.Model
{
/// <summary>
/// 迷毂数据中心把该字符串字段渲染成下拉。反射列定义时读取。
/// </summary>
[AttributeUsage(AttributeTargets.Property)]
public sealed class SignalSelectAttribute : Attribute
{
public SignalSelectAttribute(params string[] options)
{
Options = options ?? Array.Empty<string>();
}
public string[] Options { get; }
}
}
@@ -0,0 +1,27 @@
using System;
namespace StandardScene.Signal.Model
{
/// <summary>
/// 标记一张迷毂数据中心表。迷毂从插件 DLL 反射本特性,不再依赖 signal-tables.json 的列定义。
/// </summary>
[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; }
/// <summary>侧栏顺序,越小越靠前。</summary>
public int Order { get; set; }
}
}
+63
View File
@@ -0,0 +1,63 @@
using System;
using System.Text.RegularExpressions;
namespace StandardScene.Signal.Plc
{
/// <summary>西门子 DB 字节/位地址拼装与缓冲区位操作。</summary>
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);
}
}
}
@@ -0,0 +1,89 @@
using System;
using System.Collections.Generic;
using System.Linq;
using StandardScene.Signal.Model;
namespace StandardScene.Signal.Plc
{
/// <summary>
/// 加载机构 + 工位。没有工位表时,用旧机构行的 ReadDb/WriteDb/地标合成 default 工位(一字节枚举)。
/// </summary>
internal static class PlcConfigLoader
{
public const string DefaultDockId = "default";
public sealed class StationBundle
{
public PlcStationModel Station { get; init; }
public List<PlcStationDockModel> Docks { get; init; }
}
public static List<StationBundle> Load(string stationsPath, string docksPath)
{
var stations = SignalConfigStore.Load<PlcStationModel>(stationsPath)
.Where(s => s != null && !string.IsNullOrWhiteSpace(s.JgName))
.ToList();
var docks = SignalConfigStore.Load<PlcStationDockModel>(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<StationBundle>();
foreach (var station in stations)
{
if (!byName.TryGetValue(station.JgName.Trim(), out var rows) || rows.Count == 0)
rows = HasLegacyIo(station) ? new List<PlcStationDockModel> { Synthesize(station) } : new List<PlcStationDockModel>();
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
};
}
}
+216
View File
@@ -0,0 +1,216 @@
using System;
using System.Linq;
using SimpleCore.Library;
using StandardScene.Signal.Model;
namespace StandardScene.Signal.Plc
{
/// <summary>
/// PLC 字节含义转换,以及按机构类型自动推进离站状态机。对齐反编译 <c>PLCManager</c>。
/// </summary>
/// <remarks>
/// 由 <see cref="PlcStationWorker"/> 每 200ms 在已持有 <see cref="PlcStationRuntime.Sync"/> 时调用。
/// 写字节含义见 <see cref="SignalWriteValue"/>:无状态 → 进入中 → 到位 → 离开中 → 已离开 → 清零。
/// 「已离开」可由本侧写成功,或 PLC 回读写值变成已离开(对方清过)触发清空。
/// </remarks>
public static class PlcStationLogic
{
/// <summary>PLC 读字节 → 调度侧枚举。仅 1=允许进入、2=允许离开,其余当无状态。</summary>
public static SignalReadValue ConvertRead(byte val) => val switch
{
1 => SignalReadValue.,
2 => SignalReadValue.,
_ => SignalReadValue.
};
/// <summary>PLC 写地址回读字节 → 调度侧枚举。1~4 对应进入中/到位/离开中/已离开。</summary>
public static SignalWriteValue ConvertWrite(byte val) => val switch
{
1 => SignalWriteValue.,
2 => SignalWriteValue.,
3 => SignalWriteValue.,
4 => SignalWriteValue.,
_ => SignalWriteValue.
};
/// <summary>
/// 判断站点是否落在机构配置的包含列表里。
/// 列表支持逗号、分号、空格分隔,与 JSON 里 <c>JgInContains</c> / <c>JgContains</c> 一致。
/// </summary>
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);
}
/// <summary>按 <see cref="PlcStationModel.LineType"/> 分发四套离站逻辑;未知类型不处理。</summary>
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;
}
}
/// <summary>
/// 离站完成:写字节清无状态、清空机构内车号。
/// 若仍挂着 RequestInAgvNo(上线途中 PLC 已报已离开),一并清掉以免卡死下一辆。
/// </summary>
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);
}
/// <summary>
/// 上线机构:进站过程由扫车 RequestIn/Arrived 启动,离站由本方法跟车位推进。
/// 进入中且车已离开 JgInContains → 视为到位;
/// 到位且车在跑 → 离开中;
/// 离开中且车已不在 JgContains → 已离开。
/// </summary>
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.;
}
}
/// <summary>
/// 下线机构:车已在机构内(InAgvNo),等允许离开后升销钉、停充、启动,写离开中;
/// 车离开 JgContains 后写已离开。
/// <paramref name="pin2"/> 为 true 时双牵引,需销钉1+2都到位。
/// </summary>
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.;
}
}
/// <summary>
/// 等待通行机构:无销钉,允许离开且停在 JgPointId 时停充并启动,写离开中;
/// 离开包含地标后写已离开。
/// </summary>
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.;
}
}
}
}
@@ -0,0 +1,101 @@
using System;
using SimpleCore.Library;
using StandardScene.Signal.Model;
namespace StandardScene.Signal.Plc
{
/// <summary>
/// 单个工位运行态(不进 JSON)。扫车改写目标写状态,PLC Worker 负责下发 BOOL(或旧一字节枚举)。
/// </summary>
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; }
/// <summary>兼容旧代码:机构连接配置(不含工位地标)。</summary>
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 ?? ""
};
}
}
@@ -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
{
/// <summary>
/// 单机构 200ms 循环:心跳、按工位读 BOOL(或旧一字节)、回写变化位、自动离站。
/// </summary>
public sealed class PlcStationWorker : IDisposable
{
private readonly SiemensPlcSession _session;
private readonly int _pollMs;
private readonly Action _onTick;
private readonly PlcStationModel _station;
private readonly List<PlcStationRuntime> _docks;
private CancellationTokenSource _cts;
private Thread _thread;
private DateTime _lastFailLog = DateTime.MinValue;
public PlcStationWorker(
PlcStationModel station,
IEnumerable<PlcStationRuntime> docks,
SiemensPlcSession session,
object sync,
int pollMs,
Action onTick = null)
{
_station = station ?? throw new ArgumentNullException(nameof(station));
_docks = (docks ?? Array.Empty<PlcStationRuntime>()).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<PlcStationRuntime> 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<PlcStationRuntime> docks)
{
var db = PlcConfigLoader.ResolveDb(_station);
var bytes = new List<int>();
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<int>();
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<int> 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<int> 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);
}
}
}
@@ -0,0 +1,195 @@
using System;
using System.Text.RegularExpressions;
using IoTClient.Clients.PLC;
using IoTClient.Common.Enums;
namespace StandardScene.Signal.Plc
{
/// <summary>
/// 西门子 S7 长连接封装(IoTClient)。一个机构一个实例,由对应 Worker 独占。
/// </summary>
/// <remarks>
/// 字节地址兼容老 Hsl 写法 <c>DB1.0</c> 与 IoTClient 写法 <c>DB1.DBB0</c>
/// 心跳位地址保持原样(如 <c>DB1.DBX2.0</c>),不做 DBB 转换。
/// 连接失败时读/写返回 false,由 Worker 标断线,不在这里抛给扫车线程。
/// </remarks>
public sealed class SiemensPlcSession : IDisposable
{
/// <summary>匹配 Hsl 风格「DB块号.字节偏移」,捕获组 1=DB2=偏移。</summary>
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();
/// <param name="version">西门子系列名,如 S7_1500;空或无法解析时默认 S7_1500。</param>
/// <param name="ip">机构 IP。</param>
/// <param name="port">S7 端口,通常 102。</param>
/// <param name="slot">机架槽号,S7-1500 常见 1。</param>
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);
}
/// <summary>当前客户端是否已 Open 成功。未创建客户端时为 false。</summary>
public bool Connected
{
get
{
lock (_sync) return _client?.Connected == true;
}
}
/// <summary>惰性创建客户端并 Open。已连接则直接 true;失败返回 false 不抛。</summary>
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;
}
}
}
/// <summary>读一字节。地址会先规范化;未连接或失败时 value=0 且返回 false。</summary>
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;
}
}
/// <summary>写一字节(进入中/到位等)。失败返回 false。</summary>
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;
}
}
/// <summary>从起始字节连续读 length 字节,用于工位 BOOL 拆位。</summary>
public bool TryReadBytes(string startAddress, ushort length, out byte[] data)
{
data = Array.Empty<byte>();
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;
}
}
/// <summary>读心跳位。地址不做 DBB 转换。</summary>
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;
}
}
/// <summary>写心跳位(通常写当前值的反码,让 PLC 看到翻转)。</summary>
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;
}
}
/// <summary>关闭并丢弃客户端,下次 EnsureOpen 会新建。</summary>
public void Close()
{
lock (_sync)
{
try { _client?.Close(); }
catch { }
_client = null;
}
}
public void Dispose() => Close();
/// <summary>
/// <c>DB1.0</c> → <c>DB1.DBB0</c>;已是 DBB/DBW/DBX 的地址原样返回。
/// </summary>
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;
}
/// <summary>空或无法解析时默认 S7_1500。允许把 S7-1500 写成带连字符。</summary>
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;
}
}
}
@@ -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
{
/// <summary>
/// 信号插件访问 AGV / 站点的适配层。不引用 Magnetic.dll,按方法名反射启停和牵引棒。
/// </summary>
/// <remarks>
/// MagCar / Mag2Car 上有对应 MethodMemberStartMag2Car、SetPin1Up 等)时生效;
/// 没有 <c>IsActionFinish</c> 则视为动作已完成,避免非磁导航车卡在销钉等待。
/// 启停回退顺序:StartMag2Car / StartMagCar → SendStart(0)。
/// </remarks>
public static class SignalCarAdapter
{
/// <summary>当前内核里的全部车;SimpleLib 异常时返回空,避免扫车线程崩掉。</summary>
public static IEnumerable<Car> All()
{
try
{
return SimpleLib.GetAllCars().OfType<Car>();
}
catch
{
return Array.Empty<Car>();
}
}
/// <summary>按车号取车;id≤0 返回 null。</summary>
public static Car Find(int id)
{
if (id <= 0) return null;
return SimpleLib.GetCar(id) as Car;
}
/// <summary>
/// 当前站点:优先 <c>siteID</c>,否则 <c>GetLastSite()</c>。
/// 握手 / 放行 / 限流都用这个号去对 JSON 里的 Sit。
/// </summary>
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;
}
}
/// <summary>读车 status.enums["State"],没有则空串。</summary>
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 "";
}
/// <summary>State 为「运行中」或 Running。</summary>
public static bool IsMoving(Car car)
{
var state = GetState(car);
return state == "运行中" ||
string.Equals(state, "Running", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// 对齐反编译 <c>RunStatus==停止</c>:非运行中即视为可握手,含充电中 / 停止中 / WaitPass。
/// 充电点必须把「充电中」算停稳,否则 TimedCharging 不会进 Handler。
/// </summary>
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);
}
/// <summary>正在充电(中英文 State)。</summary>
public static bool IsCharging(Car car)
{
var state = GetState(car);
return state == "充电中" ||
string.Equals(state, "Charging", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// 车仍占资源:运行中,或有 pendingLocks,或 tags 含 occupied。
/// 上线机构「到位后开始离开」用这个判断,比单纯看 State 更早切到离开中。
/// </summary>
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;
}
/// <summary>电量百分比,读 enums["Soc"];解析失败当 0。</summary>
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;
}
/// <summary>限流用的车组:fields["group"] 或 ["GroupID"]。</summary>
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 "";
}
/// <summary>当前停在给定站点集合上的全部车(按 PositionId)。</summary>
public static IReadOnlyList<Car> CarsAt(IEnumerable<int> siteIds)
{
var set = new HashSet<int>(siteIds ?? Array.Empty<int>());
if (set.Count == 0)
return Array.Empty<Car>();
return All().Where(c => set.Contains(PositionId(c))).ToList();
}
/// <summary>当前停在单个站点上的车。下线 RequestIn 用 JgPointId 查机构内是否已有车。</summary>
public static IReadOnlyList<Car> CarsAt(int siteId)
{
if (siteId <= 0) return Array.Empty<Car>();
return CarsAt(new[] { siteId });
}
/// <summary>读站点 fields 指定键。站点不存在或值为空返回 false。</summary>
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;
}
}
/// <summary>
/// 地图点是否自带 SignalStopSignalType 字段存在且非空)。
/// SignalParams 可缺省。有类型则扫车不再查 handshake-points.json。
/// </summary>
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;
}
/// <summary>
/// 已充电秒数。优先反射 GetChargeTimeint/double/TimeSpan);
/// 没有则从首次看到充电中的时刻算,离开充电态时清字典。
/// </summary>
public static int ChargeSeconds(Car car, IDictionary<int, DateTime> 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;
}
/// <summary>类型名恰好为 MagCar(不含 Mag2Car)。磁条交管只放行这种车。</summary>
public static bool IsMagCar(Car car)
{
if (car == null) return false;
return string.Equals(car.GetType().Name, "MagCar", StringComparison.Ordinal);
}
/// <summary>放行:Mag2 → Mag → SendStart(0)。</summary>
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 });
}
/// <summary>仅 MagCar 发 FASS 1.0 启动(0x01)。不调 StartMag2Car。</summary>
public static void StartMagCar(Car car)
{
if (!IsMagCar(car)) return;
if (TryInvoke(car, "StartMagCar"))
return;
TryInvoke(car, "SendStart", new object[] { (ushort)0 });
}
/// <summary>急停:Mag2 → Mag → SendStop(0)。机构未允许进入时调用。</summary>
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 });
}
/// <summary>停充。握手放行前通常先停充再启动。</summary>
public static void StopCharge(Car car)
{
if (car == null) return;
TryInvoke(car, "StopCharge");
}
/// <summary>
/// 销钉/停充等动作是否完成。车类型没有 IsActionFinish 时返回 true(非磁导航不阻塞)。
/// 反射异常也当完成,避免把产线卡死。
/// </summary>
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;
}
}
/// <summary>下发动作:先按动作名直接调 MethodMember,没有再 ImmediateCommand(action)。</summary>
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 });
}
/// <summary>
/// 按名称找实例方法。无参与带参分开匹配,重载参数个数不对就跳过。
/// 调用抛错返回 false,让上层试下一个名字。
/// </summary>
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;
}
}
}
+76
View File
@@ -0,0 +1,76 @@
using System;
using System.Collections.Generic;
using System.IO;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace StandardScene.Signal
{
/// <summary>
/// 信号插件 JSON 的路径解析与读写。
/// </summary>
/// <remarks>
/// 只使用 SimpleLite 工作目录 <c>Config/Signal/*.json</c>,不读、不拷 <c>plugins/Config/Signal</c>。
/// 文件不存在时 Load 返回空表;Save 会创建目录。
/// 迷毂数据中心走同一套文件;保存后需在本进程点「重新加载配置」。
/// 枚举按名字序列化(<see cref="StringEnumConverter"/>),与样例里 <c>"上线机构"</c> 一致。
/// </remarks>
public static class SignalConfigStore
{
/// <summary>保存时串行写盘,避免 CycleGUI 与迷毂 API 同时 Save 互相覆盖一半。</summary>
private static readonly object FileLock = new object();
private static readonly JsonSerializerSettings JsonSettings = new JsonSerializerSettings
{
Formatting = Formatting.Indented,
NullValueHandling = NullValueHandling.Ignore,
Converters = { new StringEnumConverter() }
};
/// <summary>
/// 相对路径固定落到工作目录(SimpleLite.exe 所在目录)下的 <c>Config/Signal</c>。
/// 已是绝对路径则原样返回。文件不存在也返回该路径,不回退插件目录。
/// </summary>
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);
}
/// <summary>
/// 反序列化为列表。文件不存在、空、或 JSON 损坏时返回空列表,不抛给启动流程。
/// </summary>
public static List<T> Load<T>(string path) where T : class
{
try
{
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
return new List<T>();
var json = File.ReadAllText(path);
if (string.IsNullOrWhiteSpace(json))
return new List<T>();
return JsonConvert.DeserializeObject<List<T>>(json, JsonSettings) ?? new List<T>();
}
catch
{
return new List<T>();
}
}
/// <summary>缩进写入;自动建目录。与 Load 共用枚举按名序列化。</summary>
public static void Save<T>(string path, IReadOnlyList<T> items)
{
var json = JsonConvert.SerializeObject(items ?? Array.Empty<T>(), JsonSettings);
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
lock (FileLock)
File.WriteAllText(path, json);
}
}
}
@@ -0,0 +1,19 @@
namespace StandardScene.Signal.Site
{
/// <summary>
/// 站点 fields 键名,替代老地图 <c>SignalStop</c> 对象。
/// </summary>
/// <remarks>
/// 地图编辑器若未挂字段袋,仍可手工在站点 fields 写入这两个键。
/// 扫车解析顺序:站点有非空 SignalType → 用地图点;否则回退 <c>handshake-points.json</c>。
/// 键名必须与迷毂/地图侧约定完全一致,改这里要同步改地图字段袋。
/// </remarks>
public static class SignalSiteFields
{
/// <summary>握手类型,取值同 <see cref="StandardScene.Signal.Model.HandshakePointModel.SignalType"/>。</summary>
public const string SignalType = "SignalType";
/// <summary>握手参数,取值同 <see cref="StandardScene.Signal.Model.HandshakePointModel.SignalParams"/>。</summary>
public const string SignalParams = "SignalParams";
}
}
@@ -0,0 +1,65 @@
<!--
scene.signal:第三方 PLC 信号交互插件。
产物 StandardScene.Signal.dll + StandardScene.Signal.scene.json,拷到 SimpleLite/plugins。
运行时由「信号交互进程」拉起:每机构 200ms S7 轮询 + 1s 扫车握手。
Config/Signal/*.json 仅留在工程内作样例,运行时读写 SimpleLite 工作目录 Config/Signal,不部署到 plugins。
-->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<OutputType>Library</OutputType>
<RootNamespace>StandardScene.Signal</RootNamespace>
<AssemblyName>StandardScene.Signal</AssemblyName>
<LangVersion>latest</LangVersion>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Platforms>AnyCPU;x64</Platforms>
<PlatformTarget>x64</PlatformTarget>
<Deterministic>true</Deterministic>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>disable</Nullable>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<NoWarn>$(NoWarn);NU1701;CS0618;CS0612;MSB3277;CA1416</NoWarn>
<AssemblySearchPaths>{HintPathFromItem};{TargetFrameworkDirectory};{RawFileName};{GAC}</AssemblySearchPaths>
</PropertyGroup>
<ItemGroup>
<None Update="StandardScene.Signal.scene.json" CopyToOutputDirectory="PreserveNewest" />
<None Update="Config\Signal\signal-tables.json" CopyToOutputDirectory="PreserveNewest" />
<!-- 业务 JSON 样例只留源码树,不复制到输出/plugins;运行时读写工作目录 Config/Signal -->
<None Update="Config\Signal\*.json" CopyToOutputDirectory="Never" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StandardScene.Core\StandardScene.Core.csproj" />
</ItemGroup>
<ItemGroup>
<Reference Include="CommonUsage">
<HintPath>$(StandRefDir)CommonUsage.dll</HintPath>
</Reference>
<Reference Include="CycleGUI">
<HintPath>$(StandRefDir)CycleGUI.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="LessokajiWeaverUtilities">
<HintPath>$(StandRefDir)LessokajiWeaverUtilities.dll</HintPath>
</Reference>
<Reference Include="SimpleCore">
<HintPath>$(StandRefDir)SimpleCore.dll</HintPath>
</Reference>
<Reference Include="SimpleLite">
<HintPath>$(StandSimpleLiteDir)SimpleLite.dll</HintPath>
</Reference>
<Reference Include="Topaz">
<HintPath>$(StandRefDir)Topaz.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<PackageReference Include="IoTClient" Version="1.0.40" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
</ItemGroup>
</Project>
@@ -0,0 +1,10 @@
{
"id": "scene.signal",
"displayName": "第三方信号交互",
"assembly": "StandardScene.Signal.dll",
"coreVersion": ">=1.0.0",
"requiresCore": "StandardScene.dll",
"provides": {
"missionTypes": [ "SignalInteractMission", "MagTrafficMission" ]
}
}
+55
View File
@@ -0,0 +1,55 @@
using System;
using CycleGUI;
namespace StandardScene.Signal.Ui
{
/// <summary>
/// CycleGUI 独立窗口打开某一类 Model 列表(进程工具栏「打开机构/握手点/放行点列表」触发)。
/// </summary>
/// <remarks>
/// 每种 T 共用一个静态窗口:已打开则前置,关掉后下次再 DeclarePanel。
/// 真正的增删改在 <see cref="ModelTableEditor{T}"/>,保存直接写 JSON
/// 不自动 Reload 进程内 Worker,需再到进程上点「重新加载配置」。
/// </remarks>
public static class ModelListPanel<T> where T : class, new()
{
/// <summary>当前已声明的面板;关闭或异常后置 null,允许再次 Open。</summary>
private static Panel _panel;
private static readonly ModelTableEditor<T> Editor = new ModelTableEditor<T>();
/// <param name="title">窗口标题。</param>
/// <param name="path">JSON 绝对路径(应先经 SignalConfigStore.Resolve)。</param>
/// <param name="keySelector">主键,添加/保存时查重(机构名、站点号等)。</param>
public static void Open(string title, string path, Func<T, string> 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);
});
}
}
}
+355
View File
@@ -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
{
/// <summary>
/// 按 Model 的 <see cref="DisplayNameAttribute"/> 反射生成可增删改表格。
/// </summary>
/// <remarks>
/// 列顺序取属性 MetadataToken(源码声明顺序)。
/// 跳过不可写、索引器、<see cref="BrowsableAttribute"/> 为 false、<see cref="JsonIgnoreAttribute"/> 的属性。
/// 编辑区用独立字典暂存,点「添加」或「保存当前行」才反写对象并 <see cref="SignalConfigStore.Save"/>。
/// CycleGUI 控件 id 带 <c>_idPrefix</c>,三种表同时开窗时不会抢同一组控件状态。
/// </remarks>
public sealed class ModelTableEditor<T> where T : class, new()
{
private static PropertyInfo[] Props => _props ??= Discover();
private static PropertyInfo[] _props;
private List<T> _items = new List<T>();
/// <summary>-1 表示未选中,此时「保存/删除」会提示先选行,「添加」写入新行。</summary>
private int _selected = -1;
private string _status = "";
private string _path = "";
private string _idPrefix = typeof(T).Name;
private Func<T, string> _keySelector = _ => "";
/// <summary>字符串/数值编辑缓存,键为属性名。</summary>
private readonly Dictionary<string, string> _text = new Dictionary<string, string>();
private readonly Dictionary<string, bool> _bools = new Dictionary<string, bool>();
/// <summary>枚举下拉当前下标,写入时按 Enum.GetNames 转回枚举值。</summary>
private readonly Dictionary<string, int> _enumIdx = new Dictionary<string, int>();
public int Count => _items.Count;
public string Status => _status;
/// <summary>绑定 JSON 路径并立即 Reload。同一编辑器换文件时调用。</summary>
public void Bind(string path, Func<T, string> keySelector, string idPrefix)
{
_path = path ?? "";
_keySelector = keySelector ?? (_ => "");
_idPrefix = string.IsNullOrWhiteSpace(idPrefix) ? typeof(T).Name : idPrefix;
Reload();
}
/// <summary>从磁盘重读,丢弃未保存的编辑区内容。</summary>
public void Reload()
{
_items = SignalConfigStore.Load<T>(_path);
_selected = -1;
_status = $"已加载 {_items.Count} 条 · {_path}";
ClearEditor();
}
/// <summary>列表 + 编辑区 + 添加/保存/删除/清空/刷新。每帧由面板 Define 调用。</summary>
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);
}
}
/// <summary>发现可编辑列。Browsable=false 或 JsonIgnore 的运行态字段不会进表。</summary>
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<BrowsableAttribute>()?.Browsable != false)
.Where(p => p.GetCustomAttribute<JsonIgnoreAttribute>() == null)
.OrderBy(p => p.MetadataToken)
.ToArray();
}
private static string DisplayOf(PropertyInfo p) =>
p.GetCustomAttribute<DisplayNameAttribute>()?.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 "";
}
}
/// <summary>把编辑区打成对象。主键空、整数解析失败时返回 false 并给出中文 err。</summary>
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;
}
/// <summary>主键已存在则拒绝。成功后立刻写盘并把选中行指到新行。</summary>
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}";
}
/// <summary>覆盖当前选中行。主键与其它行冲突则拒绝。</summary>
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("提示", "保存成功");
}
/// <summary>确认框通过后删除选中行并写盘。</summary>
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}";
});
}
}
}
+28
View File
@@ -17,6 +17,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandardScene.Fass2Simulato
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandardScene.Magnetic.Tests", "StandardScene.Magnetic.Tests\StandardScene.Magnetic.Tests.csproj", "{7C13685A-D666-4404-8CD7-19AA9BFC907B}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandardScene.Magnetic.Tests", "StandardScene.Magnetic.Tests\StandardScene.Magnetic.Tests.csproj", "{7C13685A-D666-4404-8CD7-19AA9BFC907B}"
EndProject 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 Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU 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|x64.Build.0 = Release|Any CPU
{7C13685A-D666-4404-8CD7-19AA9BFC907B}.Release|x86.ActiveCfg = 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 {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 EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
+18
View File
@@ -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);
}