Compare commits
4
Commits
1e780c2b65
...
25f39c3cc3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25f39c3cc3 | ||
|
|
244b0af6c8 | ||
|
|
cacc5fea5c | ||
|
|
12c7c1d518 |
+21
-1
@@ -1,7 +1,7 @@
|
||||
<Project>
|
||||
<!--
|
||||
StandardScene 插件工程编译后,自动将产物复制到 build\plugins\(SimpleComposer / SimpleLite 宿主加载目录)。
|
||||
适用:StandardScene.Core + Magnetic / QrLidar / Devices / Protocol.VDA5050 等卫星插件。
|
||||
适用:StandardScene.Core + Magnetic / QrLidar / Devices / Protocol.VDA5050 / Signal 等卫星插件。
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<StandardSceneRepoRoot>$(MSBuildThisFileDirectory)</StandardSceneRepoRoot>
|
||||
@@ -12,6 +12,7 @@
|
||||
<IsStandardScenePluginCopy Condition="'$(MSBuildProjectName)' == 'StandardScene.QrLidar'">true</IsStandardScenePluginCopy>
|
||||
<IsStandardScenePluginCopy Condition="'$(MSBuildProjectName)' == 'StandardScene.Devices'">true</IsStandardScenePluginCopy>
|
||||
<IsStandardScenePluginCopy Condition="'$(MSBuildProjectName)' == 'StandardScene.Protocol.VDA5050'">true</IsStandardScenePluginCopy>
|
||||
<IsStandardScenePluginCopy Condition="'$(MSBuildProjectName)' == 'StandardScene.Signal'">true</IsStandardScenePluginCopy>
|
||||
</PropertyGroup>
|
||||
|
||||
<Target Name="CopyStandardScenePluginOutputs"
|
||||
@@ -28,12 +29,31 @@
|
||||
<!-- Devices 插件运行时依赖 -->
|
||||
<StandardScenePluginCopyFile Include="$(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>
|
||||
|
||||
<Copy SourceFiles="@(StandardScenePluginCopyFile)"
|
||||
DestinationFolder="$(StandardScenePluginsDir)"
|
||||
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"
|
||||
Text="[StandardScene] 已复制 $(MSBuildProjectName) 插件产物到 $(StandardScenePluginsDir)" />
|
||||
</Target>
|
||||
|
||||
@@ -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 > 0,且两车优先级不同:</p>
|
||||
<p>若 <b>高优先级到达时刻 − 低优先级到达时刻 > W</b>,则低优先级先走。</p>
|
||||
<p>含义:低优先级更早到达,且已经比高优先级早到超过 W 秒,避免支路饿死。差值未超过 W 时,仍让优先级高的先走。</p>
|
||||
<p>使用「>」而不是「≥」。例如 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.Magnetic")]
|
||||
[assembly: InternalsVisibleTo("StandardScene.QrLidar")]
|
||||
[assembly: InternalsVisibleTo("StandardScene.Signal")]
|
||||
// SimpleLite 反射 API 需读取 internal 字段袋(BasicTrackFields / KivaSiteFields 等)的 public 字段定义。
|
||||
[assembly: InternalsVisibleTo("SimpleLite")]
|
||||
|
||||
@@ -24,7 +24,7 @@ namespace StandardScene.Fass2Simulator
|
||||
public bool AutoCompleteStationActions { get; set; }
|
||||
public string NodeProfilesPath { get; set; } = "sim-nodes.json";
|
||||
public bool EnableFileLog { get; set; } = true;
|
||||
public string LogDirectory { get; set; } = "logs";
|
||||
public string LogDirectory { get; set; } = "log";
|
||||
public Dictionary<string, Fass2SimNodeProfile> NodeProfiles { get; set; } = new Dictionary<string, Fass2SimNodeProfile>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ namespace StandardScene.Fass2Simulator
|
||||
|
||||
public static string GetLogDirectory()
|
||||
{
|
||||
return _logDirectory ?? ResolveLogDirectory("logs");
|
||||
return _logDirectory ?? ResolveLogDirectory("log");
|
||||
}
|
||||
|
||||
private static void EnsureDefaultWriter()
|
||||
@@ -129,7 +129,7 @@ namespace StandardScene.Fass2Simulator
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configuredPath))
|
||||
{
|
||||
return Path.Combine(AppContext.BaseDirectory, "logs");
|
||||
return Path.Combine(AppContext.BaseDirectory, "log");
|
||||
}
|
||||
|
||||
return Path.IsPathRooted(configuredPath)
|
||||
|
||||
@@ -43,7 +43,7 @@ namespace StandardScene.Fass2Simulator
|
||||
Fass2SimFileLogger.Shutdown();
|
||||
};
|
||||
|
||||
AppendLog("就绪。配置参数后点击「启动模拟」开始联调。完整日志将写入 logs 目录。");
|
||||
AppendLog("就绪。配置参数后点击「启动模拟」开始联调。完整日志将写入 log 目录。");
|
||||
}
|
||||
|
||||
private void StartButton_Click(object sender, RoutedEventArgs e)
|
||||
@@ -317,7 +317,7 @@ namespace StandardScene.Fass2Simulator
|
||||
AutoCompleteStationActions = AutoCompleteBox.IsChecked == true,
|
||||
LogRawFrames = LogRawFramesBox.IsChecked == true,
|
||||
EnableFileLog = _config?.EnableFileLog ?? true,
|
||||
LogDirectory = _config?.LogDirectory ?? "logs"
|
||||
LogDirectory = _config?.LogDirectory ?? "log"
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,6 @@
|
||||
"AutoCompleteStationActions": false,
|
||||
"NodeProfilesPath": "sim-nodes.json",
|
||||
"EnableFileLog": true,
|
||||
"LogDirectory": "logs\\car2"
|
||||
"LogDirectory": "log\\car2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,6 @@
|
||||
"AutoCompleteStationActions": false,
|
||||
"NodeProfilesPath": "sim-nodes.json",
|
||||
"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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -79,7 +79,7 @@ namespace StandardScene.CarTypes
|
||||
[FieldMember] public bool EnableDetailLog = true;
|
||||
[FieldMember] public bool LogRawFrame = true;
|
||||
[FieldMember] public bool EnableFileLog = true;
|
||||
[FieldMember] public string LogDirectory = "logs";
|
||||
[FieldMember] public string LogDirectory = "log";
|
||||
[FieldMember] public float CarLength = 1200;
|
||||
[FieldMember] public float CarWidth = 800;
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ using System.Drawing.Drawing2D;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -101,6 +102,9 @@ namespace StandardScene.CarTypes
|
||||
/// </summary>
|
||||
private DateTime _lastPollTime = DateTime.MinValue;
|
||||
|
||||
/// <summary>最近一次与 AGV 通讯成功的时间;用于「断开超过 OfflineAfterMs → 离线」。</summary>
|
||||
private DateTime _lastCommOkTime = DateTime.Now;
|
||||
|
||||
/// <summary>
|
||||
/// AGV 服务端监听端口,默认 5000。
|
||||
/// </summary>
|
||||
@@ -135,6 +139,12 @@ namespace StandardScene.CarTypes
|
||||
/// </summary>
|
||||
[FieldMember] public int PollIntervalMs = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// 连续通讯失败超过该毫秒数后,概况显示「离线」。
|
||||
/// 默认 20000(20 秒);期间失败会显示「连接中」。
|
||||
/// </summary>
|
||||
[FieldMember] public int OfflineAfterMs = 20000;
|
||||
|
||||
/// <summary>
|
||||
/// 移动任务的预留超时时间,单位秒。
|
||||
/// 当前 MagCar 不下发脚本,保留该字段用于后续任务调度或界面配置兼容。
|
||||
@@ -280,11 +290,28 @@ namespace StandardScene.CarTypes
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lstatus = IsBenignSocketFault(ex) ? lstatus : "离线";
|
||||
if (!IsBenignSocketFault(ex))
|
||||
{
|
||||
DetailLog($"keepAlive poll failed: {ex.Message}");
|
||||
}
|
||||
ApplyOfflineAfterDisconnect(ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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))
|
||||
{
|
||||
DetailLog($"keepAlive poll failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -590,6 +617,13 @@ namespace StandardScene.CarTypes
|
||||
try
|
||||
{
|
||||
ConnectWithTimeout(client);
|
||||
if (IsLoopbackSelfConnect(client))
|
||||
{
|
||||
AbortClientQuietly(client);
|
||||
throw new IOException(
|
||||
$"TCP self-connect {address}:{Port} (local ephemeral hit listen port); refuse and retry");
|
||||
}
|
||||
|
||||
client.SendTimeout = SendTimeoutMs;
|
||||
client.ReceiveTimeout = ReceiveTimeoutMs;
|
||||
_persistentClient = client;
|
||||
@@ -619,7 +653,51 @@ namespace StandardScene.CarTypes
|
||||
{
|
||||
try
|
||||
{
|
||||
return _persistentClient != null && _persistentClient.Connected;
|
||||
if (_persistentClient == null || !_persistentClient.Connected)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 本机动态端口若从 1024 起,连 500x 且模拟器未监听时,Windows 可能自连成功且 Connected=true,
|
||||
// 之后会一直把自身请求当响应(State=0 → 未准备),必须主动拆掉重连。
|
||||
if (IsLoopbackSelfConnect(_persistentClient))
|
||||
{
|
||||
ClosePersistentConnection("loopback self-connect");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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
|
||||
{
|
||||
@@ -680,6 +758,13 @@ namespace StandardScene.CarTypes
|
||||
{
|
||||
DetailLog($"short connect begin {address}:{Port}, timeout={ConnectTimeoutMs}ms");
|
||||
ConnectWithTimeout(client);
|
||||
if (IsLoopbackSelfConnect(client))
|
||||
{
|
||||
AbortClientQuietly(client);
|
||||
throw new IOException(
|
||||
$"TCP self-connect {address}:{Port} (local ephemeral hit listen port); refuse and retry");
|
||||
}
|
||||
|
||||
DetailLog($"short connect ok {address}:{Port}");
|
||||
|
||||
client.SendTimeout = SendTimeoutMs;
|
||||
@@ -785,11 +870,17 @@ namespace StandardScene.CarTypes
|
||||
if (!string.IsNullOrEmpty(message) &&
|
||||
(message.Contains("已中止 I/O", StringComparison.Ordinal) ||
|
||||
message.Contains("I/O operation", StringComparison.OrdinalIgnoreCase) ||
|
||||
message.Contains("operation was aborted", StringComparison.OrdinalIgnoreCase)))
|
||||
message.Contains("operation was aborted", StringComparison.OrdinalIgnoreCase) ||
|
||||
message.Contains("self-connect", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (current is TimeoutException)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -899,6 +990,7 @@ namespace StandardScene.CarTypes
|
||||
private void ApplyReport(MagCarReport report)
|
||||
{
|
||||
LastReport = report;
|
||||
_lastCommOkTime = DateTime.Now;
|
||||
lstatus = report.Alarm != 0 ? "报警" : report.State == 0 ? "未准备" : "上线";
|
||||
th = report.Angle;
|
||||
|
||||
@@ -925,12 +1017,108 @@ namespace StandardScene.CarTypes
|
||||
|
||||
haveCoordination = true;
|
||||
siteID = site.id;
|
||||
x = site.x;
|
||||
y = site.y;
|
||||
ApplyStackedSitePose(site);
|
||||
SyncTrafficFromReport(site, report);
|
||||
DetailLog($"report applied, node={report.Node}, site={siteID}, x={x:0.###}, y={y:0.###}, th={th:0.###}, state={StateText(report.State)}, soc={report.Charge}, current={report.Current:0.###}, voltage={report.Voltage:0.###}, speed={report.Speed}, task={report.Task}, lift={report.Lift}, roll={report.Roll}, alarm=0x{report.Alarm:X}");
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// 将 AGV 上报的当前地标同步到交通锁(holdingLocks)。
|
||||
/// GhostCar 调度依赖 holdingLocks/GetLastSite,仅改 x/y/siteID 会导致“画面在动、初始化占点不动”。
|
||||
@@ -954,6 +1142,25 @@ namespace StandardScene.CarTypes
|
||||
return;
|
||||
}
|
||||
|
||||
// 他车仍占本站锁但物理站已离开 → 回收陈旧锁,否则 TrafficReset 失败,画面占点不跟着走。
|
||||
var holder = FindOtherCarHoldingSite(site.id);
|
||||
if (holder != null)
|
||||
{
|
||||
if (!TryReleaseStaleHolder(holder, site.id))
|
||||
{
|
||||
if (_lastSyncedTrafficSiteId != -site.id)
|
||||
{
|
||||
DetailLog(
|
||||
$"traffic sync skip, site={site.id} held by car={holder.id}, selfLock={lockedSite}, node={report.Node}");
|
||||
_lastSyncedTrafficSiteId = -site.id;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
DetailLog($"traffic sync released stale holder car={holder.id}, site={site.id}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
DetailLog($"traffic sync TrafficReset, lock={lockedSite}, targetSite={site.id}, node={report.Node}, state={StateText(report.State)}");
|
||||
@@ -966,6 +1173,52 @@ namespace StandardScene.CarTypes
|
||||
}
|
||||
}
|
||||
|
||||
private Car FindOtherCarHoldingSite(int siteId)
|
||||
{
|
||||
foreach (var other in SimpleLib.GetAllCars().OfType<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>
|
||||
/// 将 AGV 返回的节点号解析为场景站点。
|
||||
/// 根据 <see cref="UseTagValueAsNode"/> 决定优先匹配站点 TagValue 还是直接匹配站点 id。
|
||||
|
||||
@@ -9,6 +9,7 @@ namespace StandardScene.Magnetic.Tasking
|
||||
{
|
||||
/// <summary>
|
||||
/// 按 holdingLocks + 物理 siteID 统计管控站点列表上的他车占用。
|
||||
/// Mag2 仍读地图 Fass2_ControlArea 字段;磁条交管进程只放行 MagCar,不介入本路径。
|
||||
/// </summary>
|
||||
internal static class Fass2ControlAreaOccupancy
|
||||
{
|
||||
|
||||
@@ -6,17 +6,17 @@ using System.Text;
|
||||
namespace StandardScene.Magnetic.Tasking
|
||||
{
|
||||
/// <summary>
|
||||
/// Mag2Car 本地文件日志,按协议车号分目录:logs/car{VehicleCode}/mag2_yyyyMMdd.log
|
||||
/// Mag2Car 本地文件日志,按协议车号分目录:log/car{VehicleCode}/mag2_yyyyMMdd.log
|
||||
/// </summary>
|
||||
public static class Mag2CarFileLogger
|
||||
{
|
||||
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;
|
||||
|
||||
public static void Configure(string baseDirectory, bool enabled)
|
||||
{
|
||||
_baseDirectory = string.IsNullOrWhiteSpace(baseDirectory) ? "logs" : baseDirectory.Trim();
|
||||
_baseDirectory = string.IsNullOrWhiteSpace(baseDirectory) ? "log" : baseDirectory.Trim();
|
||||
_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": "单牵引下线机构"
|
||||
}
|
||||
]
|
||||
@@ -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 成功后为 true;ReloadConfig 仅在已启动时重启 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<=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>JGControl:RequestIn / Arrived;PointControl:UP1 / 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>心跳位,0~7。</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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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=DB,2=偏移。</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 上有对应 MethodMember(StartMag2Car、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>
|
||||
/// 地图点是否自带 SignalStop(SignalType 字段存在且非空)。
|
||||
/// 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>
|
||||
/// 已充电秒数。优先反射 GetChargeTime(int/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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" ]
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}";
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandardScene.Fass2Simulato
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandardScene.Magnetic.Tests", "StandardScene.Magnetic.Tests\StandardScene.Magnetic.Tests.csproj", "{7C13685A-D666-4404-8CD7-19AA9BFC907B}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandardScene.Signal", "StandardScene.Signal\StandardScene.Signal.csproj", "{7E5050AA-0009-4A11-9C22-0A0B0C0D0E09}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandardScene.MagCarSimulator", "StandardScene.MagCarSimulator\StandardScene.MagCarSimulator.csproj", "{7E5050AA-000A-4A11-9C22-0A0B0C0D0E0A}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -111,6 +115,30 @@ Global
|
||||
{7C13685A-D666-4404-8CD7-19AA9BFC907B}.Release|x64.Build.0 = Release|Any CPU
|
||||
{7C13685A-D666-4404-8CD7-19AA9BFC907B}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{7C13685A-D666-4404-8CD7-19AA9BFC907B}.Release|x86.Build.0 = Release|Any CPU
|
||||
{7E5050AA-0009-4A11-9C22-0A0B0C0D0E09}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{7E5050AA-0009-4A11-9C22-0A0B0C0D0E09}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{7E5050AA-0009-4A11-9C22-0A0B0C0D0E09}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{7E5050AA-0009-4A11-9C22-0A0B0C0D0E09}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{7E5050AA-0009-4A11-9C22-0A0B0C0D0E09}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{7E5050AA-0009-4A11-9C22-0A0B0C0D0E09}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{7E5050AA-0009-4A11-9C22-0A0B0C0D0E09}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{7E5050AA-0009-4A11-9C22-0A0B0C0D0E09}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{7E5050AA-0009-4A11-9C22-0A0B0C0D0E09}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{7E5050AA-0009-4A11-9C22-0A0B0C0D0E09}.Release|x64.Build.0 = Release|Any CPU
|
||||
{7E5050AA-0009-4A11-9C22-0A0B0C0D0E09}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{7E5050AA-0009-4A11-9C22-0A0B0C0D0E09}.Release|x86.Build.0 = Release|Any CPU
|
||||
{7E5050AA-000A-4A11-9C22-0A0B0C0D0E0A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{7E5050AA-000A-4A11-9C22-0A0B0C0D0E0A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{7E5050AA-000A-4A11-9C22-0A0B0C0D0E0A}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{7E5050AA-000A-4A11-9C22-0A0B0C0D0E0A}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{7E5050AA-000A-4A11-9C22-0A0B0C0D0E0A}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{7E5050AA-000A-4A11-9C22-0A0B0C0D0E0A}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{7E5050AA-000A-4A11-9C22-0A0B0C0D0E0A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{7E5050AA-000A-4A11-9C22-0A0B0C0D0E0A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{7E5050AA-000A-4A11-9C22-0A0B0C0D0E0A}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{7E5050AA-000A-4A11-9C22-0A0B0C0D0E0A}.Release|x64.Build.0 = Release|Any CPU
|
||||
{7E5050AA-000A-4A11-9C22-0A0B0C0D0E0A}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{7E5050AA-000A-4A11-9C22-0A0B0C0D0E0A}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user