磁导航1.0内部交管和信号交互
This commit is contained in:
@@ -33,12 +33,27 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,13 +290,30 @@ namespace StandardScene.CarTypes
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lstatus = IsBenignSocketFault(ex) ? lstatus : "离线";
|
||||
ApplyOfflineAfterDisconnect(ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通讯失败时:未满 <see cref="OfflineAfterMs"/> 显示「连接中」,满 20 秒(可配)后显示「离线」。
|
||||
/// </summary>
|
||||
private void ApplyOfflineAfterDisconnect(Exception ex)
|
||||
{
|
||||
var offlineAfter = OfflineAfterMs < 1000 ? 1000 : OfflineAfterMs;
|
||||
if ((DateTime.Now - _lastCommOkTime).TotalMilliseconds >= offlineAfter)
|
||||
{
|
||||
lstatus = "离线";
|
||||
}
|
||||
else if (lstatus != "离线")
|
||||
{
|
||||
lstatus = "连接中";
|
||||
}
|
||||
|
||||
if (!IsBenignSocketFault(ex))
|
||||
{
|
||||
DetailLog($"keepAlive poll failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool _running;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,31 +2,46 @@
|
||||
{
|
||||
"Sit": 98,
|
||||
"SignalType": "JGControl",
|
||||
"SignalParams": "上线机构1;RequestIn"
|
||||
"JgName": "上线机构1",
|
||||
"DockId": "dock1",
|
||||
"Action": "RequestIn"
|
||||
},
|
||||
{
|
||||
"Sit": 100,
|
||||
"SignalType": "JGControl",
|
||||
"SignalParams": "上线机构1;Arrived"
|
||||
"JgName": "上线机构1",
|
||||
"DockId": "dock1",
|
||||
"Action": "Arrived"
|
||||
},
|
||||
{
|
||||
"Sit": 108,
|
||||
"SignalType": "JGControl",
|
||||
"JgName": "上线机构1",
|
||||
"DockId": "dock2",
|
||||
"Action": "RequestIn"
|
||||
},
|
||||
{
|
||||
"Sit": 199,
|
||||
"SignalType": "JGControl",
|
||||
"SignalParams": "下线机构1;RequestIn"
|
||||
"JgName": "下线机构1",
|
||||
"DockId": "dock1",
|
||||
"Action": "RequestIn"
|
||||
},
|
||||
{
|
||||
"Sit": 201,
|
||||
"SignalType": "PointControl",
|
||||
"SignalParams": "UP1;下线机构1"
|
||||
"JgName": "下线机构1",
|
||||
"DockId": "dock1",
|
||||
"Action": "UP1"
|
||||
},
|
||||
{
|
||||
"Sit": 50,
|
||||
"SignalType": "TimedCharging",
|
||||
"SignalParams": "300;95;20"
|
||||
"ExtraParams": "300;95;20"
|
||||
},
|
||||
{
|
||||
"Sit": 60,
|
||||
"SignalType": "RateFlowControl",
|
||||
"SignalParams": "缓存区;A;2;60,61,62"
|
||||
"ExtraParams": "缓存区;A;2;60,61,62"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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,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"
|
||||
}
|
||||
]
|
||||
@@ -3,26 +3,20 @@
|
||||
"JgName": "上线机构1",
|
||||
"Ip": "192.168.1.10",
|
||||
"Port": 102,
|
||||
"ReadDb": "DB1.DBB0",
|
||||
"WriteDb": "DB1.DBB1",
|
||||
"HeartDb": "DB1.DBX2.0",
|
||||
"JgInPointId": 99,
|
||||
"JgInContains": "99,100",
|
||||
"JgPointId": 100,
|
||||
"JgContains": "100,101,102",
|
||||
"PlcType": "S7_1500",
|
||||
"DbNumber": 100,
|
||||
"HeartByte": 32,
|
||||
"HeartBit": 0,
|
||||
"LineType": "上线机构"
|
||||
},
|
||||
{
|
||||
"JgName": "下线机构1",
|
||||
"Ip": "192.168.1.11",
|
||||
"Port": 102,
|
||||
"ReadDb": "DB1.DBB0",
|
||||
"WriteDb": "DB1.DBB1",
|
||||
"HeartDb": "DB1.DBX2.0",
|
||||
"JgInPointId": 200,
|
||||
"JgInContains": "200",
|
||||
"JgPointId": 201,
|
||||
"JgContains": "201,202",
|
||||
"PlcType": "S7_1500",
|
||||
"DbNumber": 100,
|
||||
"HeartByte": 32,
|
||||
"HeartBit": 0,
|
||||
"LineType": "单牵引下线机构"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -139,13 +139,26 @@ namespace StandardScene.Signal.Logic
|
||||
release = _release;
|
||||
}
|
||||
|
||||
PlcStationRuntime Find(string name)
|
||||
PlcStationRuntime Find(string name, string dockId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return null;
|
||||
var list = _runtimes() ?? Array.Empty<PlcStationRuntime>();
|
||||
return list.FirstOrDefault(x =>
|
||||
string.Equals(x.Config.JgName, name, StringComparison.OrdinalIgnoreCase));
|
||||
var ofStation = list.Where(x =>
|
||||
string.Equals(x.JgName, name, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
if (ofStation.Count == 0)
|
||||
return null;
|
||||
if (!string.IsNullOrWhiteSpace(dockId))
|
||||
{
|
||||
var hit = ofStation.FirstOrDefault(x =>
|
||||
string.Equals(x.DockId, dockId.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||
if (hit != null)
|
||||
return hit;
|
||||
}
|
||||
|
||||
return ofStation.FirstOrDefault(x =>
|
||||
string.Equals(x.DockId, PlcConfigLoader.DefaultDockId, StringComparison.OrdinalIgnoreCase))
|
||||
?? ofStation[0];
|
||||
}
|
||||
|
||||
foreach (var car in SignalCarAdapter.All())
|
||||
@@ -192,7 +205,7 @@ namespace StandardScene.Signal.Logic
|
||||
/// 停稳分发。TimedCharging 优先:充电点不应再走机构逻辑。
|
||||
/// 未识别的 SignalType 按站点节流打日志,避免刷屏。
|
||||
/// </summary>
|
||||
private void DispatchStopped(Car car, SignalStopSpec spec, Func<string, PlcStationRuntime> find)
|
||||
private void DispatchStopped(Car car, SignalStopSpec spec, Func<string, string, PlcStationRuntime> find)
|
||||
{
|
||||
if (spec.Is("TimedCharging"))
|
||||
{
|
||||
|
||||
@@ -30,11 +30,12 @@ namespace StandardScene.Signal.Logic
|
||||
public static class JgControlHandler
|
||||
{
|
||||
/// <summary>停稳:可选限流 → RequestIn 或 Arrived。</summary>
|
||||
public static void OnStopped(Car car, SignalStopSpec spec, Func<string, PlcStationRuntime> find, IDictionary<string, int> rateFlowLog)
|
||||
public static void OnStopped(Car car, SignalStopSpec spec, Func<string, string, PlcStationRuntime> find, IDictionary<string, int> rateFlowLog)
|
||||
{
|
||||
var jgName = spec.Part(0).Trim();
|
||||
var action = spec.Part(1).Trim();
|
||||
var rateFlow = spec.Part(2).Trim();
|
||||
var jgName = spec.JgName;
|
||||
var action = spec.Action;
|
||||
var dockId = spec.DockId;
|
||||
var rateFlow = spec.ExtraParams;
|
||||
if (string.IsNullOrEmpty(jgName))
|
||||
{
|
||||
Diagnosis.Log($"AGV{car.id}停在地标{SignalCarAdapter.PositionId(car)},读取配置机构名称为空", "JGControl", true);
|
||||
@@ -48,29 +49,25 @@ namespace StandardScene.Signal.Logic
|
||||
}
|
||||
|
||||
if (string.Equals(action, "RequestIn", StringComparison.OrdinalIgnoreCase))
|
||||
RequestIn(jgName, car, find);
|
||||
RequestIn(jgName, dockId, car, find);
|
||||
else if (string.Equals(action, "Arrived", StringComparison.OrdinalIgnoreCase))
|
||||
Arrived(jgName, car, find);
|
||||
Arrived(jgName, dockId, car, find);
|
||||
}
|
||||
|
||||
/// <summary>行驶中只做 RequestIn 安全闸:未允许进入且不是本车占用请求槽则停车。</summary>
|
||||
public static void OnMoving(Car car, SignalStopSpec spec, Func<string, PlcStationRuntime> find)
|
||||
public static void OnMoving(Car car, SignalStopSpec spec, Func<string, string, PlcStationRuntime> find)
|
||||
{
|
||||
var jgName = spec.Part(0).Trim();
|
||||
var action = spec.Part(1).Trim();
|
||||
var jgName = spec.JgName;
|
||||
var action = spec.Action;
|
||||
if (string.IsNullOrEmpty(jgName))
|
||||
return;
|
||||
if (string.Equals(action, "RequestIn", StringComparison.OrdinalIgnoreCase))
|
||||
RequestInJudge(jgName, car, find);
|
||||
RequestInJudge(jgName, spec.DockId, car, find);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 请求进入。锁机构运行态,避免与 PLC Worker 同时改写字节 / 车号。
|
||||
/// 后半段与 <see cref="RequestInJudge"/> 相同,覆盖「刚放行后又被调度层启动」的竞态。
|
||||
/// </summary>
|
||||
private static void RequestIn(string jgName, Car car, Func<string, PlcStationRuntime> find)
|
||||
private static void RequestIn(string jgName, string dockId, Car car, Func<string, string, PlcStationRuntime> find)
|
||||
{
|
||||
var rt = find(jgName);
|
||||
var rt = find(jgName, dockId);
|
||||
if (rt == null)
|
||||
{
|
||||
Diagnosis.Log($"{jgName} 未能找到机构", "JGControl", true);
|
||||
@@ -83,19 +80,19 @@ namespace StandardScene.Signal.Logic
|
||||
rt.RequestInAgvNo == 0 &&
|
||||
rt.ReadWriteSignal == SignalWriteValue.无状态)
|
||||
{
|
||||
if (rt.Config.LineType is SignalLineType.单牵引下线机构 or SignalLineType.双牵引下线机构)
|
||||
if (rt.Station.LineType is SignalLineType.单牵引下线机构 or SignalLineType.双牵引下线机构)
|
||||
{
|
||||
var inside = SignalCarAdapter.CarsAt(rt.Config.JgPointId);
|
||||
var inside = SignalCarAdapter.CarsAt(rt.Dock.JgPointId);
|
||||
if (inside.Count > 0)
|
||||
{
|
||||
Diagnosis.Log(
|
||||
$"{jgName} 机构内有AGV【{string.Join(",", inside.Select(x => x.id))}】暂不放行AGV{car.id}",
|
||||
$"{rt.DisplayName} 机构内有AGV【{string.Join(",", inside.Select(x => x.id))}】暂不放行AGV{car.id}",
|
||||
"JGControl", true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Diagnosis.Log($"{jgName} 机构允许进入,通知AGV{car.id}启动,通知机构AGV进入中", "JGControl", true);
|
||||
Diagnosis.Log($"{rt.DisplayName} 机构允许进入,通知AGV{car.id}启动,通知机构AGV进入中", "JGControl", true);
|
||||
rt.WriteSignal = SignalWriteValue.进入中;
|
||||
rt.RequestInAgvNo = car.id;
|
||||
SignalCarAdapter.StopCharge(car);
|
||||
@@ -108,15 +105,14 @@ namespace StandardScene.Signal.Logic
|
||||
SignalCarAdapter.IsMoving(car))
|
||||
{
|
||||
SignalCarAdapter.Stop(car);
|
||||
Diagnosis.Log($"{jgName} 机构不允许进入,监控到AGV启动,暂停AGV{car.id}", "JGControl", true);
|
||||
Diagnosis.Log($"{rt.DisplayName} 机构不允许进入,监控到AGV启动,暂停AGV{car.id}", "JGControl", true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>行驶中的 RequestIn 安全闸,条件与 RequestIn 后半段一致。</summary>
|
||||
private static void RequestInJudge(string jgName, Car car, Func<string, PlcStationRuntime> find)
|
||||
private static void RequestInJudge(string jgName, string dockId, Car car, Func<string, string, PlcStationRuntime> find)
|
||||
{
|
||||
var rt = find(jgName);
|
||||
var rt = find(jgName, dockId);
|
||||
if (rt == null)
|
||||
{
|
||||
Diagnosis.Log($"{jgName} 未能找到机构", "JGControl", true);
|
||||
@@ -130,17 +126,14 @@ namespace StandardScene.Signal.Logic
|
||||
SignalCarAdapter.IsMoving(car))
|
||||
{
|
||||
SignalCarAdapter.Stop(car);
|
||||
Diagnosis.Log($"{jgName} 机构不允许进入,监控到AGV启动,暂停AGV{car.id}", "JGControl", true);
|
||||
Diagnosis.Log($"{rt.DisplayName} 机构不允许进入,监控到AGV启动,暂停AGV{car.id}", "JGControl", true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 到位。InAgvNo 已是本车则认为已报到,避免每秒重复写「到位」把 WriteSuccess 清掉。
|
||||
/// </summary>
|
||||
private static void Arrived(string jgName, Car car, Func<string, PlcStationRuntime> find)
|
||||
private static void Arrived(string jgName, string dockId, Car car, Func<string, string, PlcStationRuntime> find)
|
||||
{
|
||||
var rt = find(jgName);
|
||||
var rt = find(jgName, dockId);
|
||||
if (rt == null)
|
||||
{
|
||||
Diagnosis.Log($"{jgName} 未能找到机构", "JGControl", true);
|
||||
@@ -151,7 +144,7 @@ namespace StandardScene.Signal.Logic
|
||||
{
|
||||
if (rt.InAgvNo == car.id)
|
||||
return;
|
||||
Diagnosis.Log($"AGV{car.id}已到达机构{jgName}", "JGControl", true);
|
||||
Diagnosis.Log($"AGV{car.id}已到达机构{rt.DisplayName}", "JGControl", true);
|
||||
rt.WriteSignal = SignalWriteValue.到位;
|
||||
rt.InAgvNo = car.id;
|
||||
rt.RequestInAgvNo = 0;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,10 @@ namespace StandardScene.Signal.Logic
|
||||
public static class PointControlHandler
|
||||
{
|
||||
/// <summary>停稳:先判机构是否允许离开,再下发销钉,全部到位后启动。</summary>
|
||||
public static void OnStopped(Car car, SignalStopSpec spec, Func<string, PlcStationRuntime> find)
|
||||
public static void OnStopped(Car car, SignalStopSpec spec, Func<string, string, PlcStationRuntime> find)
|
||||
{
|
||||
var action = spec.Part(0).Trim();
|
||||
var jgName = spec.Part(1).Trim();
|
||||
var action = spec.Action;
|
||||
var jgName = spec.JgName;
|
||||
if (string.IsNullOrEmpty(action))
|
||||
{
|
||||
Diagnosis.Log($"AGV{car.id}停在地标{SignalCarAdapter.PositionId(car)},读取配置特殊点名称为空", "SetPin", true);
|
||||
@@ -31,7 +31,7 @@ namespace StandardScene.Signal.Logic
|
||||
var waitLeave = false;
|
||||
if (!string.IsNullOrEmpty(jgName))
|
||||
{
|
||||
var rt = find(jgName);
|
||||
var rt = find(jgName, spec.DockId);
|
||||
if (rt != null)
|
||||
{
|
||||
lock (rt.Sync)
|
||||
|
||||
@@ -4,30 +4,24 @@ using StandardScene.Signal.Model;
|
||||
namespace StandardScene.Signal.Logic
|
||||
{
|
||||
/// <summary>
|
||||
/// 一条握手点配置的解析结果。参数格式与反编译 <c>SignalStop.SignalParams</c> 一致。
|
||||
/// 一条握手点配置的解析结果。新列(机构/工位/动作/附加参数)优先,旧 SignalParams 作回退。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="SignalParams"/> 按分号切成 <see cref="Parts"/>,缺段时 <see cref="Part"/> 返回空串。
|
||||
/// 各类型约定:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>JGControl</c>:机构名;RequestIn|Arrived;[区域-车组-限额-点列表]</item>
|
||||
/// <item><c>PointControl</c>:UP1|UP2|Down1|Down2;机构名</item>
|
||||
/// <item><c>TimedCharging</c>:最长秒数;上限电量;下限电量</item>
|
||||
/// <item><c>RateFlowControl</c>:区域;车组;限额;点1,点2</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public sealed class SignalStopSpec
|
||||
{
|
||||
/// <summary>握手类型名,与 Handler 的 <see cref="Is"/> 比较时忽略大小写。</summary>
|
||||
public string SignalType { get; init; } = "";
|
||||
|
||||
/// <summary>原始参数串,限流独立类型会整段交给 <see cref="RateFlowHandler"/>。</summary>
|
||||
public string SignalParams { get; init; } = "";
|
||||
|
||||
/// <summary>按 ';' 切分,保留空段(与 Split None 一致,方便 Part(n) 按下标取)。</summary>
|
||||
public string[] Parts { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>取第 <paramref name="index"/> 段;越界或 null 返回空串。</summary>
|
||||
public string JgName { get; init; } = "";
|
||||
|
||||
public string DockId { get; init; } = "";
|
||||
|
||||
public string Action { get; init; } = "";
|
||||
|
||||
public string ExtraParams { get; init; } = "";
|
||||
|
||||
public string Part(int index)
|
||||
{
|
||||
if (index < 0 || index >= Parts.Length)
|
||||
@@ -35,29 +29,102 @@ namespace StandardScene.Signal.Logic
|
||||
return Parts[index] ?? "";
|
||||
}
|
||||
|
||||
/// <summary>从类型名和参数串构造;类型会 Trim,参数保持原样以便日志对照现场 JSON。</summary>
|
||||
public static SignalStopSpec Parse(string signalType, string signalParams)
|
||||
{
|
||||
var type = (signalType ?? "").Trim();
|
||||
var raw = signalParams ?? "";
|
||||
return new SignalStopSpec
|
||||
var parsed = new SignalStopSpec
|
||||
{
|
||||
SignalType = type,
|
||||
SignalParams = raw,
|
||||
Parts = raw.Split(new[] { ';' }, StringSplitOptions.None)
|
||||
};
|
||||
return EnrichFromParts(parsed);
|
||||
}
|
||||
|
||||
/// <summary>从握手点 Model 构造;model 为 null 时返回 null。</summary>
|
||||
public static SignalStopSpec From(HandshakePointModel model)
|
||||
{
|
||||
if (model == null)
|
||||
return null;
|
||||
return Parse(model.SignalType, model.SignalParams);
|
||||
|
||||
var type = (model.SignalType ?? "").Trim();
|
||||
if (string.Equals(type, "TimedCharging", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(type, "RateFlowControl", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var raw = First(model.ExtraParams, model.SignalParams);
|
||||
return Parse(type, raw);
|
||||
}
|
||||
|
||||
var parsed = Parse(type, model.SignalParams ?? "");
|
||||
var jg = First(model.JgName, parsed.JgName);
|
||||
var action = First(model.Action, parsed.Action);
|
||||
var dock = First(model.DockId, parsed.DockId);
|
||||
var extra = First(model.ExtraParams, parsed.ExtraParams);
|
||||
return new SignalStopSpec
|
||||
{
|
||||
SignalType = parsed.SignalType,
|
||||
SignalParams = parsed.SignalParams,
|
||||
Parts = parsed.Parts,
|
||||
JgName = jg,
|
||||
DockId = dock,
|
||||
Action = action,
|
||||
ExtraParams = extra
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>类型名是否匹配(忽略大小写)。</summary>
|
||||
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,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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ using StandardScene.Utils;
|
||||
namespace StandardScene.Signal
|
||||
{
|
||||
/// <summary>
|
||||
/// 第三方信号交互进程(场景 <c>scene.signal</c> 的唯一 Mission)。
|
||||
/// 第三方信号交互进程(场景 <c>scene.signal</c>:PLC 握手 / 放行;磁条交管见 <c>MagTrafficMission</c>)。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -65,9 +65,9 @@ namespace StandardScene.Signal
|
||||
|
||||
/// <summary>
|
||||
/// 西门子系列,传给 IoTClient <c>SiemensVersion</c>。
|
||||
/// 常用 <c>S7_1500</c> / <c>S7_1200</c> / <c>S7_300</c>;连字符会当成下划线解析。
|
||||
/// 机构表 PLC类型为「跟随进程」时使用本项;机构单独指定则用机构值。
|
||||
/// </summary>
|
||||
[FieldMember] public string PlcVersion = "S7_1500";
|
||||
[FieldMember] public SignalPlcType PlcVersion = SignalPlcType.S7_1500;
|
||||
|
||||
/// <summary>机构未单独填 Port 时使用的 S7 端口,默认 102。</summary>
|
||||
[FieldMember] public int PlcPort = 102;
|
||||
@@ -84,6 +84,9 @@ namespace StandardScene.Signal
|
||||
/// <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;
|
||||
|
||||
@@ -126,6 +129,12 @@ namespace StandardScene.Signal
|
||||
/// <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>();
|
||||
}
|
||||
@@ -170,23 +179,26 @@ namespace StandardScene.Signal
|
||||
public void ReloadConfig()
|
||||
{
|
||||
var stationsFile = SignalConfigStore.Resolve(StationsPath);
|
||||
var docksFile = SignalConfigStore.Resolve(DocksPath);
|
||||
var releaseFile = SignalConfigStore.Resolve(ReleasePointsPath);
|
||||
var handshakeFile = SignalConfigStore.Resolve(HandshakePointsPath);
|
||||
var stations = SignalConfigStore.Load<PlcStationModel>(stationsFile);
|
||||
var bundles = PlcConfigLoader.Load(stationsFile, docksFile);
|
||||
var points = SignalConfigStore.Load<ReleasePointModel>(releaseFile);
|
||||
var handshake = SignalConfigStore.Load<HandshakePointModel>(handshakeFile);
|
||||
|
||||
var st = (SignalInteractMissionStatus)status;
|
||||
st.StationCount = stations.Count;
|
||||
st.StationCount = bundles.Count;
|
||||
st.DockCount = bundles.Sum(b => b.Docks.Count);
|
||||
st.ReleasePointCount = points.Count;
|
||||
st.HandshakePointCount = handshake.Count;
|
||||
st.StationsFile = stationsFile;
|
||||
st.DocksFile = docksFile;
|
||||
st.ReleasePointsFile = releaseFile;
|
||||
st.HandshakePointsFile = handshakeFile;
|
||||
st.Stations = stations.Select(s => new PlcStationSnapshot { JgName = s.JgName }).ToList();
|
||||
st.Stations = bundles.Select(b => new PlcStationSnapshot { JgName = b.Station.JgName }).ToList();
|
||||
status.status = _started ? "运行中" : "配置已加载";
|
||||
Diagnosis.Post(
|
||||
$"信号配置已加载:机构 {stations.Count};握手点 {handshake.Count} @ {handshakeFile};放行点 {points.Count} @ {releaseFile}",
|
||||
$"信号配置已加载:机构 {bundles.Count},工位 {st.DockCount} @ {docksFile};握手点 {handshake.Count} @ {handshakeFile};放行点 {points.Count} @ {releaseFile}",
|
||||
"Signal", true);
|
||||
|
||||
if (_started)
|
||||
@@ -224,6 +236,24 @@ namespace StandardScene.Signal
|
||||
}
|
||||
}
|
||||
|
||||
/// <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()
|
||||
@@ -266,7 +296,7 @@ namespace StandardScene.Signal
|
||||
get
|
||||
{
|
||||
lock (_workerLock)
|
||||
return _workers.Select(w => w.Runtime).ToList();
|
||||
return _workers.SelectMany(w => w.Docks).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,25 +315,28 @@ namespace StandardScene.Signal
|
||||
|
||||
/// <summary>
|
||||
/// 先停全部旧连接,再按机构表为每个非空名称建 <see cref="PlcStationWorker"/>。
|
||||
/// 机构 Port<=0 时回退进程级 <see cref="PlcPort"/>。
|
||||
/// 机构 Port<=0 时回退进程级 <see cref="PlcPort"/>;PLC类型跟随进程时用 <see cref="PlcVersion"/>。
|
||||
/// </summary>
|
||||
private void RestartWorkers()
|
||||
{
|
||||
StopWorkers();
|
||||
var stationsFile = SignalConfigStore.Resolve(StationsPath);
|
||||
var stations = SignalConfigStore.Load<PlcStationModel>(stationsFile)
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s.JgName))
|
||||
.ToList();
|
||||
var bundles = PlcConfigLoader.Load(
|
||||
SignalConfigStore.Resolve(StationsPath),
|
||||
SignalConfigStore.Resolve(DocksPath));
|
||||
|
||||
var created = new List<PlcStationWorker>();
|
||||
lock (_workerLock)
|
||||
{
|
||||
foreach (var station in stations)
|
||||
foreach (var bundle in bundles)
|
||||
{
|
||||
var station = bundle.Station;
|
||||
var port = station.Port > 0 ? station.Port : PlcPort;
|
||||
var runtime = new PlcStationRuntime(station);
|
||||
var session = new SiemensPlcSession(PlcVersion, station.Ip, port, PlcSlot);
|
||||
var worker = new PlcStationWorker(runtime, session, PlcPollMs, RefreshStatus);
|
||||
var sync = new object();
|
||||
var runtimes = bundle.Docks
|
||||
.Select(d => new PlcStationRuntime(station, d, sync))
|
||||
.ToList();
|
||||
var session = new SiemensPlcSession(ResolvePlcType(station), station.Ip, port, PlcSlot);
|
||||
var worker = new PlcStationWorker(station, runtimes, session, sync, PlcPollMs, RefreshStatus);
|
||||
_workers.Add(worker);
|
||||
created.Add(worker);
|
||||
}
|
||||
@@ -315,6 +348,15 @@ namespace StandardScene.Signal
|
||||
PushStatus();
|
||||
}
|
||||
|
||||
/// <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()
|
||||
{
|
||||
@@ -345,7 +387,7 @@ namespace StandardScene.Signal
|
||||
{
|
||||
List<PlcStationSnapshot> snaps;
|
||||
lock (_workerLock)
|
||||
snaps = _workers.Select(w => w.Runtime.ToSnapshot()).ToList();
|
||||
snaps = _workers.SelectMany(w => w.Docks.Select(d => d.ToSnapshot())).ToList();
|
||||
|
||||
var st = (SignalInteractMissionStatus)status;
|
||||
st.Stations = snaps;
|
||||
|
||||
@@ -1,41 +1,47 @@
|
||||
using System.ComponentModel;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace StandardScene.Signal.Model
|
||||
{
|
||||
/// <summary>
|
||||
/// 握手点配置。对应老地图点上的 <c>SignalStop</c>(SignalType + SignalParams)。
|
||||
/// 握手点。JGControl / PointControl 填机构、工位、动作;定时充电 / 限流走附加参数。
|
||||
/// 旧 JSON 仅有 SignalParams 时仍可解析。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 站点 fields 若已填写同名键,扫车优先用站点字段,本表该站点不再生效。
|
||||
/// SignalParams 按分号切分,各类型格式见 <see cref="StandardScene.Signal.Logic.SignalStopSpec"/>。
|
||||
/// 列表主键展示为「站点号 + 类型」,同一站点建议只配一条。
|
||||
/// </remarks>
|
||||
[SignalTable("handshake", "handshake-points.json", "握手点", Order = 3)]
|
||||
[Category("握手点数据管理")]
|
||||
[DisplayName("握手点配置列表")]
|
||||
public class HandshakePointModel
|
||||
{
|
||||
/// <summary>地图站点号,与车 PositionId(siteID / GetLastSite)对应。</summary>
|
||||
[DisplayName("站点")]
|
||||
public int Sit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 握手类型:<c>JGControl</c> 机构进出、
|
||||
/// <c>PointControl</c> 销钉升降、
|
||||
/// <c>TimedCharging</c> 定时充电、
|
||||
/// <c>RateFlowControl</c> 区域限流。
|
||||
/// 其它字符串会每 10s 打一次「未找到业务模块」日志。
|
||||
/// </summary>
|
||||
/// <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>
|
||||
/// 类型参数。示例:
|
||||
/// JGControl <c>上线机构1;RequestIn</c> / <c>上线机构1;Arrived</c> / 第三段限流 <c>缓存区-A-2-60,61</c>;
|
||||
/// PointControl <c>UP1;下线机构1</c>;
|
||||
/// TimedCharging <c>300;95;20</c>;
|
||||
/// RateFlowControl <c>缓存区;A;2;60,61,62</c>。
|
||||
/// TimedCharging:最长秒;上限电量;下限电量。
|
||||
/// RateFlowControl:区域;车组;限额;点列表。
|
||||
/// JGControl 可选限流:区域-车组-限额-点列表。
|
||||
/// </summary>
|
||||
[DisplayName("信号参数")]
|
||||
public string SignalParams { get; set; } = "";
|
||||
[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();
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
using System.ComponentModel;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace StandardScene.Signal.Model
|
||||
{
|
||||
/// <summary>
|
||||
/// PLC 机构静态配置,对应反编译 <c>PLCModel</c> 可存档字段。
|
||||
/// 读写运行态(当前字节、机构内车号)在 <see cref="StandardScene.Signal.Plc.PlcStationRuntime"/>,不进本类、不进 JSON。
|
||||
/// 机构名称不能重复,列表编辑器用 JgName 做主键。
|
||||
/// PLC 机构:一把 S7 连接 + 一块 DB。点位和地标在 <see cref="PlcStationDockModel"/>。
|
||||
/// </summary>
|
||||
[SignalTable("stations", "stations.json", "PLC机构", Order = 1)]
|
||||
[Category("PLC数据管理")]
|
||||
[DisplayName("PLC配置列表(机构名称不能重复)")]
|
||||
public class PlcStationModel
|
||||
{
|
||||
/// <summary>逻辑名,握手点 SignalParams 第一段、Worker 线程名都用它。不能空、不能重复。</summary>
|
||||
/// <summary>逻辑名,握手点机构名、Worker 线程名都用它。不能空、不能重复。</summary>
|
||||
[DisplayName("机构名称")]
|
||||
public string JgName { get; set; } = "";
|
||||
|
||||
@@ -23,53 +23,55 @@ namespace StandardScene.Signal.Model
|
||||
[DisplayName("端口")]
|
||||
public int Port { get; set; } = 102;
|
||||
|
||||
/// <summary>
|
||||
/// 读字节地址(PLC→调度:允许进入/离开)。
|
||||
/// 支持 <c>DB1.DBB0</c> 或 Hsl 风格 <c>DB1.0</c>。空则跳过读取。
|
||||
/// </summary>
|
||||
[DisplayName("读地址")]
|
||||
public string ReadDb { get; set; } = "";
|
||||
/// <summary>西门子系列。跟随进程则用信号交互进程的 PlcVersion。</summary>
|
||||
[DisplayName("PLC类型")]
|
||||
public SignalPlcType PlcType { get; set; } = SignalPlcType.跟随进程;
|
||||
|
||||
/// <summary>
|
||||
/// 写字节地址(调度→PLC:进入中/到位/离开中/已离开)。
|
||||
/// Worker 会先回读再按需写入。空则跳过写和回读。
|
||||
/// </summary>
|
||||
[DisplayName("写地址")]
|
||||
public string WriteDb { get; set; } = "";
|
||||
/// <summary>DB 块号。工位 BOOL 与心跳都拼到这块 DB。</summary>
|
||||
[DisplayName("DB块号")]
|
||||
public int DbNumber { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// 心跳位地址,如 <c>DB1.DBX2.0</c>。
|
||||
/// Worker 每拍读当前值再写反码,让 PLC 判断调度进程存活。空则不做心跳。
|
||||
/// </summary>
|
||||
[DisplayName("写心跳地址")]
|
||||
public string HeartDb { get; set; } = "";
|
||||
/// <summary>心跳字节偏移。未填 HeartDb 时拼 DB{n}.DBX{HeartByte}.{HeartBit}。</summary>
|
||||
[DisplayName("心跳字节")]
|
||||
public int HeartByte { get; set; } = 32;
|
||||
|
||||
/// <summary>机构进入地标(预留,与老 PLCModel 字段对齐;上线跟车主要用 JgInContains)。</summary>
|
||||
[DisplayName("机构进入地标")]
|
||||
public int JgInPointId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 进入过程包含的站点列表(逗号/分号/空格分隔)。
|
||||
/// 上线:写字节=进入中 且车已不在此列表 → 视为到位。
|
||||
/// </summary>
|
||||
[DisplayName("机构进入包含地标")]
|
||||
public string JgInContains { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 机构工作地标。下线 RequestIn 用它查「机构内是否已有车」;
|
||||
/// 下线/等待通行离站时,车必须停在此点才响应允许离开。
|
||||
/// </summary>
|
||||
[DisplayName("机构地标")]
|
||||
public int JgPointId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 机构占用包含的站点列表。车已不在此列表且写字节还不是已离开 → 写已离开。
|
||||
/// </summary>
|
||||
[DisplayName("机构包含地标")]
|
||||
public string JgContains { get; set; } = "";
|
||||
/// <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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ namespace StandardScene.Signal.Model
|
||||
/// 同一站点不要同时配握手点:扫车先握手再放行,可能把还在等允许进入的车开走。
|
||||
/// 站点号不能重复,列表编辑器用 Sit 做主键。
|
||||
/// </remarks>
|
||||
[SignalTable("release", "release-points.json", "放行点", Order = 4)]
|
||||
[Category("放行点数据管理")]
|
||||
[DisplayName("放行点配置列表")]
|
||||
public class ReleasePointModel
|
||||
|
||||
@@ -38,6 +38,34 @@ namespace StandardScene.Signal.Model
|
||||
已离开 = 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 要不要查机构内是否有车。
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -48,8 +48,8 @@ namespace StandardScene.Signal.Plc
|
||||
/// <summary>按 <see cref="PlcStationModel.LineType"/> 分发四套离站逻辑;未知类型不处理。</summary>
|
||||
public static void AutoLeave(PlcStationRuntime rt)
|
||||
{
|
||||
if (rt?.Config == null) return;
|
||||
switch (rt.Config.LineType)
|
||||
if (rt?.Station == null || rt.Dock == null) return;
|
||||
switch (rt.Station.LineType)
|
||||
{
|
||||
case SignalLineType.上线机构:
|
||||
AutoLeaveUpLine(rt);
|
||||
@@ -77,12 +77,12 @@ namespace StandardScene.Signal.Plc
|
||||
if (rt.RequestInAgvNo > 0)
|
||||
{
|
||||
Diagnosis.Log(
|
||||
$"{rt.Config.JgName} {(fromReadback ? "回读已离开" : "写已离开成功")},清空请求进入车 {rt.RequestInAgvNo}",
|
||||
$"{rt.DisplayName} {(fromReadback ? "回读已离开" : "写已离开成功")},清空请求进入车 {rt.RequestInAgvNo}",
|
||||
"JGControl", true);
|
||||
rt.RequestInAgvNo = 0;
|
||||
}
|
||||
else
|
||||
Diagnosis.Log($"{rt.Config.JgName} 已离开,清空信号", "JGControl", true);
|
||||
Diagnosis.Log($"{rt.DisplayName} 已离开,清空信号", "JGControl", true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -102,12 +102,12 @@ namespace StandardScene.Signal.Plc
|
||||
{
|
||||
var car = SignalCarAdapter.Find(rt.RequestInAgvNo);
|
||||
if (car == null) return;
|
||||
if (!ContainsSite(rt.Config.JgInContains, SignalCarAdapter.PositionId(car)))
|
||||
if (!ContainsSite(rt.Dock.JgInContains, SignalCarAdapter.PositionId(car)))
|
||||
{
|
||||
rt.WriteSignal = SignalWriteValue.到位;
|
||||
rt.InAgvNo = car.id;
|
||||
rt.RequestInAgvNo = 0;
|
||||
Diagnosis.Log($"AGV{car.id} 已到达机构 {rt.Config.JgName}", "JGControl", true);
|
||||
Diagnosis.Log($"AGV{car.id} 已到达机构 {rt.DisplayName}", "JGControl", true);
|
||||
}
|
||||
}
|
||||
else if (rt.WriteSuccess && rt.WriteSignal == SignalWriteValue.到位)
|
||||
@@ -119,7 +119,7 @@ namespace StandardScene.Signal.Plc
|
||||
else if (rt.WriteSuccess && rt.WriteSignal == SignalWriteValue.离开中)
|
||||
{
|
||||
var car = SignalCarAdapter.Find(rt.InAgvNo);
|
||||
if (car == null || !ContainsSite(rt.Config.JgContains, SignalCarAdapter.PositionId(car)))
|
||||
if (car == null || !ContainsSite(rt.Dock.JgContains, SignalCarAdapter.PositionId(car)))
|
||||
rt.WriteSignal = SignalWriteValue.已离开;
|
||||
}
|
||||
}
|
||||
@@ -145,7 +145,7 @@ namespace StandardScene.Signal.Plc
|
||||
if (car == null) return;
|
||||
|
||||
var pos = SignalCarAdapter.PositionId(car);
|
||||
if (rt.ReadSignal == SignalReadValue.允许离开 && pos == rt.Config.JgPointId)
|
||||
if (rt.ReadSignal == SignalReadValue.允许离开 && pos == rt.Dock.JgPointId)
|
||||
{
|
||||
if (rt.WriteSignal == SignalWriteValue.离开中) return;
|
||||
var pinOk = SignalCarAdapter.IsActionFinish(car, "SetPin1Up")
|
||||
@@ -156,7 +156,7 @@ namespace StandardScene.Signal.Plc
|
||||
SignalCarAdapter.StopCharge(car);
|
||||
SignalCarAdapter.Start(car);
|
||||
rt.WriteSignal = SignalWriteValue.离开中;
|
||||
Diagnosis.Log($"{rt.Config.JgName} 允许离开,通知 AGV{car.id} 启动,写离开中", "JGControl", true);
|
||||
Diagnosis.Log($"{rt.DisplayName} 允许离开,通知 AGV{car.id} 启动,写离开中", "JGControl", true);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -165,8 +165,8 @@ namespace StandardScene.Signal.Plc
|
||||
SignalCarAdapter.Control(car, "StopCharge");
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(rt.Config.JgContains)
|
||||
&& !ContainsSite(rt.Config.JgContains, pos)
|
||||
else if (!string.IsNullOrEmpty(rt.Dock.JgContains)
|
||||
&& !ContainsSite(rt.Dock.JgContains, pos)
|
||||
&& rt.ReadWriteSignal != SignalWriteValue.已离开)
|
||||
{
|
||||
rt.WriteSignal = SignalWriteValue.已离开;
|
||||
@@ -193,20 +193,20 @@ namespace StandardScene.Signal.Plc
|
||||
if (car == null) return;
|
||||
|
||||
var pos = SignalCarAdapter.PositionId(car);
|
||||
if (rt.ReadSignal == SignalReadValue.允许离开 && pos == rt.Config.JgPointId)
|
||||
if (rt.ReadSignal == SignalReadValue.允许离开 && pos == rt.Dock.JgPointId)
|
||||
{
|
||||
if (rt.WriteSignal == SignalWriteValue.离开中) return;
|
||||
if (SignalCarAdapter.IsActionFinish(car, "StopCharge"))
|
||||
{
|
||||
SignalCarAdapter.Start(car);
|
||||
rt.WriteSignal = SignalWriteValue.离开中;
|
||||
Diagnosis.Log($"{rt.Config.JgName} 允许离开,通知 AGV{car.id} 启动,写离开中", "JGControl", true);
|
||||
Diagnosis.Log($"{rt.DisplayName} 允许离开,通知 AGV{car.id} 启动,写离开中", "JGControl", true);
|
||||
}
|
||||
else
|
||||
SignalCarAdapter.Control(car, "StopCharge");
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(rt.Config.JgContains)
|
||||
&& !ContainsSite(rt.Config.JgContains, pos)
|
||||
else if (!string.IsNullOrEmpty(rt.Dock.JgContains)
|
||||
&& !ContainsSite(rt.Dock.JgContains, pos)
|
||||
&& rt.ReadWriteSignal != SignalWriteValue.已离开)
|
||||
{
|
||||
rt.WriteSignal = SignalWriteValue.已离开;
|
||||
|
||||
@@ -5,91 +5,88 @@ using StandardScene.Signal.Model;
|
||||
namespace StandardScene.Signal.Plc
|
||||
{
|
||||
/// <summary>
|
||||
/// 单个机构的运行态(不进 JSON)。扫车线程改写目标写字节,PLC Worker 负责真正下发。
|
||||
/// 单个工位运行态(不进 JSON)。扫车改写目标写状态,PLC Worker 负责下发 BOOL(或旧一字节枚举)。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 赋值 <see cref="WriteSignal"/> 会把 <see cref="WriteSuccess"/> 置 false,下一拍 Worker 发现
|
||||
/// 目标与回读不一致就会重写 PLC。回读与目标一致后再标成功,AutoLeave 才继续往下一状态走。
|
||||
/// 所有读写应在 <see cref="Sync"/> 内进行。
|
||||
/// </remarks>
|
||||
public sealed class PlcStationRuntime
|
||||
{
|
||||
private SignalWriteValue _writeSignal;
|
||||
private SignalReadValue _readSignal;
|
||||
private SignalWriteValue _readWriteSignal;
|
||||
|
||||
public PlcStationRuntime(PlcStationModel config)
|
||||
public PlcStationRuntime(PlcStationModel station, PlcStationDockModel dock, object sync = null)
|
||||
{
|
||||
Config = config ?? throw new ArgumentNullException(nameof(config));
|
||||
Station = station ?? throw new ArgumentNullException(nameof(station));
|
||||
Dock = dock ?? throw new ArgumentNullException(nameof(dock));
|
||||
Sync = sync ?? new object();
|
||||
}
|
||||
|
||||
/// <summary>本机构静态配置(IP、DB 地址、地标、机构类型)。</summary>
|
||||
public PlcStationModel Config { get; }
|
||||
public PlcStationModel Station { get; }
|
||||
|
||||
/// <summary>扫车线程与 PLC Worker 共用运行态,写入时加此锁。</summary>
|
||||
public object Sync { get; } = new object();
|
||||
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}";
|
||||
|
||||
/// <summary>PLC→调度:允许进入 / 允许离开 / 无状态。变化时打诊断。</summary>
|
||||
public SignalReadValue ReadSignal
|
||||
{
|
||||
get => _readSignal;
|
||||
set
|
||||
{
|
||||
if (_readSignal != value)
|
||||
Diagnosis.Log($"{Config.JgName} 读信号 {_readSignal} → {value}", "PLC", true);
|
||||
Diagnosis.Log($"{DisplayName} 读信号 {_readSignal} → {value}", "PLC", true);
|
||||
_readSignal = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 调度→PLC 的目标写字节。赋值即视为尚未写成功,驱动 Worker 重写。
|
||||
/// </summary>
|
||||
public SignalWriteValue WriteSignal
|
||||
{
|
||||
get => _writeSignal;
|
||||
set
|
||||
{
|
||||
if (_writeSignal != value)
|
||||
Diagnosis.Log($"{Config.JgName} 写信号 {_writeSignal} → {value}", "PLC", true);
|
||||
Diagnosis.Log($"{DisplayName} 写信号 {_writeSignal} → {value}", "PLC", true);
|
||||
_writeSignal = value;
|
||||
WriteSuccess = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>从写地址回读到的当前值,用来确认 PLC 是否已吃进目标字节。</summary>
|
||||
public SignalWriteValue ReadWriteSignal
|
||||
{
|
||||
get => _readWriteSignal;
|
||||
set
|
||||
{
|
||||
if (_readWriteSignal != value)
|
||||
Diagnosis.Log($"{Config.JgName} 回读写值 {_readWriteSignal} → {value}", "PLC", true);
|
||||
Diagnosis.Log($"{DisplayName} 回读写值 {_readWriteSignal} → {value}", "PLC", true);
|
||||
_readWriteSignal = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>已报到在机构内的 AGV 号;0 表示没有。Arrived / 上线自动到位 时写入。</summary>
|
||||
public int InAgvNo { get; set; }
|
||||
|
||||
/// <summary>已获准进入、正在往机构开的 AGV 号;到位或离站清空后为 0。</summary>
|
||||
public int RequestInAgvNo { get; set; }
|
||||
|
||||
/// <summary>最近一拍 PLC 读写是否成功。心跳或读写失败会立刻变 false。</summary>
|
||||
public bool IsConnected { get; set; }
|
||||
|
||||
/// <summary>目标写字节是否已与 PLC 回读一致。改 WriteSignal 时自动清掉。</summary>
|
||||
public bool WriteSuccess { get; set; }
|
||||
|
||||
/// <summary>内存侧心跳翻转标志,供监视面板看到 Worker 在跑;真正写 PLC 的是 HeartDb 位翻转。</summary>
|
||||
public bool Heart { get; set; }
|
||||
|
||||
/// <summary>最近一次断线或周期异常原因,连接正常时清空。</summary>
|
||||
public string LastError { get; set; } = "";
|
||||
|
||||
/// <summary>拷贝一份给 MissionStatus / 监视列表,避免 UI 直接绑运行态。</summary>
|
||||
public PlcStationSnapshot ToSnapshot() => new PlcStationSnapshot
|
||||
{
|
||||
JgName = Config.JgName,
|
||||
JgName = DisplayName,
|
||||
IsConnected = IsConnected,
|
||||
ReadSignal = ReadSignal.ToString(),
|
||||
WriteSignal = WriteSignal.ToString(),
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using SimpleCore.Library;
|
||||
using StandardScene.Signal.Model;
|
||||
@@ -6,41 +8,43 @@ using StandardScene.Signal.Model;
|
||||
namespace StandardScene.Signal.Plc
|
||||
{
|
||||
/// <summary>
|
||||
/// 单机构 200ms 循环:心跳、读允许进出、回读写值、按需写入、自动离站。
|
||||
/// 对齐反编译 <c>PLCManager.ExcuteThreadMethod</c>。
|
||||
/// 单机构 200ms 循环:心跳、按工位读 BOOL(或旧一字节)、回写变化位、自动离站。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 一拍顺序固定,失败则本拍标断线并 return,避免用脏数据推进状态机:
|
||||
/// 1. 翻转内存心跳标志;若配置了 HeartDb,读当前位再写反码(PLC 侧看翻转判断调度活着)。
|
||||
/// 2. 读 ReadDb → <see cref="PlcStationRuntime.ReadSignal"/>。
|
||||
/// 3. 回读 WriteDb → ReadWriteSignal;与目标 WriteSignal 一致则 WriteSuccess=true。
|
||||
/// 4. 目标与回读不一致且尚未成功时写入目标字节。
|
||||
/// 5. <see cref="PlcStationLogic.AutoLeave"/>。
|
||||
/// 读写均在 <see cref="PlcStationRuntime.Sync"/> 内,与扫车 Handler 互斥。
|
||||
/// </remarks>
|
||||
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;
|
||||
|
||||
/// <summary>断线日志节流起点,避免 200ms 打满诊断。</summary>
|
||||
private DateTime _lastFailLog = DateTime.MinValue;
|
||||
|
||||
public PlcStationWorker(PlcStationRuntime runtime, SiemensPlcSession session, int pollMs, Action onTick = null)
|
||||
public PlcStationWorker(
|
||||
PlcStationModel station,
|
||||
IEnumerable<PlcStationRuntime> docks,
|
||||
SiemensPlcSession session,
|
||||
object sync,
|
||||
int pollMs,
|
||||
Action onTick = null)
|
||||
{
|
||||
Runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
||||
_station = station ?? throw new ArgumentNullException(nameof(station));
|
||||
_docks = (docks ?? Array.Empty<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;
|
||||
}
|
||||
|
||||
/// <summary>本机构运行态,扫车线程按 JgName 查找后加 Sync 读写。</summary>
|
||||
public PlcStationRuntime Runtime { get; }
|
||||
public object Sync { get; }
|
||||
|
||||
public IReadOnlyList<PlcStationRuntime> Docks => _docks;
|
||||
|
||||
public PlcStationRuntime Runtime => _docks.Count > 0 ? _docks[0] : null;
|
||||
|
||||
/// <summary>启动后台线程。重复 Start 若仍存活则忽略。</summary>
|
||||
public void Start()
|
||||
{
|
||||
if (_thread != null && _thread.IsAlive) return;
|
||||
@@ -48,13 +52,12 @@ namespace StandardScene.Signal.Plc
|
||||
_thread = new Thread(Loop)
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = $"PlcStation:{Runtime.Config.JgName}"
|
||||
Name = $"PlcStation:{_station.JgName}"
|
||||
};
|
||||
_thread.Start();
|
||||
Diagnosis.Log($"{Runtime.Config.JgName} Worker 已启动 → {Runtime.Config.Ip}:{Runtime.Config.Port}", "PLC", true);
|
||||
Diagnosis.Log($"{_station.JgName} Worker 已启动 → {_station.Ip}:{_station.Port} 工位{_docks.Count}", "PLC", true);
|
||||
}
|
||||
|
||||
/// <summary>取消循环、最多等 2s、关闭 S7 连接并标断线。</summary>
|
||||
public void Stop()
|
||||
{
|
||||
try { _cts?.Cancel(); }
|
||||
@@ -63,8 +66,9 @@ namespace StandardScene.Signal.Plc
|
||||
catch { }
|
||||
_thread = null;
|
||||
_session.Close();
|
||||
Runtime.IsConnected = false;
|
||||
Diagnosis.Log($"{Runtime.Config.JgName} Worker 已停止", "PLC", true);
|
||||
foreach (var d in _docks)
|
||||
d.IsConnected = false;
|
||||
Diagnosis.Log($"{_station.JgName} Worker 已停止", "PLC", true);
|
||||
}
|
||||
|
||||
public void Dispose() => Stop();
|
||||
@@ -76,14 +80,17 @@ namespace StandardScene.Signal.Plc
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (Runtime.Sync)
|
||||
lock (Sync)
|
||||
Tick();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Runtime.LastError = ex.Message;
|
||||
Runtime.IsConnected = false;
|
||||
LogFail($"{Runtime.Config.JgName} 周期异常 {ex.Message}");
|
||||
foreach (var d in _docks)
|
||||
{
|
||||
d.LastError = ex.Message;
|
||||
d.IsConnected = false;
|
||||
}
|
||||
LogFail($"{_station.JgName} 周期异常 {ex.Message}");
|
||||
}
|
||||
|
||||
try { _onTick?.Invoke(); }
|
||||
@@ -94,17 +101,18 @@ namespace StandardScene.Signal.Plc
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>单拍 PLC 交互。心跳地址为空则跳过心跳;读写地址为空则跳过对应步骤。</summary>
|
||||
internal void Tick()
|
||||
{
|
||||
var cfg = Runtime.Config;
|
||||
Runtime.Heart = !Runtime.Heart;
|
||||
var heart = !_docks.Select(d => d.Heart).FirstOrDefault();
|
||||
foreach (var d in _docks)
|
||||
d.Heart = heart;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(cfg.HeartDb))
|
||||
var heartAddr = PlcConfigLoader.ResolveHeartAddress(_station);
|
||||
if (!string.IsNullOrWhiteSpace(heartAddr))
|
||||
{
|
||||
if (_session.TryReadBool(cfg.HeartDb, out var bit))
|
||||
if (_session.TryReadBool(heartAddr, out var bit))
|
||||
{
|
||||
_session.TryWriteBool(cfg.HeartDb, !bit);
|
||||
_session.TryWriteBool(heartAddr, !bit);
|
||||
Thread.Sleep(20);
|
||||
}
|
||||
else
|
||||
@@ -114,56 +122,183 @@ namespace StandardScene.Signal.Plc
|
||||
}
|
||||
}
|
||||
|
||||
var boolDocks = _docks.Where(d => !d.Dock.UseByteIo).ToList();
|
||||
var byteDocks = _docks.Where(d => d.Dock.UseByteIo).ToList();
|
||||
|
||||
if (boolDocks.Count > 0 && !TickBoolDocks(boolDocks))
|
||||
return;
|
||||
|
||||
foreach (var rt in byteDocks)
|
||||
{
|
||||
if (!TickByteDock(rt))
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var rt in _docks)
|
||||
{
|
||||
rt.IsConnected = true;
|
||||
rt.LastError = "";
|
||||
PlcStationLogic.AutoLeave(rt);
|
||||
}
|
||||
}
|
||||
|
||||
private bool TickBoolDocks(List<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))
|
||||
Runtime.ReadSignal = PlcStationLogic.ConvertRead(rb);
|
||||
rt.ReadSignal = PlcStationLogic.ConvertRead(rb);
|
||||
else
|
||||
{
|
||||
MarkDisconnected("读地址失败");
|
||||
return;
|
||||
MarkDisconnected($"{rt.DisplayName} 读地址失败");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(cfg.WriteDb))
|
||||
if (string.IsNullOrWhiteSpace(cfg.WriteDb))
|
||||
return true;
|
||||
|
||||
if (!_session.TryReadByte(cfg.WriteDb, out var wb))
|
||||
{
|
||||
if (_session.TryReadByte(cfg.WriteDb, out var wb))
|
||||
{
|
||||
Runtime.ReadWriteSignal = PlcStationLogic.ConvertWrite(wb);
|
||||
if (Runtime.ReadWriteSignal == Runtime.WriteSignal)
|
||||
Runtime.WriteSuccess = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
MarkDisconnected("写地址回读失败");
|
||||
return;
|
||||
MarkDisconnected($"{rt.DisplayName} 写地址回读失败");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Runtime.WriteSignal != Runtime.ReadWriteSignal && !Runtime.WriteSuccess)
|
||||
rt.ReadWriteSignal = PlcStationLogic.ConvertWrite(wb);
|
||||
if (rt.ReadWriteSignal == rt.WriteSignal)
|
||||
rt.WriteSuccess = true;
|
||||
|
||||
if (rt.WriteSignal != rt.ReadWriteSignal && !rt.WriteSuccess)
|
||||
{
|
||||
Diagnosis.Log(
|
||||
$"AGV→机构 {cfg.JgName} 写入【{Runtime.WriteSignal}】请求进入{Runtime.RequestInAgvNo} 到达{Runtime.InAgvNo}",
|
||||
$"AGV→机构 {rt.DisplayName} 写入【{rt.WriteSignal}】请求进入{rt.RequestInAgvNo} 到达{rt.InAgvNo}",
|
||||
"JGControl", true);
|
||||
_session.TryWriteByte(cfg.WriteDb, (byte)Runtime.WriteSignal);
|
||||
}
|
||||
_session.TryWriteByte(cfg.WriteDb, (byte)rt.WriteSignal);
|
||||
}
|
||||
|
||||
Runtime.IsConnected = true;
|
||||
Runtime.LastError = "";
|
||||
PlcStationLogic.AutoLeave(Runtime);
|
||||
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)
|
||||
{
|
||||
Runtime.IsConnected = false;
|
||||
Runtime.LastError = reason;
|
||||
LogFail($"{Runtime.Config.JgName} {reason}");
|
||||
foreach (var d in _docks)
|
||||
{
|
||||
d.IsConnected = false;
|
||||
d.LastError = reason;
|
||||
}
|
||||
LogFail($"{_station.JgName} {reason}");
|
||||
}
|
||||
|
||||
/// <summary>同一机构失败日志最少间隔 5 秒。</summary>
|
||||
private void LogFail(string msg)
|
||||
{
|
||||
if ((DateTime.Now - _lastFailLog).TotalSeconds < 5) return;
|
||||
if ((DateTime.Now - _lastFailLog).TotalSeconds < 5)
|
||||
return;
|
||||
_lastFailLog = DateTime.Now;
|
||||
Diagnosis.Log(msg, "PLC", true);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ namespace StandardScene.Signal.Plc
|
||||
private SiemensClient _client;
|
||||
private readonly object _sync = new object();
|
||||
|
||||
/// <param name="version">进程字段 PlcVersion,如 S7_1500。</param>
|
||||
/// <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>
|
||||
@@ -103,6 +103,26 @@ namespace StandardScene.Signal.Plc
|
||||
}
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
|
||||
@@ -224,6 +224,13 @@ namespace StandardScene.Signal.Plc
|
||||
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)
|
||||
{
|
||||
@@ -233,6 +240,15 @@ namespace StandardScene.Signal.Plc
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -7,7 +7,7 @@ using Newtonsoft.Json.Converters;
|
||||
namespace StandardScene.Signal
|
||||
{
|
||||
/// <summary>
|
||||
/// 信号插件三张 JSON 的路径解析与读写。
|
||||
/// 信号插件 JSON 的路径解析与读写。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 只使用 SimpleLite 工作目录 <c>Config/Signal/*.json</c>,不读、不拷 <c>plugins/Config/Signal</c>。
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="StandardScene.Signal.scene.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
<!-- 样例 JSON 只留源码树,不复制到输出/plugins;运行时读写工作目录 Config/Signal -->
|
||||
<None Update="Config\Signal\signal-tables.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
<!-- 业务 JSON 样例只留源码树,不复制到输出/plugins;运行时读写工作目录 Config/Signal -->
|
||||
<None Update="Config\Signal\*.json" CopyToOutputDirectory="Never" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -5,6 +5,6 @@
|
||||
"coreVersion": ">=1.0.0",
|
||||
"requiresCore": "StandardScene.dll",
|
||||
"provides": {
|
||||
"missionTypes": [ "SignalInteractMission" ]
|
||||
"missionTypes": [ "SignalInteractMission", "MagTrafficMission" ]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandardScene.Magnetic.Test
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandardScene.Signal", "StandardScene.Signal\StandardScene.Signal.csproj", "{7E5050AA-0009-4A11-9C22-0A0B0C0D0E09}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandardScene.MagCarSimulator", "StandardScene.MagCarSimulator\StandardScene.MagCarSimulator.csproj", "{7E5050AA-000A-4A11-9C22-0A0B0C0D0E0A}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -125,6 +127,18 @@ Global
|
||||
{7E5050AA-0009-4A11-9C22-0A0B0C0D0E09}.Release|x64.Build.0 = Release|Any CPU
|
||||
{7E5050AA-0009-4A11-9C22-0A0B0C0D0E09}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{7E5050AA-0009-4A11-9C22-0A0B0C0D0E09}.Release|x86.Build.0 = Release|Any CPU
|
||||
{7E5050AA-000A-4A11-9C22-0A0B0C0D0E0A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{7E5050AA-000A-4A11-9C22-0A0B0C0D0E0A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{7E5050AA-000A-4A11-9C22-0A0B0C0D0E0A}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{7E5050AA-000A-4A11-9C22-0A0B0C0D0E0A}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{7E5050AA-000A-4A11-9C22-0A0B0C0D0E0A}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{7E5050AA-000A-4A11-9C22-0A0B0C0D0E0A}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{7E5050AA-000A-4A11-9C22-0A0B0C0D0E0A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{7E5050AA-000A-4A11-9C22-0A0B0C0D0E0A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{7E5050AA-000A-4A11-9C22-0A0B0C0D0E0A}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{7E5050AA-000A-4A11-9C22-0A0B0C0D0E0A}.Release|x64.Build.0 = Release|Any CPU
|
||||
{7E5050AA-000A-4A11-9C22-0A0B0C0D0E0A}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{7E5050AA-000A-4A11-9C22-0A0B0C0D0E0A}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
Reference in New Issue
Block a user