磁导航1.0内部交管和信号交互

This commit is contained in:
ykkokluo
2026-08-24 14:55:35 +08:00
parent cacc5fea5c
commit 244b0af6c8
60 changed files with 6395 additions and 294 deletions
+15
View File
@@ -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>
+355
View File
@@ -0,0 +1,355 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>磁条交管逻辑与策略说明</title>
<style>
@page { size: A4; margin: 18mm 16mm 20mm 16mm; }
* { box-sizing: border-box; }
body {
font-family: "Microsoft YaHei", "PingFang SC", "SimSun", sans-serif;
font-size: 11pt;
line-height: 1.65;
color: #1a1a1a;
margin: 0;
}
h1 { font-size: 22pt; margin: 0 0 8px; letter-spacing: 0.5px; }
h2 {
font-size: 15pt;
margin: 28px 0 10px;
padding-bottom: 4px;
border-bottom: 2px solid #1f4e79;
color: #1f4e79;
page-break-after: avoid;
}
h3 {
font-size: 12.5pt;
margin: 18px 0 8px;
color: #2e75b6;
page-break-after: avoid;
}
h4 { font-size: 11.5pt; margin: 14px 0 6px; color: #333; }
p { margin: 6px 0 10px; text-align: justify; }
.cover {
text-align: center;
padding: 72px 24px 40px;
page-break-after: always;
}
.cover .sub { color: #555; font-size: 13pt; margin-top: 12px; }
.meta { margin-top: 64px; color: #444; font-size: 11pt; line-height: 1.9; }
.badge {
display: inline-block;
border: 1px solid #1f4e79;
color: #1f4e79;
padding: 2px 10px;
font-size: 10pt;
border-radius: 3px;
}
table {
width: 100%;
border-collapse: collapse;
margin: 8px 0 14px;
font-size: 10pt;
page-break-inside: avoid;
}
th, td {
border: 1px solid #b4b4b4;
padding: 6px 8px;
vertical-align: top;
}
th { background: #1f4e79; color: #fff; font-weight: 600; }
tr:nth-child(even) td { background: #f4f7fb; }
code, .mono {
font-family: Consolas, "Courier New", monospace;
font-size: 9.5pt;
background: #f3f3f3;
padding: 0 4px;
}
pre {
background: #f6f6f6;
border: 1px solid #ddd;
padding: 10px 12px;
font-size: 9pt;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-all;
page-break-inside: avoid;
}
ol, ul { margin: 6px 0 12px; padding-left: 22px; }
li { margin: 3px 0; }
.note {
background: #fff8e6;
border-left: 4px solid #e6a23c;
padding: 8px 12px;
margin: 10px 0 14px;
}
.ok {
background: #eef8f0;
border-left: 4px solid #67c23a;
padding: 8px 12px;
margin: 10px 0 14px;
}
.warn {
background: #fdeeee;
border-left: 4px solid #c0392b;
padding: 8px 12px;
margin: 10px 0 14px;
}
.flow {
background: #f7fafc;
border: 1px solid #d0d7de;
padding: 10px 14px;
margin: 8px 0 14px;
page-break-inside: avoid;
}
.toc a { color: #1f4e79; text-decoration: none; }
.toc li { margin: 5px 0; }
.small { font-size: 9.5pt; color: #555; }
.footer-note { margin-top: 28px; color: #777; font-size: 9.5pt; }
</style>
</head>
<body>
<div class="cover">
<p class="badge">FASS 2.0 / scene.signal</p>
<h1>磁条交管逻辑与策略说明</h1>
<p class="sub">MagCar 管控区排队、兼容组并行与扫车放行</p>
<div class="meta">
适用对象:现场实施、调度配置、软件维护<br />
配置文件:SimpleLite 工作目录 <span class="mono">Config/Signal/mag-control-areas.json</span><br />
进程名称:磁条交管进程(MagTrafficMission<br />
文档日期:2026-08-18
</div>
</div>
<h2>1. 文档目的与适用范围</h2>
<p>本文说明 FASS 2.0 磁导航场景中,<b>磁条交管进程</b>如何根据管控区配置表对 <b>MagCar</b>(FASS 1.0 磁条车)进行停车等待、排队和放行。读者按本文即可完成十字路口、汇合点、单入口限流的配表,并理解扫车每一拍在做什么。</p>
<h3>1.1 适用</h3>
<ul>
<li>车型仅 <b>MagCar</b>。类型名必须恰好为 MagCar。</li>
<li>车在配置的<b>管控触发站点</b>停稳后,由本进程发 FASS 1.0 启动指令(<span class="mono">StartMagCar</span>)。</li>
<li>配置表由迷毂数据中心「磁条管控区」或进程内「打开管控区列表」维护,保存到上述 JSON。</li>
</ul>
<h3>1.2 不适用</h3>
<ul>
<li><b>Mag2Car</b> 不走本进程。Mag2 仍使用地图字段 <span class="mono">Fass2_ControlArea</span> / <span class="mono">Fass2_ControlCapacity</span>,以及任务报文 StartStop=12→11,本进程不改 Mag2 报文。</li>
<li>内核 <span class="mono">TrafficControl.TryLock</span> 点锁、交通管制进程 <span class="mono">traffic.json</span>、环线任务的流量字段,与本表独立。</li>
<li>不负责让车在触发点停车。车必须先由任务/站点策略停在触发点,本进程只负责「满足条件后发启动」。</li>
</ul>
<h2>2. 总体策略</h2>
<p>交管策略可以概括为四句话:</p>
<ol>
<li><b>按区绑定</b>:同一「管控区名称」的多行表示同一个物理路口(或汇合区)。</li>
<li><b>按线占用</b>:每一行有自己的内部站点列表和容量,占用只统计本行站点,不把整个路口并成一份。</li>
<li><b>冲突才互斥</b>:同区里,兼容组相同,或两行内部站点有交集,视为冲突,不能同时放;否则可并行。</li>
<li><b>冲突才排队</b>:优先级、到达时刻、等待时长只在冲突线路之间生效。不冲突的线路互不插队、互不占名额。</li>
</ol>
<div class="ok">
兼容组留空且同区多行都为空时,视为同一组,<b>整区互斥</b>,与早期「同区容量 1 只放一辆」的行为兼容。旧 JSON 不填兼容组即可沿用。
</div>
<h2>3. 配置表(交管策略字段)</h2>
<p>主键是<b>管控触发站点</b>。同一触发点多条启用记录只保留第一条。未启用(IsUse=false)的行保留在文件中但不参与判定。</p>
<table>
<tr><th style="width:18%">字段</th><th style="width:16%">JSON 键</th><th>含义与配法</th></tr>
<tr>
<td>管控触发站点</td>
<td class="mono">TriggerSit</td>
<td>MagCar 必须停稳在此站点后才进入本行排队。应对齐现场停车点,不要把路口内部行驶点当成触发点。</td>
</tr>
<tr>
<td>管控区名称</td>
<td class="mono">AreaName</td>
<td>同名多行绑成一个路口,共用等待时长(取最长)。空则该触发点自成一区,相当于单入口限流。</td>
</tr>
<tr>
<td>兼容组</td>
<td class="mono">CompatGroup</td>
<td>同区里互不干扰的线路填<b>不同组名</b>(如东西 EW、南北 NS)。会交叉、会抢同一段路的填<b>同组</b>。空字符串与其他空组视为同组。</td>
</tr>
<tr>
<td>优先级</td>
<td class="mono">Priority</td>
<td>整数,越大越先放。干道高、支路低。只在冲突线路之间比较。</td>
</tr>
<tr>
<td>等待时长</td>
<td class="mono">WaitSeconds</td>
<td>秒。同区多行不一致时取<b>最长</b>。≤0 表示不做插队。用于防止支路被干道一直堵住。</td>
</tr>
<tr>
<td>管控区站点列表</td>
<td class="mono">ControlArea</td>
<td>本线路穿过路口的<b>内部站点</b>,逗号分隔,如 10,11。不要把各方向排队触发点写进来。空则回退为触发点本身。</td>
</tr>
<tr>
<td>区内最多几辆</td>
<td class="mono">ControlCapacity</td>
<td>本行容量。小于 1 按 1 处理。统计范围:占用本行内部站点的车 + 与本行冲突、已发启动但未进区的在途车。</td>
</tr>
<tr>
<td>放行后启停字节</td>
<td class="mono">ControlReleaseStartStop</td>
<td>兼容字段。MagCar 放行发 FASS 1.0 启动指令,不使用该字节。</td>
</tr>
<tr>
<td>启用</td>
<td class="mono">IsUse</td>
<td>false 时本行不生效。</td>
</tr>
</table>
<h3>3.1 运行态(不入库)</h3>
<table>
<tr><th>名称</th><th>说明</th></tr>
<tr>
<td>到达时刻</td>
<td>进程第一次扫到该 MagCar 停在该触发点的时间。开走后再回来重新计时。不写入 JSON。</td>
</tr>
<tr>
<td>在途占额</td>
<td>已对该车发过启动,但尚未占用本行内部站点。避免「刚放行、车还在触发点」时下一拍又放一辆冲突车。</td>
</tr>
</table>
<h3>3.2 进程参数</h3>
<table>
<tr><th>参数</th><th>默认</th><th>说明</th></tr>
<tr><td>TickMs</td><td>1000</td><td>扫车周期(毫秒),下限按 200ms 夹紧。</td></tr>
<tr><td>PendingTimeoutSeconds</td><td>8</td><td>在途超时(秒)。超时后取消占额,防止启动失败把名额卡死。</td></tr>
<tr><td>AreasPath</td><td class="mono">Config\Signal\mag-control-areas.json</td><td>相对路径只解析 SimpleLite 工作目录,不读 plugins 目录。</td></tr>
</table>
<h2>4. 交管逻辑(扫车每一拍)</h2>
<p>进程启动或点「重新加载配置」后,读取启用规则。此后约每秒执行一次 <span class="mono">ScanAndRelease</span></p>
<div class="flow">
<b>单拍流程</b>
<ol>
<li>快照全场车辆:站点(siteID / GetLastSite)、占用站点(siteID + holdingLocks)、是否 MagCar、是否停稳。</li>
<li>启用规则按管控区名称分组(空名称则按「#sit:触发点」自成一区)。同区等待时长取各行之最长。</li>
<li>清理到达时刻:已离开原触发点的车重新计时。</li>
<li>清理在途:车消失、超时、或已经踩到本行内部站点 → 去掉在途标记(进区后改由占用统计)。</li>
<li>收集本区停在各触发点上的停稳 MagCar,记为等待队列;已在途的不再重复发启动。</li>
<li>按第 6 节规则排序,然后<b>从队头到队尾贪心</b>:本行有空位则发启动并登记在途;无空位则跳过,继续看后面不冲突的车。</li>
<li>放行动作:先尝试停充,再 <span class="mono">StartMagCar</span>FASS 1.0 启动)。</li>
</ol>
</div>
<h3>4.1 何谓停稳</h3>
<p>状态不是「运行中 / Running」。下列视为可排队:停止中、充电中、WaitPass,以及没有 pendingLocks、没有 occupied 标签的非运行状态。充电中的车放行前会尝试 StopCharge。</p>
<h3>4.2 占用怎么数</h3>
<p>对某一等待车辆,只统计占用<b>该行 ControlArea</b> 的他车。排队触发点上尚未放行的 MagCar 不计入占用(它们还在区外等候)。本车不计。</p>
<p>占用站点来源:车辆当前 <span class="mono">siteID</span>,以及 <span class="mono">status.holdingLocks</span>。任一命中列表即算 1 辆。</p>
<h3>4.3 可放行数(空位)</h3>
<p class="mono">空位 = 本行容量 − 占用本行站点的车 − 与本行冲突的在途车</p>
<p>空位 ≥ 1 才对该车发启动。容量 1 表示:本线路内部(含冲突在途)最多一辆。</p>
<div class="note">
在途车若已经进入本行内部站点,只算「占用」,不再重复算「在途」,避免一名额扣两次。
</div>
<h2>5. 冲突判定(能否并行)</h2>
<p>只比较<b>同一管控区名称</b>下的两行(或一行与一条在途记录)。不同区互不影响。</p>
<p>满足下面<b>任一</b>条件即冲突(互斥):</p>
<ol>
<li>兼容组相同(不区分大小写,去首尾空格)。两边都为空也算相同 → 整区互斥。</li>
<li>两行的管控区站点列表有交集。</li>
</ol>
<p>两组都不同、且站点列表不相交 → <b>不冲突,可同时放行</b></p>
<table>
<tr><th>场景</th><th>兼容组</th><th>内部站点</th><th>结果</th></tr>
<tr><td>旧表、未填兼容组</td><td>都空</td><td>任意</td><td>同区全部互斥</td></tr>
<tr><td>东西对向直行</td><td>都填 EW</td><td>可不相交</td><td>同组,互斥(对向若也要并行,应拆成不同组且站点不相交)</td></tr>
<tr><td>东西 / 南北互不交叉</td><td>EW 与 NS</td><td>10,11 与 20,21</td><td>不冲突,可并行</td></tr>
<tr><td>组名不同但抢同一段</td><td>EW 与 NS</td><td>都含 15</td><td>站点相交,仍互斥</td></tr>
<tr><td>左转与对向直行交叉</td><td>建议同组,或站点列出交叉段</td><td>有交集或同组</td><td>互斥</td></tr>
</table>
<div class="warn">
若四个方向都复制同一份内部站点(例如都写 10,11,12,13),则即使兼容组不同也会因站点相交而全部互斥。要对向并行,必须按方向拆开内部点,且不要把触发点写进列表。
</div>
<h2>6. 排队规则(仅冲突线路)</h2>
<p>同一管控区内,先按下列比较函数排序,再贪心放行。不冲突的车排序先后不影响能否同时过,只影响扫描顺序。</p>
<h3>6.1 插队(等待时长)</h3>
<p>当同区等待时长 W &gt; 0,且两车优先级不同:</p>
<p><b>高优先级到达时刻 − 低优先级到达时刻 &gt; W</b>,则低优先级先走。</p>
<p>含义:低优先级更早到达,且已经比高优先级早到超过 W 秒,避免支路饿死。差值未超过 W 时,仍让优先级高的先走。</p>
<p>使用「&gt;」而不是「≥」。例如 W=10,恰好早到 10 秒时仍按优先级。</p>
<h3>6.2 默认顺序</h3>
<ol>
<li>未触发插队时:优先级数值大的在前。</li>
<li>优先级相同:到达时刻早的在前。</li>
<li>仍相同:车号小的在前(稳定次序)。</li>
</ol>
<h3>6.3 贪心,而不是只放队头</h3>
<p>排序后从前往后看。队头因本线已满或冲突在途没有空位时,<b>跳过队头</b>,继续检查后面的车。这样南北已占用时,队头若是南北,不会挡住后面可以走的东西向车辆。</p>
<h2>7. 放行与在途生命周期</h2>
<ol>
<li>判定可放 → StopCharge(若有)→ StartMagCar。</li>
<li>登记在途:记录区名、触发点、兼容组、本行内部站点、放行时刻。</li>
<li>下一拍该车仍停在触发点:不再重复启动,但作为冲突在途占额。</li>
<li>车进入本行内部站点:清除在途,改为占用统计。</li>
<li>超过 PendingTimeoutSeconds(默认 8 秒)仍未进区:清除在途,避免卡死。车若仍停在触发点,下一拍可再次进入排队。</li>
<li>车离线或从系统消失:清除在途与到达时刻。</li>
</ol>
<p>重新加载配置会清空到达时刻和在途表,请避免在路口有车等待时频繁重载。</p>
<h2>8. 配置示例</h2>
<h3>8.1 十字路口:东西与南北并行</h3>
<p>区名均为「十字路口A」。东西兼容组 EW,南北 NS。内部站点按方向拆开,容量均为 1。东西对向若现场确实会在同一段交汇,应把对向也拆站点或改为同组。</p>
<pre>[
{ "TriggerSit": 8, "AreaName": "十字路口A", "CompatGroup": "EW", "Priority": 10, "WaitSeconds": 15, "ControlArea": "10,11", "ControlCapacity": 1, "IsUse": true },
{ "TriggerSit": 18, "AreaName": "十字路口A", "CompatGroup": "EW", "Priority": 10, "WaitSeconds": 15, "ControlArea": "12,13", "ControlCapacity": 1, "IsUse": true },
{ "TriggerSit": 9, "AreaName": "十字路口A", "CompatGroup": "NS", "Priority": 5, "WaitSeconds": 15, "ControlArea": "20,21", "ControlCapacity": 1, "IsUse": true },
{ "TriggerSit": 19, "AreaName": "十字路口A", "CompatGroup": "NS", "Priority": 5, "WaitSeconds": 15, "ControlArea": "22,23", "ControlCapacity": 1, "IsUse": true }
]</pre>
<p>效果:东西一辆与南北一辆可同时过;同一兼容组内容量 1,同向或同组第二辆要等;南北优先级较低,但若已等待超过 15 秒且比干道更早到达,可以插队。</p>
<h3>8.2 单入口限流(旧用法)</h3>
<p>只配一行:触发点 8,区名为空,兼容组空,ControlArea 写内部点,容量 1。该点自成一区,他车占用内部点时不放行。</p>
<h3>8.3 支路防饿死</h3>
<p>干道 Priority=10,支路 Priority=1,同区 WaitSeconds=20。支路先到、干道后到且时间差大于 20 秒时放支路;否则放干道。</p>
<h2>9. 现场操作</h2>
<ol>
<li>确认已加载信号插件 <span class="mono">StandardScene.Signal.dll</span>,进程栏中有「磁条交管进程」。</li>
<li>将 JSON 放到 SimpleLite 工作目录 <span class="mono">Config\Signal\mag-control-areas.json</span>。可用迷毂数据中心「磁条管控区」编辑(含兼容组列,需较新的 MiGu.Server)。</li>
<li>添加并<b>启动</b>磁条交管进程。改表后点「重新加载配置」,或进程内打开列表保存后重载。</li>
<li>状态栏可见:规则条数、触发点等待数、在途数、扫车拍数。</li>
<li>诊断通道 <span class="mono">MagTraffic</span> 会记录放行:区名、兼容组、触发点、本线占用/容量、冲突在途。</li>
</ol>
<div class="note">
交管不会替任务系统停车。请保证 MagCar 到达触发点时处于停止,否则不会进入排队,也不会发启动。
</div>
<h2>10. 与 Mag2 / 其他交管的边界</h2>
<table>
<tr><th>机制</th><th>对象</th><th>关系</th></tr>
<tr><td>本进程 mag-control-areas.json</td><td>MagCar</td><td>触发点停稳后发 StartMagCar</td></tr>
<tr><td>地图 Fass2_ControlArea 等</td><td>Mag2Car</td><td>StartStop=12 等待,放行改本车报文,本进程不介入</td></tr>
<tr><td>TrafficControl 点锁</td><td>MagCar / Mag2 路径执行</td><td>锁站点资源,与本表并行存在</td></tr>
<tr><td>交通管制 traffic.json</td><td>区域互斥钩子</td><td>需单独启动「交通管制」进程</td></tr>
</table>
<h2>11. 常见问题</h2>
<h4>十字路口空着,四个方向却一起冲出去?</h4>
<p>兼容组未区分,或内部站点写成同一串。请按方向拆兼容组并拆开 ControlArea。</p>
<h4>四个方向谁也不走?</h4>
<p>把四个停车触发点写进了 ControlArea,容量又为 1,等待车被当成区内占用。触发点不要写入内部列表。</p>
<h4>改了 JSON 不生效?</h4>
<p>必须点「重新加载配置」或重启进程。路径必须是工作目录 Config/Signal,不是插件目录。</p>
<h4>Mag2 在同一路口不停不等?</h4>
<p>本策略不管 Mag2。Mag2 需在地图配置 Fass2_StartStop=12 及管控区字段。</p>
<h4>容量填 0</h4>
<p>当前实现小于 1 按 1 处理,不会变成不限流。</p>
<h4>对向也要并行,但写成了同一兼容组?</h4>
<p>同组即互斥。对向要并行:不同组名,且内部站点不相交。</p>
<h2>12. 逻辑对照(维护用)</h2>
<table>
<tr><th>内容</th><th>位置</th></tr>
<tr><td>表字段</td><td class="mono">StandardScene.Signal / MagControlAreaModel</td></tr>
<tr><td>冲突、占用、容量</td><td class="mono">MagControlAreaGate</td></tr>
<tr><td>扫车、排队、在途、贪心放行</td><td class="mono">MagControlRuntime.ScanAndRelease</td></tr>
<tr><td>进程与重载</td><td class="mono">MagTrafficMission</td></tr>
<tr><td>迷毂列定义</td><td class="mono">MiGu.Server / SignalDataStore(表 id: mag-control</td></tr>
</table>
<p class="footer-note">本文描述 2026-08-18 代码行为。若后续调整兼容组或容量语义,以源码为准并同步修订本文。</p>
</body>
</html>
Binary file not shown.
@@ -0,0 +1,75 @@
using System;
namespace StandardScene
{
/// <summary>
/// 磁条管控区放行判定结果。由信号插件「磁条交管进程」填写,Mag2Car 在 StartStop=12 停稳后消费。
/// </summary>
public sealed class MagControlReleaseDecision
{
/// <summary>当前触发点在交管表里有启用规则。false 时 Mag2Car 回退地图 <c>Fass2_ControlArea</c> 字段。</summary>
public bool RuleFound { get; set; }
public bool CanRelease { get; set; }
public string AreaId { get; set; } = string.Empty;
public int Capacity { get; set; } = 1;
public int OthersInArea { get; set; }
/// <summary>放行后写入本车报文的 StartStop,通常 11(管控启动)或 1(过站)。</summary>
public byte ReleaseStartStop { get; set; } = 11;
public string Reason { get; set; } = string.Empty;
}
/// <summary>
/// Mag2Car 与信号插件之间的管控放行钩子。不让 Magnetic / Signal 互相引用。
/// </summary>
/// <remarks>
/// 磁条交管进程启动时 <see cref="Register"/>,停止时 <see cref="Unregister"/>。
/// Mag2Car 在管控停止点调用 <see cref="TryCheck"/>:有规则用表判定,没有则用地图字段。
/// </remarks>
public static class MagControlReleaseHook
{
private static readonly object Sync = new object();
private static object _owner;
private static Func<int, int, MagControlReleaseDecision> _check;
/// <summary>注册判定函数。<paramref name="owner"/> 用于停止时只清自己的注册。</summary>
public static void Register(object owner, Func<int, int, MagControlReleaseDecision> check)
{
if (owner == null) throw new ArgumentNullException(nameof(owner));
lock (Sync)
{
_owner = owner;
_check = check;
}
}
public static void Unregister(object owner)
{
if (owner == null) return;
lock (Sync)
{
if (!ReferenceEquals(_owner, owner))
return;
_owner = null;
_check = null;
}
}
/// <summary>
/// 询问当前注册的交管进程。未注册返回 null;已注册但该触发点无规则时
/// 返回 <see cref="MagControlReleaseDecision.RuleFound"/> = false。
/// </summary>
public static MagControlReleaseDecision TryCheck(int carId, int triggerSiteId)
{
Func<int, int, MagControlReleaseDecision> check;
lock (Sync)
check = _check;
return check?.Invoke(carId, triggerSiteId);
}
}
}
@@ -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);
}
}
}
+630
View File
@@ -0,0 +1,630 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Text;
using System.Windows.Forms;
namespace StandardScene.MagCarSimulator
{
public sealed class MainForm : Form
{
private const int MaxLogLines = 400;
private const int MaxPendingLogs = 300;
private readonly MagCarSimConfig _config;
private readonly MagCarSimHost _host;
private readonly StringBuilder _logBuffer = new StringBuilder();
private readonly Queue<string> _pendingLogs = new Queue<string>();
private readonly object _logLock = new object();
private readonly Timer _uiTimer;
private bool _logFlushScheduled;
private int _logLineCount;
private TextBox _mapPathBox;
private CheckBox _useTagValueBox;
private Label _mapInfoLabel;
private DataGridView _grid;
private ListBox _siteList;
private TextBox _logBox;
private Button _listenButton;
private Button _loopButton;
private Button _stopButton;
private Button _releaseButton;
private Button _addButton;
private Button _removeButton;
private Button _saveButton;
public MainForm()
{
_config = MagCarSimConfig.Load();
MagCarSimLog.Configure(_config);
_host = new MagCarSimHost(_config);
Text = "MagCar 模拟器(FASS 1.0 / SimpleLite 地图循环)";
Width = 1280;
Height = 820;
MinimumSize = new Size(980, 640);
StartPosition = FormStartPosition.CenterScreen;
Font = new Font("Microsoft YaHei UI", 9F);
BuildLayout();
LoadConfigToUi();
MagCarSimLog.MessageWritten += OnLogMessage;
_uiTimer = new Timer { Interval = 250 };
_uiTimer.Tick += (_, __) => RefreshGridStatus();
_uiTimer.Start();
FormClosed += (_, __) =>
{
MagCarSimLog.MessageWritten -= OnLogMessage;
_uiTimer.Stop();
_host.Dispose();
MagCarSimLog.Shutdown();
};
AppendLog($"就绪。文件日志目录: {MagCarSimLog.GetLogDirectory()}。加载 SimpleLite 地图后启动监听,再开始循环。普通站可叠车;停止点和交管点同时只允许一辆。");
TryLoadMap(_config.MapPath, silent: true);
}
private void BuildLayout()
{
var root = new TableLayoutPanel
{
Dock = DockStyle.Fill,
ColumnCount = 1,
RowCount = 4,
Padding = new Padding(8)
};
root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
root.RowStyles.Add(new RowStyle(SizeType.Percent, 42));
root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
root.RowStyles.Add(new RowStyle(SizeType.Percent, 58));
Controls.Add(root);
root.Controls.Add(BuildToolbar(), 0, 0);
_grid = BuildGrid();
root.Controls.Add(_grid, 0, 1);
root.Controls.Add(BuildVehicleButtons(), 0, 2);
var split = new SplitContainer
{
Dock = DockStyle.Fill,
Orientation = Orientation.Vertical,
SplitterDistance = 280
};
_siteList = new ListBox { Dock = DockStyle.Fill, IntegralHeight = false };
var sitePanel = new Panel { Dock = DockStyle.Fill, Padding = new Padding(0, 4, 4, 0) };
sitePanel.Controls.Add(_siteList);
sitePanel.Controls.Add(new Label
{
Text = "地图站点(带 [停止] 的为 Mag_NeedStop",
Dock = DockStyle.Top,
Height = 22
});
split.Panel1.Controls.Add(sitePanel);
_logBox = new TextBox
{
Dock = DockStyle.Fill,
Multiline = true,
ReadOnly = true,
ScrollBars = ScrollBars.Both,
WordWrap = false,
Font = new Font("Consolas", 9F)
};
var logPanel = new Panel { Dock = DockStyle.Fill, Padding = new Padding(4, 4, 0, 0) };
logPanel.Controls.Add(_logBox);
logPanel.Controls.Add(new Label
{
Text = "协议 / 运动日志",
Dock = DockStyle.Top,
Height = 22
});
split.Panel2.Controls.Add(logPanel);
root.Controls.Add(split, 0, 3);
}
private Control BuildToolbar()
{
var panel = new TableLayoutPanel
{
Dock = DockStyle.Top,
AutoSize = true,
ColumnCount = 8,
RowCount = 2,
Padding = new Padding(0, 0, 0, 6)
};
panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
panel.Controls.Add(new Label { Text = "地图", AutoSize = true, Anchor = AnchorStyles.Left }, 0, 0);
_mapPathBox = new TextBox { Dock = DockStyle.Fill, Width = 480 };
panel.Controls.Add(_mapPathBox, 1, 0);
var browse = new Button { Text = "浏览…", AutoSize = true };
browse.Click += (_, __) => BrowseMap();
panel.Controls.Add(browse, 2, 0);
var load = new Button { Text = "加载地图", AutoSize = true };
load.Click += (_, __) => TryLoadMap(_mapPathBox.Text, silent: false);
panel.Controls.Add(load, 3, 0);
_listenButton = new Button { Text = "启动监听", AutoSize = true };
_listenButton.Click += (_, __) => StartListen();
panel.Controls.Add(_listenButton, 4, 0);
_loopButton = new Button { Text = "开始循环", AutoSize = true };
_loopButton.Click += (_, __) => StartLoop();
panel.Controls.Add(_loopButton, 5, 0);
_stopButton = new Button { Text = "停止模拟", AutoSize = true, Enabled = false };
_stopButton.Click += (_, __) => StopSim();
panel.Controls.Add(_stopButton, 6, 0);
_saveButton = new Button { Text = "保存配置", AutoSize = true };
_saveButton.Click += (_, __) => SaveConfig();
panel.Controls.Add(_saveButton, 7, 0);
_useTagValueBox = new CheckBox { Text = "用 TagValue 作为节点号", AutoSize = true, Anchor = AnchorStyles.Left };
panel.SetColumnSpan(_useTagValueBox, 2);
panel.Controls.Add(_useTagValueBox, 0, 1);
_mapInfoLabel = new Label
{
Text = "未加载地图",
AutoSize = true,
Anchor = AnchorStyles.Left,
ForeColor = Color.DimGray
};
panel.SetColumnSpan(_mapInfoLabel, 6);
panel.Controls.Add(_mapInfoLabel, 2, 1);
return panel;
}
private Control BuildVehicleButtons()
{
var panel = new FlowLayoutPanel
{
Dock = DockStyle.Fill,
AutoSize = true,
WrapContents = false,
Padding = new Padding(0, 4, 0, 4)
};
_addButton = new Button { Text = "添加车辆", AutoSize = true };
_addButton.Click += (_, __) => AddVehicleRow();
_removeButton = new Button { Text = "删除选中", AutoSize = true };
_removeButton.Click += (_, __) => RemoveSelectedVehicle();
_releaseButton = new Button { Text = "放行选中车", AutoSize = true };
_releaseButton.Click += (_, __) => ReleaseSelected();
panel.Controls.Add(_addButton);
panel.Controls.Add(_removeButton);
panel.Controls.Add(_releaseButton);
panel.Controls.Add(new Label
{
Text = "普通站可叠车。停止点和交管点同时只允许一辆,后车停在上一站保持运行中。仅 Mag_NeedStop 停车等 0x01。",
AutoSize = true,
Padding = new Padding(12, 8, 0, 0),
ForeColor = Color.DimGray
});
return panel;
}
private DataGridView BuildGrid()
{
var grid = new DataGridView
{
Dock = DockStyle.Fill,
AllowUserToAddRows = false,
AllowUserToDeleteRows = false,
RowHeadersVisible = false,
SelectionMode = DataGridViewSelectionMode.FullRowSelect,
MultiSelect = false,
AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill,
BackgroundColor = Color.White
};
grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Name", HeaderText = "名称", FillWeight = 80 });
grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Code", HeaderText = "车号", FillWeight = 50 });
grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Port", HeaderText = "端口", FillWeight = 55 });
grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Start", HeaderText = "起点", FillWeight = 50 });
grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "End", HeaderText = "终点", FillWeight = 50 });
grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Interval", HeaderText = "间隔ms", FillWeight = 60 });
grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Site", HeaderText = "当前站", ReadOnly = true, FillWeight = 55 });
grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Node", HeaderText = "节点", ReadOnly = true, FillWeight = 50 });
grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "State", HeaderText = "状态", ReadOnly = true, FillWeight = 70 });
grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Wait", HeaderText = "等待", ReadOnly = true, FillWeight = 55 });
grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Clients", HeaderText = "连接", ReadOnly = true, FillWeight = 45 });
grid.Columns.Add(new DataGridViewTextBoxColumn { Name = "Path", HeaderText = "循环路径", ReadOnly = true, FillWeight = 160 });
return grid;
}
private void LoadConfigToUi()
{
_mapPathBox.Text = _config.MapPath ?? "";
_useTagValueBox.Checked = _config.UseTagValueAsNode;
_grid.Rows.Clear();
foreach (var vehicle in _config.Vehicles)
{
_grid.Rows.Add(
vehicle.Name,
vehicle.VehicleCode,
vehicle.ListenPort,
vehicle.StartSiteId,
vehicle.EndSiteId,
vehicle.IntervalMs,
"",
"",
"",
"",
"",
"");
}
}
private List<MagCarSimVehicleConfig> ReadVehiclesFromGrid()
{
var list = new List<MagCarSimVehicleConfig>();
foreach (DataGridViewRow row in _grid.Rows)
{
if (row.IsNewRow)
{
continue;
}
var code = ToUShort(row.Cells["Code"].Value, 1);
list.Add(new MagCarSimVehicleConfig
{
Name = Convert.ToString(row.Cells["Name"].Value),
VehicleCode = code,
ListenPort = ToInt(row.Cells["Port"].Value, 5001),
StartSiteId = ToInt(row.Cells["Start"].Value, 1),
EndSiteId = ToInt(row.Cells["End"].Value, 2),
IntervalMs = ToInt(row.Cells["Interval"].Value, _config.DefaultIntervalMs),
LoopSiteIds = PreserveLoop(code)
});
}
return list;
}
private List<int> PreserveLoop(ushort vehicleCode)
{
var existing = _config.Vehicles?.Find(v => v.VehicleCode == vehicleCode);
if (existing?.LoopSiteIds == null || existing.LoopSiteIds.Count == 0)
{
return null;
}
return new List<int>(existing.LoopSiteIds);
}
private void BrowseMap()
{
using var dialog = new OpenFileDialog
{
Filter = "SimpleLite 地图 (*.json)|*.json|所有文件 (*.*)|*.*",
Title = "选择 SimpleLite 地图"
};
if (!string.IsNullOrWhiteSpace(_mapPathBox.Text) && File.Exists(_mapPathBox.Text))
{
dialog.InitialDirectory = Path.GetDirectoryName(_mapPathBox.Text);
dialog.FileName = Path.GetFileName(_mapPathBox.Text);
}
if (dialog.ShowDialog(this) == DialogResult.OK)
{
_mapPathBox.Text = dialog.FileName;
TryLoadMap(dialog.FileName, silent: false);
}
}
private void TryLoadMap(string path, bool silent)
{
if (string.IsNullOrWhiteSpace(path))
{
if (!silent)
{
MessageBox.Show(this, "请先选择 SimpleLite 地图文件。", "加载地图", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
return;
}
try
{
_config.UseTagValueAsNode = _useTagValueBox.Checked;
var map = _host.LoadMap(path);
_mapPathBox.Text = map.FilePath;
RefreshSiteList(map);
var stops = map.ListNeedStopSiteIds();
_mapInfoLabel.Text = $"站点 {map.Sites.Count},路径 {map.Tracks.Count},停止点 {(stops.Count == 0 ? "" : string.Join(",", stops))}";
}
catch (Exception ex)
{
_mapInfoLabel.Text = "地图加载失败";
if (!silent)
{
MessageBox.Show(this, ex.Message, "加载地图失败", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
else
{
AppendLog("地图未加载:" + ex.Message);
}
}
}
private void RefreshSiteList(SimpleLiteMap map)
{
_siteList.Items.Clear();
foreach (var site in map.Sites.Values)
{
var tag = site.NeedStop ? " [停止]" : "";
var node = map.ResolveNode(site.Id);
_siteList.Items.Add($"站 {site.Id} node={node}{tag}");
}
}
private void StartListen()
{
try
{
ApplyGridToHost();
_host.StartListen();
SetRunningUi(true, looping: false);
AppendLog("TCP 监听已启动。请在 SimpleLite 中把 MagCar 的 address/Port/VehicleCode 配成与上表一致。");
}
catch (Exception ex)
{
MessageBox.Show(this, ex.Message, "启动监听失败", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
private void StartLoop()
{
try
{
if (!_host.IsListening)
{
ApplyGridToHost();
}
_host.StartLoops();
SetRunningUi(true, looping: true);
}
catch (Exception ex)
{
MessageBox.Show(this, ex.Message, "开始循环失败", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
private void StopSim()
{
_host.StopAll();
SetRunningUi(false, looping: false);
AppendLog("模拟已停止。");
RefreshGridStatus();
}
private void ApplyGridToHost()
{
_config.MapPath = _mapPathBox.Text;
_config.UseTagValueAsNode = _useTagValueBox.Checked;
_config.Vehicles = ReadVehiclesFromGrid();
if (!string.IsNullOrWhiteSpace(_config.MapPath))
{
TryLoadMap(_config.MapPath, silent: false);
}
_host.RebuildVehicles(_config.Vehicles);
}
private void AddVehicleRow()
{
if (_host.IsListening)
{
MessageBox.Show(this, "运行中不能改车辆列表,请先停止模拟。", "添加车辆", MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
var nextCode = _grid.Rows.Count + 1;
_grid.Rows.Add($"AGV-{nextCode}", nextCode, 5000 + nextCode, 1, 6, _config.DefaultIntervalMs, "", "", "", "", "", "");
}
private void RemoveSelectedVehicle()
{
if (_host.IsListening)
{
MessageBox.Show(this, "运行中不能改车辆列表,请先停止模拟。", "删除车辆", MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
if (_grid.CurrentRow == null)
{
return;
}
_grid.Rows.Remove(_grid.CurrentRow);
}
private void ReleaseSelected()
{
if (_grid.CurrentRow == null)
{
MessageBox.Show(this, "请先选中一辆车。", "放行", MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
var code = ToUShort(_grid.CurrentRow.Cells["Code"].Value, 0);
if (!_host.TryRelease(code))
{
MessageBox.Show(this, $"未找到车号 {code},请先启动监听。", "放行", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
}
private void SaveConfig()
{
try
{
_config.MapPath = _mapPathBox.Text;
_config.UseTagValueAsNode = _useTagValueBox.Checked;
_config.Vehicles = ReadVehiclesFromGrid();
_config.Save();
AppendLog("配置已保存到 " + MagCarSimConfig.SettingsPath);
}
catch (Exception ex)
{
MessageBox.Show(this, ex.Message, "保存失败", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
private void SetRunningUi(bool listening, bool looping)
{
_listenButton.Enabled = !listening;
_loopButton.Enabled = !looping;
_stopButton.Enabled = listening;
_addButton.Enabled = !listening;
_removeButton.Enabled = !listening;
_grid.ReadOnly = listening;
_useTagValueBox.Enabled = !listening;
}
private void RefreshGridStatus()
{
var snapshots = _host.Snapshots();
if (snapshots.Count == 0)
{
return;
}
for (var i = 0; i < _grid.Rows.Count && i < snapshots.Count; i++)
{
var snap = snapshots[i];
var row = _grid.Rows[i];
row.Cells["Site"].Value = snap.CurrentSiteId;
row.Cells["Node"].Value = snap.Node;
row.Cells["State"].Value = snap.StateText;
row.Cells["Wait"].Value = snap.WaitingRelease ? "等放行" : (snap.HoldingForStop ? "让行" : "");
row.Cells["Clients"].Value = snap.ClientCount;
row.Cells["Path"].Value = snap.PathText;
row.DefaultCellStyle.BackColor = snap.WaitingRelease
? Color.FromArgb(255, 236, 179)
: snap.HoldingForStop
? Color.FromArgb(207, 232, 255)
: Color.White;
}
}
private void OnLogMessage(string message)
{
lock (_logLock)
{
while (_pendingLogs.Count >= MaxPendingLogs)
{
_pendingLogs.Dequeue();
}
_pendingLogs.Enqueue(message);
}
if (_logFlushScheduled || !IsHandleCreated)
{
return;
}
_logFlushScheduled = true;
try
{
BeginInvoke(new Action(FlushLogs));
}
catch
{
_logFlushScheduled = false;
}
}
private void FlushLogs()
{
_logFlushScheduled = false;
List<string> batch;
lock (_logLock)
{
if (_pendingLogs.Count == 0)
{
return;
}
batch = new List<string>(_pendingLogs.Count);
while (_pendingLogs.Count > 0)
{
batch.Add(_pendingLogs.Dequeue());
}
}
foreach (var line in batch)
{
_logLineCount++;
_logBuffer.AppendLine(line);
}
while (_logLineCount > MaxLogLines)
{
var text = _logBuffer.ToString();
var firstBreak = text.IndexOf('\n');
if (firstBreak < 0)
{
break;
}
_logBuffer.Remove(0, firstBreak + 1);
_logLineCount--;
}
_logBox.Text = _logBuffer.ToString();
_logBox.SelectionStart = _logBox.TextLength;
_logBox.ScrollToCaret();
}
private void AppendLog(string message)
{
lock (_logLock)
{
while (_pendingLogs.Count >= MaxPendingLogs)
{
_pendingLogs.Dequeue();
}
_pendingLogs.Enqueue(message);
}
if (IsHandleCreated)
{
if (!_logFlushScheduled)
{
_logFlushScheduled = true;
BeginInvoke(new Action(FlushLogs));
}
}
else
{
FlushLogs();
}
}
private static int ToInt(object value, int fallback)
{
return int.TryParse(Convert.ToString(value), out var n) ? n : fallback;
}
private static ushort ToUShort(object value, ushort fallback)
{
return ushort.TryParse(Convert.ToString(value), out var n) ? n : fallback;
}
}
}
@@ -0,0 +1,83 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using Newtonsoft.Json.Linq;
namespace StandardScene.MagCarSimulator
{
/// <summary>读取 mag-control-areas.json,收集触发点与管控区站点。</summary>
public static class MagControlAreaFile
{
private static readonly char[] SiteSeparators = { ',', ';', '|', ' ', '\t' };
public static HashSet<int> LoadSiteIds(string path)
{
var ids = new HashSet<int>();
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
return ids;
}
var root = JToken.Parse(File.ReadAllText(path));
if (root is not JArray rows)
{
return ids;
}
foreach (var row in rows)
{
if (row is not JObject obj)
{
continue;
}
if (obj.Value<bool?>("IsUse") == false)
{
continue;
}
AddIfPositive(ids, obj.Value<int?>("TriggerSit") ?? 0);
foreach (var part in (obj.Value<string>("ControlArea") ?? "").Split(SiteSeparators, StringSplitOptions.RemoveEmptyEntries))
{
if (int.TryParse(part.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var id))
{
AddIfPositive(ids, id);
}
}
}
return ids;
}
public static string GuessPath(string mapPath, string configuredPath)
{
if (!string.IsNullOrWhiteSpace(configuredPath) && File.Exists(configuredPath))
{
return Path.GetFullPath(configuredPath);
}
if (string.IsNullOrWhiteSpace(mapPath))
{
return configuredPath;
}
var mapDir = Path.GetDirectoryName(Path.GetFullPath(mapPath));
if (string.IsNullOrWhiteSpace(mapDir))
{
return configuredPath;
}
var guessed = Path.GetFullPath(Path.Combine(mapDir, "..", "config", "Signal", "mag-control-areas.json"));
return File.Exists(guessed) ? guessed : configuredPath;
}
private static void AddIfPositive(HashSet<int> ids, int id)
{
if (id > 0)
{
ids.Add(id);
}
}
}
}
@@ -0,0 +1,213 @@
using System.Collections.Generic;
using System.Text;
namespace StandardScene.MagCarSimulator
{
public static class MapPathFinder
{
/// <summary>
/// 生成往返循环:起点→终点→起点(终点不重复,回到起点靠下标回绕)。
/// </summary>
public static List<int> BuildLoop(SimpleLiteMap map, int startSiteId, int endSiteId)
{
if (map == null)
{
throw new System.InvalidOperationException("尚未加载地图");
}
if (startSiteId == endSiteId)
{
throw new System.InvalidOperationException($"起点和终点不能相同(站点 {startSiteId}");
}
if (!map.Sites.ContainsKey(startSiteId))
{
throw new System.InvalidOperationException($"地图中没有起点 {startSiteId}");
}
if (!map.Sites.ContainsKey(endSiteId))
{
throw new System.InvalidOperationException($"地图中没有终点 {endSiteId}");
}
var forward = FindPath(map, startSiteId, endSiteId);
if (forward == null)
{
throw new System.InvalidOperationException($"找不到路径 {startSiteId} → {endSiteId}");
}
var back = FindPath(map, endSiteId, startSiteId);
if (back == null)
{
throw new System.InvalidOperationException($"找不到返回路径 {endSiteId} → {startSiteId}");
}
var loop = new List<int>(forward.Count + back.Count);
loop.AddRange(forward);
for (var i = 1; i < back.Count - 1; i++)
{
loop.Add(back[i]);
}
if (loop.Count < 2)
{
throw new System.InvalidOperationException("循环路径至少需要两个站点");
}
return loop;
}
public static List<int> NormalizeLoop(IEnumerable<int> siteIds)
{
var loop = new List<int>();
if (siteIds == null)
{
return loop;
}
foreach (var id in siteIds)
{
if (id <= 0)
{
continue;
}
if (loop.Count > 0 && loop[loop.Count - 1] == id)
{
continue;
}
loop.Add(id);
}
if (loop.Count >= 2 && loop[0] == loop[loop.Count - 1])
{
loop.RemoveAt(loop.Count - 1);
}
return loop;
}
public static List<int> ResolveLoop(SimpleLiteMap map, MagCarSimVehicleConfig config)
{
if (map == null)
{
throw new System.InvalidOperationException("尚未加载地图");
}
if (config?.LoopSiteIds != null && config.LoopSiteIds.Count >= 3)
{
var loop = NormalizeLoop(config.LoopSiteIds);
if (loop.Count < 3)
{
throw new System.InvalidOperationException($"{config.Name} 环线站点不足");
}
foreach (var id in loop)
{
if (!map.Sites.ContainsKey(id))
{
throw new System.InvalidOperationException($"{config.Name} 环线站点 {id} 不在地图中");
}
}
return loop;
}
return BuildLoop(map, config.StartSiteId, config.EndSiteId);
}
public static string FormatPath(IReadOnlyList<int> siteIds, int maxShow = 0)
{
if (siteIds == null || siteIds.Count == 0)
{
return "";
}
var show = siteIds.Count;
var truncated = false;
if (maxShow > 0 && siteIds.Count > maxShow)
{
show = maxShow;
truncated = true;
}
var sb = new StringBuilder();
for (var i = 0; i < show; i++)
{
if (i > 0)
{
sb.Append("→");
}
sb.Append(siteIds[i]);
}
if (truncated)
{
sb.Append("→…共").Append(siteIds.Count).Append("站");
}
else
{
sb.Append("→").Append(siteIds[0]);
}
return sb.ToString();
}
public static List<int> FindPath(SimpleLiteMap map, int fromSiteId, int toSiteId)
{
if (fromSiteId == toSiteId)
{
return new List<int> { fromSiteId };
}
var queue = new Queue<int>();
var prev = new Dictionary<int, int>();
var visited = new HashSet<int> { fromSiteId };
queue.Enqueue(fromSiteId);
while (queue.Count > 0)
{
var current = queue.Dequeue();
if (!map.Adjacency.TryGetValue(current, out var nexts))
{
continue;
}
foreach (var next in nexts)
{
if (!visited.Add(next))
{
continue;
}
prev[next] = current;
if (next == toSiteId)
{
return Reconstruct(prev, fromSiteId, toSiteId);
}
queue.Enqueue(next);
}
}
return null;
}
private static List<int> Reconstruct(Dictionary<int, int> prev, int fromSiteId, int toSiteId)
{
var path = new List<int>();
var current = toSiteId;
path.Add(current);
while (current != fromSiteId)
{
current = prev[current];
path.Add(current);
}
path.Reverse();
return path;
}
}
}
@@ -0,0 +1,324 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using Newtonsoft.Json.Linq;
namespace StandardScene.MagCarSimulator
{
public sealed class SimpleLiteSite
{
public int Id { get; set; }
public string Name { get; set; }
public double X { get; set; }
public double Y { get; set; }
public int? TagValue { get; set; }
public bool NeedStop { get; set; }
public IReadOnlyDictionary<string, string> Fields { get; set; }
}
public sealed class SimpleLiteTrack
{
public int Id { get; set; }
public int SiteA { get; set; }
public int SiteB { get; set; }
public int Direction { get; set; }
}
/// <summary>解析 SimpleLite <c>maps/*.json</c> 的 Sites / Tracks。</summary>
public sealed class SimpleLiteMap
{
public const string NeedStopField = "Mag_NeedStop";
public string FilePath { get; private set; }
public string FileName { get; private set; }
public IReadOnlyDictionary<int, SimpleLiteSite> Sites { get; private set; }
public IReadOnlyList<SimpleLiteTrack> Tracks { get; private set; }
public IReadOnlyDictionary<int, IReadOnlyList<int>> Adjacency { get; private set; }
private readonly HashSet<int> _singleOccupancySites = new HashSet<int>();
public static SimpleLiteMap Load(string path, bool useTagValueAsNode)
{
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
throw new FileNotFoundException("找不到 SimpleLite 地图文件", path);
}
var root = JObject.Parse(File.ReadAllText(path));
var sites = ParseSites(root["Sites"] as JObject);
var tracks = ParseTracks(root["Tracks"] as JObject);
var adjacency = BuildAdjacency(sites, tracks);
var map = new SimpleLiteMap
{
FilePath = Path.GetFullPath(path),
FileName = Path.GetFileName(path),
Sites = sites,
Tracks = tracks,
Adjacency = adjacency,
UseTagValueAsNode = useTagValueAsNode
};
map.ResetSingleOccupancyFromStops();
return map;
}
public bool UseTagValueAsNode { get; private set; }
public bool TryGetSite(int siteId, out SimpleLiteSite site)
{
return Sites.TryGetValue(siteId, out site);
}
public ushort ResolveNode(int siteId)
{
if (!Sites.TryGetValue(siteId, out var site))
{
return (ushort)Math.Clamp(siteId, 0, ushort.MaxValue);
}
if (UseTagValueAsNode && site.TagValue.HasValue && site.TagValue.Value >= 0)
{
return (ushort)Math.Clamp(site.TagValue.Value, 0, ushort.MaxValue);
}
return (ushort)Math.Clamp(site.Id, 0, ushort.MaxValue);
}
public bool NeedStop(int siteId)
{
return Sites.TryGetValue(siteId, out var site) && site.NeedStop;
}
/// <summary>停止点或交管点:同时只允许一辆。普通站可叠车。</summary>
public bool SingleOccupancy(int siteId)
{
return _singleOccupancySites.Contains(siteId);
}
public IReadOnlyList<int> ListSingleOccupancySiteIds()
{
var list = new List<int>(_singleOccupancySites);
list.Sort();
return list;
}
public void ResetSingleOccupancyFromStops()
{
_singleOccupancySites.Clear();
foreach (var site in Sites.Values)
{
if (site.NeedStop)
{
_singleOccupancySites.Add(site.Id);
}
}
}
public void AddSingleOccupancySites(IEnumerable<int> siteIds)
{
if (siteIds == null)
{
return;
}
foreach (var id in siteIds)
{
if (id > 0 && Sites.ContainsKey(id))
{
_singleOccupancySites.Add(id);
}
}
}
public IReadOnlyList<int> ListNeedStopSiteIds()
{
var list = new List<int>();
foreach (var site in Sites.Values)
{
if (site.NeedStop)
{
list.Add(site.Id);
}
}
list.Sort();
return list;
}
private static Dictionary<int, SimpleLiteSite> ParseSites(JObject sitesNode)
{
var result = new Dictionary<int, SimpleLiteSite>();
if (sitesNode == null)
{
return result;
}
foreach (var property in sitesNode.Properties())
{
if (property.Value is not JObject obj)
{
continue;
}
var id = obj.Value<int?>("id") ?? ParseInt(property.Name);
if (id <= 0)
{
continue;
}
var fields = ReadFields(obj["fields"] as JObject);
var site = new SimpleLiteSite
{
Id = id,
Name = obj.Value<string>("name") ?? "",
X = obj.Value<double?>("x") ?? 0,
Y = obj.Value<double?>("y") ?? 0,
TagValue = TryReadInt(fields, "TagValue"),
NeedStop = IsNeedStop(fields),
Fields = fields
};
result[id] = site;
}
return result;
}
private static List<SimpleLiteTrack> ParseTracks(JObject tracksNode)
{
var result = new List<SimpleLiteTrack>();
if (tracksNode == null)
{
return result;
}
foreach (var property in tracksNode.Properties())
{
if (property.Value is not JObject obj)
{
continue;
}
var siteA = obj.Value<int?>("siteA") ?? obj.Value<int?>("_siteA") ?? 0;
var siteB = obj.Value<int?>("siteB") ?? obj.Value<int?>("_siteB") ?? 0;
if (siteA <= 0 || siteB <= 0)
{
continue;
}
result.Add(new SimpleLiteTrack
{
Id = obj.Value<int?>("id") ?? ParseInt(property.Name),
SiteA = siteA,
SiteB = siteB,
Direction = obj.Value<int?>("direction") ?? 0
});
}
return result;
}
private static Dictionary<int, IReadOnlyList<int>> BuildAdjacency(
Dictionary<int, SimpleLiteSite> sites,
List<SimpleLiteTrack> tracks)
{
var mutable = new Dictionary<int, List<int>>();
foreach (var id in sites.Keys)
{
mutable[id] = new List<int>();
}
foreach (var track in tracks)
{
AddEdge(mutable, track.SiteA, track.SiteB, track.Direction);
}
var result = new Dictionary<int, IReadOnlyList<int>>();
foreach (var pair in mutable)
{
result[pair.Key] = pair.Value;
}
return result;
}
private static void AddEdge(Dictionary<int, List<int>> graph, int from, int to, int direction)
{
// 0 双向;1 仅 A→B;2 仅 B→A。其它值按双向处理。
var aToB = direction != 2;
var bToA = direction != 1;
if (aToB)
{
AddUnique(graph, from, to);
}
if (bToA)
{
AddUnique(graph, to, from);
}
}
private static void AddUnique(Dictionary<int, List<int>> graph, int from, int to)
{
if (!graph.TryGetValue(from, out var list))
{
list = new List<int>();
graph[from] = list;
}
if (!list.Contains(to))
{
list.Add(to);
}
}
private static Dictionary<string, string> ReadFields(JObject fields)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (fields == null)
{
return result;
}
foreach (var property in fields.Properties())
{
result[property.Name] = property.Value?.Type == JTokenType.Null
? ""
: property.Value.ToString();
}
return result;
}
private static bool IsNeedStop(Dictionary<string, string> fields)
{
if (!fields.TryGetValue(NeedStopField, out var raw))
{
return false;
}
raw = (raw ?? "").Trim();
return raw.Equals("true", StringComparison.OrdinalIgnoreCase)
|| raw.Equals("1", StringComparison.OrdinalIgnoreCase)
|| raw.Equals("yes", StringComparison.OrdinalIgnoreCase);
}
private static int? TryReadInt(Dictionary<string, string> fields, string key)
{
if (!fields.TryGetValue(key, out var raw) || string.IsNullOrWhiteSpace(raw))
{
return null;
}
return int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)
? value
: (int?)null;
}
private static int ParseInt(string text)
{
return int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)
? value
: 0;
}
}
}
@@ -0,0 +1,242 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
namespace StandardScene.MagCarSimulator
{
public sealed class MagCarSimTcpServer : IDisposable
{
private readonly IPAddress _address;
private readonly int _port;
private readonly Func<byte[], byte[]> _handler;
private readonly object _clientsLock = new object();
private readonly HashSet<TcpClient> _clients = new HashSet<TcpClient>();
private TcpListener _listener;
private CancellationTokenSource _cts;
private int _clientCount;
private bool _running;
public MagCarSimTcpServer(IPAddress address, int port, Func<byte[], byte[]> handler)
{
_address = address ?? IPAddress.Any;
_port = port;
_handler = handler ?? throw new ArgumentNullException(nameof(handler));
}
public bool IsRunning => _running;
public int ClientCount => Volatile.Read(ref _clientCount);
public void Start()
{
if (_running)
{
return;
}
_cts = new CancellationTokenSource();
_listener = new TcpListener(_address, _port);
_listener.Server.NoDelay = true;
_listener.Start();
_running = true;
_ = Task.Run(() => AcceptLoop(_cts.Token));
}
public void Dispose()
{
_running = false;
try
{
_cts?.Cancel();
}
catch
{
}
try
{
_listener?.Stop();
}
catch
{
}
List<TcpClient> clients;
lock (_clientsLock)
{
clients = new List<TcpClient>(_clients);
_clients.Clear();
}
foreach (var client in clients)
{
try
{
client.Close();
}
catch
{
}
}
_cts?.Dispose();
_cts = null;
_listener = null;
}
private async Task AcceptLoop(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
TcpClient client;
try
{
client = await _listener.AcceptTcpClientAsync(token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return;
}
catch (ObjectDisposedException)
{
return;
}
catch (SocketException)
{
if (token.IsCancellationRequested)
{
return;
}
continue;
}
lock (_clientsLock)
{
_clients.Add(client);
}
_ = Task.Run(() => HandleClient(client, token), token);
}
}
private void HandleClient(TcpClient client, CancellationToken token)
{
Interlocked.Increment(ref _clientCount);
try
{
client.NoDelay = true;
client.ReceiveTimeout = 0;
using (var stream = client.GetStream())
{
var buffer = new byte[MagCarSimProtocol.FrameLength];
while (!token.IsCancellationRequested)
{
if (!ReadExact(stream, buffer, token))
{
return;
}
byte[] response;
try
{
response = _handler(buffer);
}
catch (Exception ex)
{
MagCarSimLog.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] TCP 处理异常: {ex.Message}");
return;
}
if (response == null || response.Length != MagCarSimProtocol.FrameLength)
{
continue;
}
stream.Write(response, 0, response.Length);
stream.Flush();
}
}
}
catch (Exception ex) when (IsBenign(ex))
{
}
catch (Exception ex)
{
MagCarSimLog.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] TCP 连接异常: {ex.Message}");
}
finally
{
lock (_clientsLock)
{
_clients.Remove(client);
}
try
{
client.Dispose();
}
catch
{
}
Interlocked.Decrement(ref _clientCount);
}
}
private static bool ReadExact(NetworkStream stream, byte[] buffer, CancellationToken token)
{
var offset = 0;
while (offset < buffer.Length)
{
token.ThrowIfCancellationRequested();
int read;
try
{
read = stream.Read(buffer, offset, buffer.Length - offset);
}
catch (Exception ex) when (IsBenign(ex))
{
return false;
}
if (read == 0)
{
return false;
}
offset += read;
}
return true;
}
private static bool IsBenign(Exception ex)
{
for (var current = ex; current != null; current = current.InnerException)
{
if (current is ObjectDisposedException || current is OperationCanceledException)
{
return true;
}
if (current is SocketException socketEx)
{
switch (socketEx.SocketErrorCode)
{
case SocketError.OperationAborted:
case SocketError.Interrupted:
case SocketError.ConnectionAborted:
case SocketError.ConnectionReset:
case SocketError.Shutdown:
return true;
}
}
}
return false;
}
}
}
+15
View File
@@ -0,0 +1,15 @@
using System;
using System.Windows.Forms;
namespace StandardScene.MagCarSimulator
{
internal static class Program
{
[STAThread]
private static void Main()
{
ApplicationConfiguration.Initialize();
Application.Run(new MainForm());
}
}
}
@@ -0,0 +1,144 @@
using System;
namespace StandardScene.MagCarSimulator
{
/// <summary>
/// FASS 1.0 32 字节帧,字段布局与 <c>MagCar.MagCarReport</c> 对齐。
/// </summary>
public static class MagCarSimProtocol
{
public const int FrameLength = 32;
public const byte Begin = 0xBB;
public const byte End = 0xEE;
public const byte CmdQueryState = 0x00;
public const byte CmdStart = 0x01;
public const byte CmdStop = 0x02;
public const byte CmdSpeed = 0x03;
public const byte CmdAngle = 0x04;
public const byte CmdLegacyNode = 0x05;
public const byte CmdTask = 0xA1;
public const byte StateIdle = 0;
public const byte StateRunning = 1;
public const byte StateStopping = 2;
public const byte StateCharging = 3;
public static bool TryParse(byte[] bytes, out MagCarSimRequest request)
{
request = null;
if (bytes == null || bytes.Length < FrameLength)
{
return false;
}
if (bytes[0] != Begin || bytes[FrameLength - 1] != End)
{
return false;
}
request = new MagCarSimRequest
{
Command = bytes[1],
VehicleCode = ReadUInt16(bytes, 2),
Data0 = ReadUInt16(bytes, 4),
Data1 = ReadUInt16(bytes, 6)
};
return true;
}
public static byte[] BuildStatus(
byte command,
ushort vehicleCode,
ushort node,
byte state,
byte charge,
float current,
float voltage,
byte speed,
ushort angle,
byte task,
byte lift,
byte roll)
{
var bytes = new byte[FrameLength];
bytes[0] = Begin;
bytes[1] = command;
WriteUInt16(bytes, 2, vehicleCode);
WriteUInt16(bytes, 4, node);
WriteUInt16(bytes, 25, angle);
WriteSingle(bytes, 16, current);
WriteSingle(bytes, 20, voltage);
bytes[14] = state;
bytes[15] = charge;
bytes[24] = speed;
bytes[27] = task;
bytes[28] = lift;
bytes[29] = roll;
bytes[31] = End;
return bytes;
}
public static string CommandName(byte command)
{
switch (command)
{
case CmdQueryState: return "Query(0x00)";
case CmdStart: return "Start(0x01)";
case CmdStop: return "Stop(0x02)";
case CmdSpeed: return "Speed(0x03)";
case CmdAngle: return "Angle(0x04)";
case CmdLegacyNode: return "LegacyNode(0x05)";
case CmdTask: return "Task(0xA1)";
default: return $"Unknown(0x{command:X2})";
}
}
public static string StateText(byte state)
{
switch (state)
{
case StateIdle: return "未准备";
case StateRunning: return "运行中";
case StateStopping: return "停止中";
case StateCharging: return "充电中";
default: return $"未知({state})";
}
}
public static string ToHex(byte[] bytes)
{
return bytes == null ? string.Empty : BitConverter.ToString(bytes).Replace("-", " ");
}
public static ushort ReadUInt16(byte[] bytes, int offset)
{
return (ushort)((bytes[offset] << 8) | bytes[offset + 1]);
}
public static void WriteUInt16(byte[] bytes, int offset, ushort value)
{
bytes[offset] = (byte)(value >> 8);
bytes[offset + 1] = (byte)(value & 0xFF);
}
public static void WriteSingle(byte[] bytes, int offset, float value)
{
var raw = BitConverter.GetBytes(value);
if (BitConverter.IsLittleEndian)
{
Array.Reverse(raw);
}
Buffer.BlockCopy(raw, 0, bytes, offset, 4);
}
}
public sealed class MagCarSimRequest
{
public byte Command { get; set; }
public ushort VehicleCode { get; set; }
public ushort Data0 { get; set; }
public ushort Data1 { get; set; }
}
}
@@ -0,0 +1,114 @@
using System;
using System.Collections.Generic;
using System.IO;
using Newtonsoft.Json;
namespace StandardScene.MagCarSimulator
{
public sealed class MagCarSimVehicleConfig
{
public string Name { get; set; } = "AGV";
public ushort VehicleCode { get; set; } = 1;
public int ListenPort { get; set; } = 5001;
public int StartSiteId { get; set; } = 1;
public int EndSiteId { get; set; } = 2;
public int IntervalMs { get; set; } = 2000;
/// <summary>显式环线站点序列(不含回到起点的重复点)。有值时优先于起点/终点寻路。</summary>
public List<int> LoopSiteIds { get; set; }
}
public sealed class MagCarSimConfig
{
public string MapPath { get; set; } = "";
public bool UseTagValueAsNode { get; set; }
public string ListenAddress { get; set; } = "0.0.0.0";
public bool LogRawFrames { get; set; }
public bool LogQueries { get; set; }
/// <summary>文件日志根目录(相对 exe 或绝对路径),默认 log。</summary>
public string LogDirectory { get; set; } = "log";
/// <summary>是否写入 log 目录下的 magcar-sim_yyyyMMdd.log。</summary>
public bool EnableFileLog { get; set; } = true;
public int DefaultIntervalMs { get; set; } = 2000;
/// <summary>同一环线后车相对前车的发车间隔。≤0 时用该车 IntervalMs。</summary>
public int LaunchStaggerMs { get; set; }
/// <summary>磁条交管表。空则按地图目录推断 SimpleLite/config/Signal/mag-control-areas.json。</summary>
public string ControlAreasPath { get; set; }
public List<MagCarSimVehicleConfig> Vehicles { get; set; } = new List<MagCarSimVehicleConfig>();
public static string SettingsPath => Path.Combine(AppContext.BaseDirectory, "appsettings.json");
public static MagCarSimConfig Load(string path = null)
{
path ??= SettingsPath;
if (!File.Exists(path))
{
return CreateDefault();
}
var json = File.ReadAllText(path);
var config = JsonConvert.DeserializeObject<MagCarSimConfig>(json) ?? CreateDefault();
if (config.Vehicles == null || config.Vehicles.Count == 0)
{
config.Vehicles = CreateDefault().Vehicles;
}
if (config.DefaultIntervalMs <= 0)
{
config.DefaultIntervalMs = 2000;
}
foreach (var vehicle in config.Vehicles)
{
if (vehicle.IntervalMs <= 0)
{
vehicle.IntervalMs = config.DefaultIntervalMs;
}
if (string.IsNullOrWhiteSpace(vehicle.Name))
{
vehicle.Name = $"AGV-{vehicle.VehicleCode}";
}
if (vehicle.LoopSiteIds != null && vehicle.LoopSiteIds.Count > 0)
{
vehicle.LoopSiteIds = MapPathFinder.NormalizeLoop(vehicle.LoopSiteIds);
}
}
return config;
}
public void Save(string path = null)
{
path ??= SettingsPath;
var json = JsonConvert.SerializeObject(this, Formatting.Indented);
File.WriteAllText(path, json);
}
public static MagCarSimConfig CreateDefault()
{
return new MagCarSimConfig
{
DefaultIntervalMs = 2000,
Vehicles =
{
new MagCarSimVehicleConfig
{
Name = "AGV-1",
VehicleCode = 1,
ListenPort = 5001,
StartSiteId = 1,
EndSiteId = 6,
IntervalMs = 2000
}
}
};
}
}
}
@@ -0,0 +1,335 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
namespace StandardScene.MagCarSimulator
{
public sealed class MagCarSimHost : IDisposable
{
private readonly object _syncRoot = new object();
private readonly List<MagCarSimVehicle> _vehicles = new List<MagCarSimVehicle>();
private Timer _timer;
private SimpleLiteMap _map;
private int _tickBusy;
public MagCarSimHost(MagCarSimConfig config)
{
Config = config ?? throw new ArgumentNullException(nameof(config));
}
public MagCarSimConfig Config { get; }
public SimpleLiteMap Map => _map;
public bool IsListening { get; private set; }
public bool IsLooping { get; private set; }
public IReadOnlyList<MagCarSimVehicle> Vehicles
{
get
{
lock (_syncRoot)
{
return _vehicles.ToList();
}
}
}
public SimpleLiteMap LoadMap(string path)
{
var map = SimpleLiteMap.Load(path, Config.UseTagValueAsNode);
var controlPath = MagControlAreaFile.GuessPath(map.FilePath, Config.ControlAreasPath);
var controlSites = MagControlAreaFile.LoadSiteIds(controlPath);
map.AddSingleOccupancySites(controlSites);
lock (_syncRoot)
{
_map = map;
Config.MapPath = map.FilePath;
if (!string.IsNullOrWhiteSpace(controlPath))
{
Config.ControlAreasPath = controlPath;
}
foreach (var vehicle in _vehicles)
{
vehicle.AttachMap(map);
}
}
var exclusive = map.ListSingleOccupancySiteIds();
MagCarSimLog.WriteLine(
$"[{DateTime.Now:HH:mm:ss.fff}] 已加载地图 {map.FileName},站点 {map.Sites.Count},路径 {map.Tracks.Count},停止/交管点 {(exclusive.Count == 0 ? "" : string.Join(",", exclusive))}");
return map;
}
public void RebuildVehicles(IEnumerable<MagCarSimVehicleConfig> configs)
{
if (IsListening)
{
throw new InvalidOperationException("请先停止模拟再改车辆列表");
}
lock (_syncRoot)
{
DisposeVehiclesUnlocked();
foreach (var config in configs ?? Array.Empty<MagCarSimVehicleConfig>())
{
if (config == null)
{
continue;
}
var vehicle = new MagCarSimVehicle(Clone(config), Config);
if (_map != null)
{
vehicle.AttachMap(_map);
}
_vehicles.Add(vehicle);
}
}
}
public void StartListen()
{
List<MagCarSimVehicle> vehicles;
SimpleLiteMap map;
lock (_syncRoot)
{
if (IsListening)
{
return;
}
if (_map == null)
{
throw new InvalidOperationException("请先加载 SimpleLite 地图");
}
if (_vehicles.Count == 0)
{
throw new InvalidOperationException("没有可启动的车辆");
}
EnsureUniquePortsUnlocked();
vehicles = _vehicles.ToList();
map = _map;
EnsureTimerUnlocked();
IsListening = true;
}
foreach (var vehicle in vehicles)
{
vehicle.AttachMap(map);
vehicle.StartListen();
}
}
public void StartLoops()
{
if (!IsListening)
{
StartListen();
}
List<MagCarSimVehicle> vehicles;
lock (_syncRoot)
{
vehicles = _vehicles.ToList();
IsLooping = true;
}
var now = DateTime.Now;
foreach (var group in vehicles.GroupBy(RingKey).OrderBy(g => g.Key))
{
var members = group.OrderBy(v => v.VehicleCode).ToList();
var dispatchAt = now;
foreach (var vehicle in members)
{
vehicle.StartLoop(dispatchAt);
dispatchAt = dispatchAt.AddMilliseconds(StaggerMs(vehicle));
}
MagCarSimLog.WriteLine(
$"[{DateTime.Now:HH:mm:ss.fff}] 同环依次发车 {members[0].Name} 等 {members.Count} 台,间隔 {StaggerMs(members[0])}ms");
}
}
public void StopAll()
{
Timer timer;
List<MagCarSimVehicle> vehicles;
lock (_syncRoot)
{
timer = _timer;
_timer = null;
IsListening = false;
IsLooping = false;
vehicles = _vehicles.ToList();
}
try
{
timer?.Dispose();
}
catch
{
}
foreach (var vehicle in vehicles)
{
vehicle.StopLoop();
vehicle.StopListen();
}
}
public bool TryRelease(ushort vehicleCode)
{
lock (_syncRoot)
{
var vehicle = _vehicles.FirstOrDefault(v => v.VehicleCode == vehicleCode);
if (vehicle == null)
{
return false;
}
vehicle.Release();
return true;
}
}
public List<MagCarSimVehicleSnapshot> Snapshots()
{
lock (_syncRoot)
{
return _vehicles.Select(v => v.Snapshot()).ToList();
}
}
public void Dispose()
{
StopAll();
lock (_syncRoot)
{
DisposeVehiclesUnlocked();
}
}
private void EnsureTimerUnlocked()
{
_timer ??= new Timer(_ => OnTick(), null, 50, 50);
}
private void OnTick()
{
if (Interlocked.Exchange(ref _tickBusy, 1) != 0)
{
return;
}
try
{
List<MagCarSimVehicle> vehicles;
SimpleLiteMap map;
lock (_syncRoot)
{
vehicles = _vehicles.ToList();
map = _map;
}
var occupancy = SnapshotExclusiveOccupancy(map, vehicles);
var now = DateTime.Now;
foreach (var vehicle in vehicles)
{
vehicle.Tick(now, occupancy);
}
}
finally
{
Interlocked.Exchange(ref _tickBusy, 0);
}
}
private void EnsureUniquePortsUnlocked()
{
var seen = new HashSet<int>();
foreach (var vehicle in _vehicles)
{
if (!seen.Add(vehicle.ListenPort))
{
throw new InvalidOperationException($"端口 {vehicle.ListenPort} 被多辆车占用");
}
}
}
private static Dictionary<int, ushort> SnapshotExclusiveOccupancy(
SimpleLiteMap map,
IEnumerable<MagCarSimVehicle> vehicles)
{
var occupancy = new Dictionary<int, ushort>();
if (map == null)
{
return occupancy;
}
foreach (var vehicle in vehicles)
{
if (vehicle.CurrentSiteId <= 0 ||
!map.SingleOccupancy(vehicle.CurrentSiteId) ||
occupancy.ContainsKey(vehicle.CurrentSiteId))
{
continue;
}
occupancy[vehicle.CurrentSiteId] = vehicle.VehicleCode;
}
return occupancy;
}
private int StaggerMs(MagCarSimVehicle vehicle)
{
if (Config.LaunchStaggerMs > 0)
{
return Math.Max(200, Config.LaunchStaggerMs);
}
var interval = vehicle.Config.IntervalMs > 0 ? vehicle.Config.IntervalMs : Config.DefaultIntervalMs;
return Math.Max(200, interval);
}
private static string RingKey(MagCarSimVehicle vehicle)
{
var ids = vehicle.Config.LoopSiteIds;
if (ids != null && ids.Count > 0)
{
return string.Join("-", ids);
}
return $"s{vehicle.Config.StartSiteId}-e{vehicle.Config.EndSiteId}";
}
private void DisposeVehiclesUnlocked()
{
foreach (var vehicle in _vehicles)
{
vehicle.Dispose();
}
_vehicles.Clear();
}
private static MagCarSimVehicleConfig Clone(MagCarSimVehicleConfig source)
{
return new MagCarSimVehicleConfig
{
Name = source.Name,
VehicleCode = source.VehicleCode,
ListenPort = source.ListenPort,
StartSiteId = source.StartSiteId,
EndSiteId = source.EndSiteId,
IntervalMs = source.IntervalMs,
LoopSiteIds = source.LoopSiteIds == null ? null : new List<int>(source.LoopSiteIds)
};
}
}
}
@@ -0,0 +1,499 @@
using System;
using System.Collections.Generic;
using System.Net;
namespace StandardScene.MagCarSimulator
{
public sealed class MagCarSimVehicleSnapshot
{
public string Name { get; set; }
public ushort VehicleCode { get; set; }
public int ListenPort { get; set; }
public int StartSiteId { get; set; }
public int EndSiteId { get; set; }
public int IntervalMs { get; set; }
public int CurrentSiteId { get; set; }
public ushort Node { get; set; }
public byte State { get; set; }
public string StateText { get; set; }
public bool WaitingRelease { get; set; }
public bool HoldingForStop { get; set; }
public bool Looping { get; set; }
public bool Listening { get; set; }
public int ClientCount { get; set; }
public string PathText { get; set; }
}
/// <summary>
/// 单车循环:普通站可叠车;停止点与交管点同时只允许一辆,后车停在上一站并保持运行中;仅 Mag_NeedStop 停车等 0x01。
/// </summary>
public sealed class MagCarSimVehicle : IDisposable
{
private readonly object _syncRoot = new object();
private readonly MagCarSimConfig _hostConfig;
private MagCarSimTcpServer _server;
private SimpleLiteMap _map;
private List<int> _loopSiteIds = new List<int>();
private int _index;
private DateTime _nextHopAt = DateTime.MaxValue;
private DateTime _dispatchAt = DateTime.MinValue;
private DateTime _lastHoldLogAt = DateTime.MinValue;
private bool _looping;
private bool _dispatched;
private bool _waitingRelease;
private bool _pausedByStopCommand;
private bool _holdingForStop;
public MagCarSimVehicle(MagCarSimVehicleConfig config, MagCarSimConfig hostConfig)
{
Config = config ?? throw new ArgumentNullException(nameof(config));
_hostConfig = hostConfig ?? throw new ArgumentNullException(nameof(hostConfig));
Name = string.IsNullOrWhiteSpace(config.Name) ? $"AGV-{config.VehicleCode}" : config.Name;
Charge = 100;
Voltage = 24f;
Speed = 50;
}
public MagCarSimVehicleConfig Config { get; }
public string Name { get; }
public ushort VehicleCode => Config.VehicleCode;
public int ListenPort => Config.ListenPort;
public int CurrentSiteId { get; private set; }
public ushort Node { get; private set; }
public byte State { get; private set; }
public byte Charge { get; private set; }
public float Current { get; private set; }
public float Voltage { get; private set; }
public byte Speed { get; private set; }
public ushort Angle { get; private set; }
public byte Task { get; private set; }
public byte Lift { get; private set; }
public byte Roll { get; private set; }
public bool WaitingRelease => _waitingRelease;
public bool HoldingForStop => _holdingForStop;
public bool Looping => _looping;
public bool Listening => _server != null && _server.IsRunning;
public void AttachMap(SimpleLiteMap map)
{
lock (_syncRoot)
{
_map = map;
if (!_looping)
{
PlaceAtStartUnlocked();
}
}
}
public void StartListen()
{
MagCarSimTcpServer server;
lock (_syncRoot)
{
if (_server != null && _server.IsRunning)
{
return;
}
_server?.Dispose();
var address = string.IsNullOrWhiteSpace(_hostConfig.ListenAddress)
? "0.0.0.0"
: _hostConfig.ListenAddress;
server = new MagCarSimTcpServer(IPAddress.Parse(address), Config.ListenPort, HandleFrame);
_server = server;
PlaceAtStartUnlocked();
Log($"监听 {address}:{Config.ListenPort},当前站={CurrentSiteId} node={Node} {MagCarSimProtocol.StateText(State)}");
}
server.Start();
}
public void StopListen()
{
MagCarSimTcpServer server;
lock (_syncRoot)
{
server = _server;
_server = null;
}
server?.Dispose();
}
public void StartLoop(DateTime dispatchAt)
{
lock (_syncRoot)
{
if (_map == null)
{
throw new InvalidOperationException($"{Name} 尚未加载地图");
}
_loopSiteIds = MapPathFinder.ResolveLoop(_map, Config);
_index = 0;
if (Config.StartSiteId > 0)
{
var found = _loopSiteIds.IndexOf(Config.StartSiteId);
if (found >= 0)
{
_index = found;
}
}
_looping = true;
_dispatched = false;
_holdingForStop = false;
_pausedByStopCommand = false;
_waitingRelease = false;
_dispatchAt = dispatchAt;
PlaceAtStartUnlocked();
// 待命阶段若起点是停止点,先报运行中,避免交管误放行并占住 pending,导致真发车后长时间不放。
if (_map != null && _map.NeedStop(CurrentSiteId))
{
State = MagCarSimProtocol.StateRunning;
_waitingRelease = false;
}
var delayMs = Math.Max(0, (int)(dispatchAt - DateTime.Now).TotalMilliseconds);
Log($"待命发车,环线 {MapPathFinder.FormatPath(_loopSiteIds, 16)},间隔 {IntervalOrDefault()}ms" +
(delayMs > 50 ? $"{delayMs}ms 后启动" : ",立即启动"));
}
}
public void StopLoop()
{
lock (_syncRoot)
{
_looping = false;
_dispatched = false;
_waitingRelease = false;
_holdingForStop = false;
_pausedByStopCommand = false;
_nextHopAt = DateTime.MaxValue;
State = MagCarSimProtocol.StateStopping;
Log("已停止循环");
}
}
public void Release()
{
lock (_syncRoot)
{
ApplyStartUnlocked("界面放行");
}
}
public void Tick(DateTime now, Dictionary<int, ushort> occupancy)
{
lock (_syncRoot)
{
if (!_looping || _pausedByStopCommand)
{
return;
}
if (!_dispatched)
{
TryDispatchUnlocked(now, occupancy);
return;
}
if (_waitingRelease)
{
return;
}
if (State != MagCarSimProtocol.StateRunning)
{
return;
}
if (now < _nextHopAt)
{
return;
}
AdvanceUnlocked(now, occupancy);
}
}
public MagCarSimVehicleSnapshot Snapshot()
{
lock (_syncRoot)
{
return new MagCarSimVehicleSnapshot
{
Name = Name,
VehicleCode = VehicleCode,
ListenPort = ListenPort,
StartSiteId = Config.StartSiteId,
EndSiteId = Config.EndSiteId,
IntervalMs = Config.IntervalMs,
CurrentSiteId = CurrentSiteId,
Node = Node,
State = State,
StateText = MagCarSimProtocol.StateText(State),
WaitingRelease = _waitingRelease,
HoldingForStop = _holdingForStop,
Looping = _looping,
Listening = Listening,
ClientCount = _server?.ClientCount ?? 0,
PathText = MapPathFinder.FormatPath(_loopSiteIds, 12)
};
}
}
public void Dispose()
{
lock (_syncRoot)
{
_looping = false;
}
StopListen();
}
private byte[] HandleFrame(byte[] request)
{
lock (_syncRoot)
{
if (!MagCarSimProtocol.TryParse(request, out var parsed))
{
Log("收到非法帧");
return BuildStatusUnlocked(MagCarSimProtocol.CmdQueryState);
}
if (parsed.VehicleCode != 0 && parsed.VehicleCode != VehicleCode)
{
Log($"帧车号 {parsed.VehicleCode} 与本车 {VehicleCode} 不一致,仍按本车应答");
}
if (_hostConfig.LogRawFrames)
{
Log($"RX {MagCarSimProtocol.CommandName(parsed.Command)} {MagCarSimProtocol.ToHex(request)}");
}
else if (parsed.Command != MagCarSimProtocol.CmdQueryState || _hostConfig.LogQueries)
{
Log($"RX {MagCarSimProtocol.CommandName(parsed.Command)}");
}
switch (parsed.Command)
{
case MagCarSimProtocol.CmdStart:
ApplyStartUnlocked("0x01");
break;
case MagCarSimProtocol.CmdStop:
_pausedByStopCommand = true;
State = MagCarSimProtocol.StateStopping;
Log("收到停止,暂停循环");
break;
case MagCarSimProtocol.CmdSpeed:
Speed = (byte)Math.Clamp((int)parsed.Data0 >> 8, 0, 100);
break;
case MagCarSimProtocol.CmdAngle:
Angle = parsed.Data0;
break;
case MagCarSimProtocol.CmdLegacyNode:
case MagCarSimProtocol.CmdTask:
Log($"忽略路径命令 {MagCarSimProtocol.CommandName(parsed.Command)}(本模拟器按 WinForm 循环跑)");
break;
}
return BuildStatusUnlocked(parsed.Command);
}
}
private void ApplyStartUnlocked(string source)
{
if (!_dispatched)
{
Log($"待命中忽略放行({source}");
return;
}
if (_waitingRelease)
{
_waitingRelease = false;
_pausedByStopCommand = false;
State = MagCarSimProtocol.StateRunning;
_nextHopAt = DateTime.Now.AddMilliseconds(IntervalOrDefault());
Log($"放行成功({source}),站点 {CurrentSiteId} 改为运行中,{IntervalOrDefault()}ms 后前往下一站");
return;
}
if (_pausedByStopCommand && _looping)
{
_pausedByStopCommand = false;
State = MagCarSimProtocol.StateRunning;
_nextHopAt = DateTime.Now.AddMilliseconds(IntervalOrDefault());
Log($"从暂停恢复({source}");
return;
}
if (_looping && State != MagCarSimProtocol.StateRunning)
{
State = MagCarSimProtocol.StateRunning;
_nextHopAt = DateTime.Now.AddMilliseconds(IntervalOrDefault());
Log($"启动运行({source}");
}
}
private void PlaceAtStartUnlocked()
{
CurrentSiteId = Config.StartSiteId;
Node = _map == null ? (ushort)Math.Clamp(Config.StartSiteId, 0, ushort.MaxValue) : _map.ResolveNode(Config.StartSiteId);
State = MagCarSimProtocol.StateStopping;
_waitingRelease = false;
_pausedByStopCommand = false;
}
private void ArriveUnlocked(int siteId, bool isStart)
{
CurrentSiteId = siteId;
Node = _map.ResolveNode(siteId);
var needStop = _map.NeedStop(siteId);
if (needStop)
{
State = MagCarSimProtocol.StateStopping;
_waitingRelease = true;
_nextHopAt = DateTime.MaxValue;
Log($"{(isStart ? "" : "")} {siteId} node={Node} Mag_NeedStop=true → 停止中,等待放行");
return;
}
State = MagCarSimProtocol.StateRunning;
_waitingRelease = false;
_nextHopAt = DateTime.Now.AddMilliseconds(IntervalOrDefault());
if (isStart)
{
Log($"起点 {siteId} node={Node} 过站,{IntervalOrDefault()}ms 后下一地标");
}
}
private void TryDispatchUnlocked(DateTime now, Dictionary<int, ushort> occupancy)
{
if (now < _dispatchAt)
{
return;
}
if (_loopSiteIds.Count == 0)
{
return;
}
var startSite = _loopSiteIds[_index];
if (IsOccupiedUnlocked(startSite, occupancy))
{
HoldForOccupiedUnlocked(now, startSite, "发车");
return;
}
OccupyUnlocked(occupancy, startSite);
_dispatched = true;
_holdingForStop = false;
ArriveUnlocked(startSite, isStart: true);
}
private void AdvanceUnlocked(DateTime now, Dictionary<int, ushort> occupancy)
{
if (_loopSiteIds.Count < 2)
{
return;
}
var nextIndex = (_index + 1) % _loopSiteIds.Count;
var nextSite = _loopSiteIds[nextIndex];
if (IsOccupiedUnlocked(nextSite, occupancy))
{
HoldForOccupiedUnlocked(now, nextSite, "进站");
return;
}
MoveOccupancyUnlocked(occupancy, CurrentSiteId, nextSite);
_holdingForStop = false;
_index = nextIndex;
ArriveUnlocked(nextSite, isStart: false);
}
private bool IsOccupiedUnlocked(int siteId, Dictionary<int, ushort> occupancy)
{
if (_map == null || occupancy == null || !_map.SingleOccupancy(siteId))
{
return false;
}
return occupancy.TryGetValue(siteId, out var occupier) && occupier != VehicleCode;
}
private void OccupyUnlocked(Dictionary<int, ushort> occupancy, int siteId)
{
if (occupancy == null || siteId <= 0 || _map == null || !_map.SingleOccupancy(siteId))
{
return;
}
occupancy[siteId] = VehicleCode;
}
private void MoveOccupancyUnlocked(Dictionary<int, ushort> occupancy, int fromSiteId, int toSiteId)
{
if (occupancy == null)
{
return;
}
if (fromSiteId > 0 &&
occupancy.TryGetValue(fromSiteId, out var occupier) &&
occupier == VehicleCode)
{
occupancy.Remove(fromSiteId);
}
OccupyUnlocked(occupancy, toSiteId);
}
private void HoldForOccupiedUnlocked(DateTime now, int occupiedSiteId, string action)
{
_holdingForStop = true;
State = MagCarSimProtocol.StateRunning;
_waitingRelease = false;
_nextHopAt = now.AddMilliseconds(200);
if ((now - _lastHoldLogAt).TotalMilliseconds >= 5000)
{
_lastHoldLogAt = now;
Log($"停止/交管点 {occupiedSiteId} 已有车,{action}受阻,保持站点 {CurrentSiteId} 运行中");
}
}
private int IntervalOrDefault()
{
var value = Config.IntervalMs > 0 ? Config.IntervalMs : _hostConfig.DefaultIntervalMs;
return Math.Max(200, value);
}
private byte[] BuildStatusUnlocked(byte command)
{
return MagCarSimProtocol.BuildStatus(
command,
VehicleCode,
Node,
State,
Charge,
Current,
Voltage,
Speed,
Angle,
Task,
Lift,
Roll);
}
private void Log(string message)
{
MagCarSimLog.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] [{Name}/{VehicleCode}] {message}");
}
}
}
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<RootNamespace>StandardScene.MagCarSimulator</RootNamespace>
<AssemblyName>StandardScene.MagCarSimulator</AssemblyName>
<LangVersion>latest</LangVersion>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>disable</Nullable>
<Deterministic>true</Deterministic>
<UseWindowsForms>true</UseWindowsForms>
<ApplicationIcon />
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
@@ -0,0 +1,574 @@
{
"MapPath": "D:\\工作\\stand\\SimpleLite\\maps\\复杂交管.json",
"UseTagValueAsNode": false,
"ListenAddress": "0.0.0.0",
"LogRawFrames": false,
"LogQueries": false,
"LogDirectory": "log",
"EnableFileLog": true,
"DefaultIntervalMs": 2000,
"LaunchStaggerMs": 0,
"ControlAreasPath": "D:\\工作\\stand\\SimpleLite\\config\\Signal\\mag-control-areas.json",
"Vehicles": [
{
"Name": "西环-1",
"VehicleCode": 1,
"ListenPort": 5001,
"StartSiteId": 1,
"EndSiteId": 7,
"IntervalMs": 1800,
"LoopSiteIds": [
1,
2,
3,
4,
5,
104,
100,
103,
6,
7,
8,
9,
10,
11,
12,
201,
13,
14
]
},
{
"Name": "西环-2",
"VehicleCode": 2,
"ListenPort": 5002,
"StartSiteId": 4,
"EndSiteId": 7,
"IntervalMs": 1880,
"LoopSiteIds": [
1,
2,
3,
4,
5,
104,
100,
103,
6,
7,
8,
9,
10,
11,
12,
201,
13,
14
]
},
{
"Name": "西环-3",
"VehicleCode": 3,
"ListenPort": 5003,
"StartSiteId": 8,
"EndSiteId": 7,
"IntervalMs": 1960,
"LoopSiteIds": [
1,
2,
3,
4,
5,
104,
100,
103,
6,
7,
8,
9,
10,
11,
12,
201,
13,
14
]
},
{
"Name": "西环-4",
"VehicleCode": 4,
"ListenPort": 5004,
"StartSiteId": 12,
"EndSiteId": 7,
"IntervalMs": 2040,
"LoopSiteIds": [
1,
2,
3,
4,
5,
104,
100,
103,
6,
7,
8,
9,
10,
11,
12,
201,
13,
14
]
},
{
"Name": "西环-5",
"VehicleCode": 5,
"ListenPort": 5005,
"StartSiteId": 13,
"EndSiteId": 7,
"IntervalMs": 2120,
"LoopSiteIds": [
1,
2,
3,
4,
5,
104,
100,
103,
6,
7,
8,
9,
10,
11,
12,
201,
13,
14
]
},
{
"Name": "东环-1",
"VehicleCode": 6,
"ListenPort": 5006,
"StartSiteId": 15,
"EndSiteId": 23,
"IntervalMs": 1900,
"LoopSiteIds": [
15,
16,
17,
18,
19,
20,
202,
21,
22,
23,
24,
25,
26,
6,
103,
100,
104,
5
]
},
{
"Name": "东环-2",
"VehicleCode": 7,
"ListenPort": 5007,
"StartSiteId": 18,
"EndSiteId": 23,
"IntervalMs": 1980,
"LoopSiteIds": [
15,
16,
17,
18,
19,
20,
202,
21,
22,
23,
24,
25,
26,
6,
103,
100,
104,
5
]
},
{
"Name": "东环-3",
"VehicleCode": 8,
"ListenPort": 5008,
"StartSiteId": 20,
"EndSiteId": 23,
"IntervalMs": 2060,
"LoopSiteIds": [
15,
16,
17,
18,
19,
20,
202,
21,
22,
23,
24,
25,
26,
6,
103,
100,
104,
5
]
},
{
"Name": "东环-4",
"VehicleCode": 9,
"ListenPort": 5009,
"StartSiteId": 22,
"EndSiteId": 23,
"IntervalMs": 2140,
"LoopSiteIds": [
15,
16,
17,
18,
19,
20,
202,
21,
22,
23,
24,
25,
26,
6,
103,
100,
104,
5
]
},
{
"Name": "东环-5",
"VehicleCode": 10,
"ListenPort": 5010,
"StartSiteId": 25,
"EndSiteId": 23,
"IntervalMs": 2220,
"LoopSiteIds": [
15,
16,
17,
18,
19,
20,
202,
21,
22,
23,
24,
25,
26,
6,
103,
100,
104,
5
]
},
{
"Name": "北环-1",
"VehicleCode": 11,
"ListenPort": 5011,
"StartSiteId": 35,
"EndSiteId": 28,
"IntervalMs": 2000,
"LoopSiteIds": [
35,
36,
9,
37,
38,
27,
101,
100,
102,
28,
29,
30,
24,
31,
32,
33,
203,
34
]
},
{
"Name": "北环-2",
"VehicleCode": 12,
"ListenPort": 5012,
"StartSiteId": 37,
"EndSiteId": 28,
"IntervalMs": 2080,
"LoopSiteIds": [
35,
36,
9,
37,
38,
27,
101,
100,
102,
28,
29,
30,
24,
31,
32,
33,
203,
34
]
},
{
"Name": "北环-3",
"VehicleCode": 13,
"ListenPort": 5013,
"StartSiteId": 29,
"EndSiteId": 28,
"IntervalMs": 2160,
"LoopSiteIds": [
35,
36,
9,
37,
38,
27,
101,
100,
102,
28,
29,
30,
24,
31,
32,
33,
203,
34
]
},
{
"Name": "北环-4",
"VehicleCode": 14,
"ListenPort": 5014,
"StartSiteId": 32,
"EndSiteId": 28,
"IntervalMs": 2240,
"LoopSiteIds": [
35,
36,
9,
37,
38,
27,
101,
100,
102,
28,
29,
30,
24,
31,
32,
33,
203,
34
]
},
{
"Name": "北环-5",
"VehicleCode": 15,
"ListenPort": 5015,
"StartSiteId": 203,
"EndSiteId": 28,
"IntervalMs": 2320,
"LoopSiteIds": [
35,
36,
9,
37,
38,
27,
101,
100,
102,
28,
29,
30,
24,
31,
32,
33,
203,
34
]
},
{
"Name": "南环-1",
"VehicleCode": 16,
"ListenPort": 5016,
"StartSiteId": 39,
"EndSiteId": 28,
"IntervalMs": 2100,
"LoopSiteIds": [
39,
40,
204,
41,
42,
43,
17,
44,
45,
28,
102,
100,
101,
27,
46,
47,
2,
48
]
},
{
"Name": "南环-2",
"VehicleCode": 17,
"ListenPort": 5017,
"StartSiteId": 204,
"EndSiteId": 28,
"IntervalMs": 2180,
"LoopSiteIds": [
39,
40,
204,
41,
42,
43,
17,
44,
45,
28,
102,
100,
101,
27,
46,
47,
2,
48
]
},
{
"Name": "南环-3",
"VehicleCode": 18,
"ListenPort": 5018,
"StartSiteId": 42,
"EndSiteId": 28,
"IntervalMs": 2260,
"LoopSiteIds": [
39,
40,
204,
41,
42,
43,
17,
44,
45,
28,
102,
100,
101,
27,
46,
47,
2,
48
]
},
{
"Name": "南环-4",
"VehicleCode": 19,
"ListenPort": 5019,
"StartSiteId": 45,
"EndSiteId": 28,
"IntervalMs": 2340,
"LoopSiteIds": [
39,
40,
204,
41,
42,
43,
17,
44,
45,
28,
102,
100,
101,
27,
46,
47,
2,
48
]
},
{
"Name": "南环-5",
"VehicleCode": 20,
"ListenPort": 5020,
"StartSiteId": 47,
"EndSiteId": 28,
"IntervalMs": 2420,
"LoopSiteIds": [
39,
40,
204,
41,
42,
43,
17,
44,
45,
28,
102,
100,
101,
27,
46,
47,
2,
48
]
}
]
}
+1 -1
View File
@@ -79,7 +79,7 @@ namespace StandardScene.CarTypes
[FieldMember] public bool EnableDetailLog = true;
[FieldMember] public bool 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;
+259 -6
View File
@@ -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": "单牵引下线机构"
}
]
+17 -4
View File
@@ -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"))
{
+24 -31
View File
@@ -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)
+87 -20
View File
@@ -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 成功后为 trueReloadConfig 仅在已启动时重启 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&lt;=0 时回退进程级 <see cref="PlcPort"/>。
/// 机构 Port&lt;=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>地图站点号,与车 PositionIdsiteID / 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>JGControlRequestIn / ArrivedPointControlUP1 / 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();
}
}
+47 -45
View File
@@ -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>心跳位,07。</summary>
[DisplayName("心跳位")]
public int HeartBit { get; set; }
/// <summary>上线 / 单牵引下线 / 双牵引下线 / 等待通行,决定 AutoLeave 分支。</summary>
[DisplayName("上下机构")]
public SignalLineType LineType { get; set; } = SignalLineType.;
/// <summary>旧配置读字节地址。有工位表后不再使用;无工位表时合成 default 工位。</summary>
[Browsable(false)]
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
public string ReadDb { get; set; }
/// <summary>旧配置写字节地址。</summary>
[Browsable(false)]
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
public string WriteDb { get; set; }
/// <summary>旧配置心跳位整段地址,优先于心跳字节/位。</summary>
[Browsable(false)]
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
public string HeartDb { get; set; }
[Browsable(false)]
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
public int JgInPointId { get; set; }
[Browsable(false)]
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
public string JgInContains { get; set; }
[Browsable(false)]
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
public int JgPointId { get; set; }
[Browsable(false)]
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
public string JgContains { get; set; }
}
}
@@ -10,6 +10,7 @@ namespace StandardScene.Signal.Model
/// 同一站点不要同时配握手点:扫车先握手再放行,可能把还在等允许进入的车开走。
/// 站点号不能重复,列表编辑器用 Sit 做主键。
/// </remarks>
[SignalTable("release", "release-points.json", "放行点", Order = 4)]
[Category("放行点数据管理")]
[DisplayName("放行点配置列表")]
public class ReleasePointModel
+28
View File
@@ -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; }
}
}
+63
View File
@@ -0,0 +1,63 @@
using System;
using System.Text.RegularExpressions;
namespace StandardScene.Signal.Plc
{
/// <summary>西门子 DB 字节/位地址拼装与缓冲区位操作。</summary>
internal static class PlcAddress
{
private static readonly Regex Addr = new Regex(
@"^DB(\d+)\.(?:DBB|DBX|DBW)?(\d+)(?:\.(\d+))?$",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
public static string Bit(int db, int absByte, int bit) =>
$"DB{Math.Max(1, db)}.DBX{Math.Max(0, absByte)}.{ClampBit(bit)}";
public static string Byte(int db, int absByte) =>
$"DB{Math.Max(1, db)}.DBB{Math.Max(0, absByte)}";
public static int ClampBit(int bit)
{
if (bit < 0) return 0;
if (bit > 7) return 7;
return bit;
}
public static bool TryParse(string address, out int db, out int absByte, out int bit)
{
db = 0;
absByte = 0;
bit = 0;
if (string.IsNullOrWhiteSpace(address))
return false;
var m = Addr.Match(address.Trim());
if (!m.Success)
return false;
db = int.Parse(m.Groups[1].Value);
absByte = int.Parse(m.Groups[2].Value);
if (m.Groups[3].Success)
bit = ClampBit(int.Parse(m.Groups[3].Value));
return true;
}
public static bool GetBit(byte[] buffer, int originByte, int absByte, int bit)
{
var i = absByte - originByte;
if (buffer == null || i < 0 || i >= buffer.Length)
return false;
return (buffer[i] & (1 << ClampBit(bit))) != 0;
}
public static void SetBit(byte[] buffer, int originByte, int absByte, int bit, bool value)
{
var i = absByte - originByte;
if (buffer == null || i < 0 || i >= buffer.Length)
return;
var mask = (byte)(1 << ClampBit(bit));
if (value)
buffer[i] |= mask;
else
buffer[i] = (byte)(buffer[i] & ~mask);
}
}
}
@@ -0,0 +1,89 @@
using System;
using System.Collections.Generic;
using System.Linq;
using StandardScene.Signal.Model;
namespace StandardScene.Signal.Plc
{
/// <summary>
/// 加载机构 + 工位。没有工位表时,用旧机构行的 ReadDb/WriteDb/地标合成 default 工位(一字节枚举)。
/// </summary>
internal static class PlcConfigLoader
{
public const string DefaultDockId = "default";
public sealed class StationBundle
{
public PlcStationModel Station { get; init; }
public List<PlcStationDockModel> Docks { get; init; }
}
public static List<StationBundle> Load(string stationsPath, string docksPath)
{
var stations = SignalConfigStore.Load<PlcStationModel>(stationsPath)
.Where(s => s != null && !string.IsNullOrWhiteSpace(s.JgName))
.ToList();
var docks = SignalConfigStore.Load<PlcStationDockModel>(docksPath)
.Where(d => d != null && !string.IsNullOrWhiteSpace(d.JgName))
.ToList();
var byName = docks
.GroupBy(d => d.JgName.Trim(), StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
var list = new List<StationBundle>();
foreach (var station in stations)
{
if (!byName.TryGetValue(station.JgName.Trim(), out var rows) || rows.Count == 0)
rows = HasLegacyIo(station) ? new List<PlcStationDockModel> { Synthesize(station) } : new List<PlcStationDockModel>();
list.Add(new StationBundle { Station = station, Docks = rows });
}
return list;
}
public static int ResolveDb(PlcStationModel station)
{
if (station == null)
return 100;
if (station.DbNumber > 0)
return station.DbNumber;
if (PlcAddress.TryParse(station.HeartDb, out var db, out _, out _))
return db;
if (PlcAddress.TryParse(station.ReadDb, out db, out _, out _))
return db;
if (PlcAddress.TryParse(station.WriteDb, out db, out _, out _))
return db;
return 100;
}
public static string ResolveHeartAddress(PlcStationModel station)
{
if (station == null)
return "";
if (!string.IsNullOrWhiteSpace(station.HeartDb))
return station.HeartDb.Trim();
return PlcAddress.Bit(ResolveDb(station), station.HeartByte, station.HeartBit);
}
private static bool HasLegacyIo(PlcStationModel station) =>
station != null &&
(!string.IsNullOrWhiteSpace(station.ReadDb) ||
!string.IsNullOrWhiteSpace(station.WriteDb) ||
station.JgPointId > 0 ||
!string.IsNullOrWhiteSpace(station.JgContains));
private static PlcStationDockModel Synthesize(PlcStationModel station) => new PlcStationDockModel
{
JgName = station.JgName,
DockId = DefaultDockId,
UseByteIo = true,
ReadDb = station.ReadDb,
WriteDb = station.WriteDb,
JgInPointId = station.JgInPointId,
JgInContains = station.JgInContains,
JgPointId = station.JgPointId,
JgContains = station.JgContains
};
}
}
+15 -15
View File
@@ -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.;
+25 -28
View File
@@ -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(),
+194 -59
View File
@@ -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);
}
+21 -1
View File
@@ -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)
{
+1 -1
View File
@@ -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" ]
}
}
+14
View File
@@ -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