Compare commits

...
6 Commits
141 changed files with 25358 additions and 210 deletions
+6
View File
@@ -1,5 +1,11 @@
<Project>
<PropertyGroup>
<!-- FASS2.0 与 stand 为兄弟目录:$(RepoRoot)stand\ -->
<StandRoot Condition="'$(StandRoot)' == ''">$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\..\stand\'))</StandRoot>
<StandRefDir Condition="'$(StandRefDir)' == ''">$(StandRoot)ref\</StandRefDir>
<StandSimpleLiteDir Condition="'$(StandSimpleLiteDir)' == ''">$(StandRoot)SimpleLite\</StandSimpleLiteDir>
<CGUILibDir Condition="'$(CGUILibDir)' == ''">D:\MDCS\Dependencies\Commons</CGUILibDir>
<LeegKeysSdkPath Condition="'$(LeegKeysSdkPath)' == '' and Exists('$(MSBuildThisFileDirectory)StandardScene.Core\Ref\leegKeys-sdk.dll')">$(MSBuildThisFileDirectory)StandardScene.Core\Ref\leegKeys-sdk.dll</LeegKeysSdkPath>
<LeegKeysSdkPath Condition="'$(LeegKeysSdkPath)' == ''">$(StandSimpleLiteDir)plugins\leegKeys-sdk.dll</LeegKeysSdkPath>
</PropertyGroup>
</Project>
+21 -1
View File
@@ -1,7 +1,7 @@
<Project>
<!--
StandardScene 插件工程编译后,自动将产物复制到 build\plugins\SimpleComposer / SimpleLite 宿主加载目录)。
适用:StandardScene.Core + Magnetic / QrLidar / Devices / Protocol.VDA5050 等卫星插件。
适用:StandardScene.Core + Magnetic / QrLidar / Devices / Protocol.VDA5050 / Signal 等卫星插件。
-->
<PropertyGroup>
<StandardSceneRepoRoot>$(MSBuildThisFileDirectory)</StandardSceneRepoRoot>
@@ -12,6 +12,7 @@
<IsStandardScenePluginCopy Condition="'$(MSBuildProjectName)' == 'StandardScene.QrLidar'">true</IsStandardScenePluginCopy>
<IsStandardScenePluginCopy Condition="'$(MSBuildProjectName)' == 'StandardScene.Devices'">true</IsStandardScenePluginCopy>
<IsStandardScenePluginCopy Condition="'$(MSBuildProjectName)' == 'StandardScene.Protocol.VDA5050'">true</IsStandardScenePluginCopy>
<IsStandardScenePluginCopy Condition="'$(MSBuildProjectName)' == 'StandardScene.Signal'">true</IsStandardScenePluginCopy>
</PropertyGroup>
<Target Name="CopyStandardScenePluginOutputs"
@@ -28,12 +29,31 @@
<!-- Devices 插件运行时依赖 -->
<StandardScenePluginCopyFile Include="$(TargetDir)leegKeys-sdk.dll"
Condition="Exists('$(TargetDir)leegKeys-sdk.dll')" />
<StandardScenePluginCopyFile Include="$(TargetDir)IoTClient.dll"
Condition="Exists('$(TargetDir)IoTClient.dll')" />
<StandardScenePluginCopyFile Include="$(NuGetPackageRoot)iotclient\1.0.40\lib\netstandard2.0\IoTClient.dll"
Condition="'$(MSBuildProjectName)' == 'StandardScene.Signal' and Exists('$(NuGetPackageRoot)iotclient\1.0.40\lib\netstandard2.0\IoTClient.dll')" />
<StandardScenePluginCopyFile Include="$(MSBuildProjectDirectory)\Config\Signal\signal-tables.json"
Condition="'$(MSBuildProjectName)' == 'StandardScene.Signal' and Exists('$(MSBuildProjectDirectory)\Config\Signal\signal-tables.json')" />
</ItemGroup>
<Copy SourceFiles="@(StandardScenePluginCopyFile)"
DestinationFolder="$(StandardScenePluginsDir)"
SkipUnchangedFiles="true" />
<Copy SourceFiles="$(TargetPath)"
DestinationFolder="$(StandSimpleLiteDir)plugins"
SkipUnchangedFiles="true"
Condition="'$(MSBuildProjectName)' == 'StandardScene.Signal' and Exists('$(StandSimpleLiteDir)plugins')" />
<Copy SourceFiles="$(TargetDir)$(TargetName).pdb"
DestinationFolder="$(StandSimpleLiteDir)plugins"
SkipUnchangedFiles="true"
Condition="'$(MSBuildProjectName)' == 'StandardScene.Signal' and Exists('$(TargetDir)$(TargetName).pdb') and Exists('$(StandSimpleLiteDir)plugins')" />
<Copy SourceFiles="$(TargetDir)$(TargetName).scene.json"
DestinationFolder="$(StandSimpleLiteDir)plugins"
SkipUnchangedFiles="true"
Condition="'$(MSBuildProjectName)' == 'StandardScene.Signal' and Exists('$(TargetDir)$(TargetName).scene.json') and Exists('$(StandSimpleLiteDir)plugins')" />
<Message Importance="high"
Text="[StandardScene] 已复制 $(MSBuildProjectName) 插件产物到 $(StandardScenePluginsDir)" />
</Target>
+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.
@@ -335,7 +335,7 @@ namespace StandardScene.Chained
/// </summary>
/// <param name="siteId">站点ID</param>
/// <returns>找到的车辆,未找到返回 null</returns>
protected Car FindCarArrivedAtSite(int siteId)
protected virtual Car FindCarArrivedAtSite(int siteId)
{
try
{
@@ -1027,7 +1027,6 @@ namespace StandardScene.Chained
/// <param name="reverse">是否倒车,默认为 false</param>
public async Task GoSite(AbstractCar car, Site targetSite, string action = "/", bool reverse = false)
{
try
{
// 创建路径规划
@@ -1039,7 +1038,7 @@ namespace StandardScene.Chained
// 查找从当前位置到目标站点的路径
plan.FindRoute(SimpleLib.GetSite(car.GetLastSite()), targetSite);
// 编译并执行移动脚本
// 编译并执行移动脚本(默认 Forecast
var program = plan.Compile("move");
// 标记车辆为占用状态
@@ -1058,6 +1057,22 @@ namespace StandardScene.Chained
}
catch (Exception ex)
{
try
{
car.tags.Remove("occupied");
if (car is Car trafficCar && car.GetLastSite() > 0)
{
var currentSite = SimpleLib.GetSite(car.GetLastSite());
if (currentSite != null)
{
trafficCar.TrafficReset(currentSite, true, strict: false);
}
}
}
catch
{
}
// 记录异常并等待后重试
Diagnosis.Post(
$"{car.name}循环任务报错{ExceptionFormatter.FormatEx(ex)}--去往循环点id:{targetSite.id},name:{targetSite.name}",
+4 -2
View File
@@ -297,11 +297,13 @@ namespace StandardScene
&& car.status.pendingLocks.Length == 0 && !car.tags.Contains("deliver") &&//pendingLocks临时增加解决车接多任务问题
(!car.tags.Contains("shouldCharge") || needCharge)
&& !car.tags.Contains("currentWorkStep") && !car.fields.ContainsKey("StopAccept") && car.tags.Contains("Online"); //巡航任务未完成的车
if (car.name.Contains("模拟"))
// Mag2Car 联调:UDP Online 即可调度,不强制 lstatus=上线(模拟器常报 state=0 未准备)
var isMag2Car = string.Equals(car.GetType().Name, "Mag2Car", StringComparison.Ordinal);
if (car.name.Contains("模拟") || isMag2Car)
{
conditions = car.GetLastSite() != -1 &&
(!car.tags.Contains("holdCar") || !checkHoldCar) && !car.tags.Contains("occupied")
&& (!isMag2Car || car.tags.Contains("Online"))
&& car.status.pendingLocks.Length == 0 && !car.tags.Contains("deliver") &&//pendingLocks临时增加解决车接多任务问题
(!car.tags.Contains("shouldCharge") || needCharge)
&& !car.tags.Contains("currentWorkStep") && !car.fields.ContainsKey("StopAccept"); //巡航任务未完成的车
@@ -0,0 +1,75 @@
using System;
namespace StandardScene
{
/// <summary>
/// 磁条管控区放行判定结果。由信号插件「磁条交管进程」填写,Mag2Car 在 StartStop=12 停稳后消费。
/// </summary>
public sealed class MagControlReleaseDecision
{
/// <summary>当前触发点在交管表里有启用规则。false 时 Mag2Car 回退地图 <c>Fass2_ControlArea</c> 字段。</summary>
public bool RuleFound { get; set; }
public bool CanRelease { get; set; }
public string AreaId { get; set; } = string.Empty;
public int Capacity { get; set; } = 1;
public int OthersInArea { get; set; }
/// <summary>放行后写入本车报文的 StartStop,通常 11(管控启动)或 1(过站)。</summary>
public byte ReleaseStartStop { get; set; } = 11;
public string Reason { get; set; } = string.Empty;
}
/// <summary>
/// Mag2Car 与信号插件之间的管控放行钩子。不让 Magnetic / Signal 互相引用。
/// </summary>
/// <remarks>
/// 磁条交管进程启动时 <see cref="Register"/>,停止时 <see cref="Unregister"/>。
/// Mag2Car 在管控停止点调用 <see cref="TryCheck"/>:有规则用表判定,没有则用地图字段。
/// </remarks>
public static class MagControlReleaseHook
{
private static readonly object Sync = new object();
private static object _owner;
private static Func<int, int, MagControlReleaseDecision> _check;
/// <summary>注册判定函数。<paramref name="owner"/> 用于停止时只清自己的注册。</summary>
public static void Register(object owner, Func<int, int, MagControlReleaseDecision> check)
{
if (owner == null) throw new ArgumentNullException(nameof(owner));
lock (Sync)
{
_owner = owner;
_check = check;
}
}
public static void Unregister(object owner)
{
if (owner == null) return;
lock (Sync)
{
if (!ReferenceEquals(_owner, owner))
return;
_owner = null;
_check = null;
}
}
/// <summary>
/// 询问当前注册的交管进程。未注册返回 null;已注册但该触发点无规则时
/// 返回 <see cref="MagControlReleaseDecision.RuleFound"/> = false。
/// </summary>
public static MagControlReleaseDecision TryCheck(int carId, int triggerSiteId)
{
Func<int, int, MagControlReleaseDecision> check;
lock (Sync)
check = _check;
return check?.Invoke(carId, triggerSiteId);
}
}
}
@@ -6,5 +6,6 @@ using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StandardScene.Devices")]
[assembly: InternalsVisibleTo("StandardScene.Magnetic")]
[assembly: InternalsVisibleTo("StandardScene.QrLidar")]
[assembly: InternalsVisibleTo("StandardScene.Signal")]
// SimpleLite 反射 API 需读取 internal 字段袋(BasicTrackFields / KivaSiteFields 等)的 public 字段定义。
[assembly: InternalsVisibleTo("SimpleLite")]
+19 -20
View File
@@ -25,32 +25,31 @@
<!-- 程序集引用:SimpleComposer.exe -> SimpleLite.dllSimpleCore 配套本地产物 -->
<ItemGroup>
<Reference Include="SimpleLite">
<HintPath>E:\Work\Core\Simple-FR\Simple\SimpleLite\bin\Debug\SimpleLite.dll</HintPath>
<Reference Include="CommonUsage">
<HintPath>$(StandRefDir)CommonUsage.dll</HintPath>
</Reference>
<Reference Include="CycleGUI">
<HintPath>$(StandRefDir)CycleGUI.dll</HintPath>
</Reference>
<Reference Include="LessokajiWeaverUtilities">
<HintPath>$(StandRefDir)LessokajiWeaverUtilities.dll</HintPath>
</Reference>
<Reference Include="MDCSToolBox">
<HintPath>$(StandRefDir)MDCSToolBox.dll</HintPath>
</Reference>
<Reference Include="SimpleCore">
<HintPath>$(StandRefDir)SimpleCore.dll</HintPath>
</Reference>
<!-- CycleGUI:插件 UI 已从 WinForms 迁移到 CycleGUIDeliveryViewer 等)。
Private=false:宿主 SimpleLite 已在默认 ALC 加载 CycleGUI,插件仅编译期引用、不随产物分发,避免重复 DLL。 -->
<Reference Include="CycleGUI">
<HintPath>$(CGUILibDir)\CycleGUI.dll</HintPath>
<Private>false</Private>
<Reference Include="leegKeys-sdk">
<HintPath>$(LeegKeysSdkPath)</HintPath>
</Reference>
<Reference Include="SimpleCore">
<HintPath>E:\Work\Core\Simple-FR\Simple\SimpleCore\bin\Debug\netstandard2.0\SimpleCore.dll</HintPath>
</Reference>
<Reference Include="CommonUsage">
<HintPath>D:\MDCS\Dependencies\Commons\CommonUsage.dll</HintPath>
</Reference>
<Reference Include="MDCSToolBox">
<HintPath>D:\MDCS\Dependencies\Commons\MDCSToolBox.dll</HintPath>
<Reference Include="SimpleLite">
<HintPath>$(StandSimpleLiteDir)SimpleLite.dll</HintPath>
</Reference>
<Reference Include="Topaz">
<HintPath>E:\Work\Core\Simple-FR\Simple\tools\Topaz.dll</HintPath>
</Reference>
<Reference Include="LessokajiWeaverUtilities">
<HintPath>E:\Work\Core\Simple-FR\Simple\SimpleLite\bin\Debug\LessokajiWeaverUtilities.dll</HintPath>
</Reference>
<Reference Include="leegKeys-sdk">
<HintPath>Ref\leegKeys-sdk.dll</HintPath>
<HintPath>$(StandRefDir)Topaz.dll</HintPath>
</Reference>
</ItemGroup>
@@ -30,23 +30,23 @@
<!-- 与 Core 一致的本地契约/工具 dll + Leeg 按钮盒 SDKleegiot);Modbus/TCP 经 Core 的 Utils/TCP 间接使用,无需 NuGet -->
<ItemGroup>
<Reference Include="SimpleLite">
<HintPath>E:\Work\Core\Simple-FR\Simple\SimpleLite\bin\Debug\SimpleLite.dll</HintPath>
</Reference>
<Reference Include="SimpleCore">
<HintPath>E:\Work\Core\Simple-FR\Simple\SimpleCore\bin\Debug\netstandard2.0\SimpleCore.dll</HintPath>
</Reference>
<Reference Include="CommonUsage">
<HintPath>D:\MDCS\Dependencies\Commons\CommonUsage.dll</HintPath>
</Reference>
<Reference Include="Topaz">
<HintPath>E:\Work\Core\Simple-FR\Simple\tools\Topaz.dll</HintPath>
</Reference>
<Reference Include="LessokajiWeaverUtilities">
<HintPath>E:\Work\Core\Simple-FR\Simple\SimpleLite\bin\Debug\LessokajiWeaverUtilities.dll</HintPath>
<HintPath>$(StandRefDir)CommonUsage.dll</HintPath>
</Reference>
<Reference Include="leegKeys-sdk">
<HintPath>..\StandardScene.Core\Ref\leegKeys-sdk.dll</HintPath>
<HintPath>$(LeegKeysSdkPath)</HintPath>
</Reference>
<Reference Include="LessokajiWeaverUtilities">
<HintPath>$(StandRefDir)LessokajiWeaverUtilities.dll</HintPath>
</Reference>
<Reference Include="SimpleCore">
<HintPath>$(StandRefDir)SimpleCore.dll</HintPath>
</Reference>
<Reference Include="SimpleLite">
<HintPath>$(StandSimpleLiteDir)SimpleLite.dll</HintPath>
</Reference>
<Reference Include="Topaz">
<HintPath>$(StandRefDir)Topaz.dll</HintPath>
</Reference>
</ItemGroup>
+9
View File
@@ -0,0 +1,9 @@
<Application x:Class="StandardScene.Fass2Simulator.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Application.Resources>
<SolidColorBrush x:Key="PanelBrush" Color="#F4F6F8"/>
<SolidColorBrush x:Key="AccentBrush" Color="#2563EB"/>
<SolidColorBrush x:Key="BorderBrush" Color="#D0D7DE"/>
</Application.Resources>
</Application>
+45
View File
@@ -0,0 +1,45 @@
using System;
using System.Windows;
namespace StandardScene.Fass2Simulator
{
public partial class App : Application
{
protected override void OnStartup(StartupEventArgs e)
{
Fass2SimBootstrap.ApplyCommandLine(e.Args);
if (HasTestArg(e.Args))
{
var code = Fass2SimSelfTests.Run(e.Args);
Shutdown(code);
return;
}
base.OnStartup(e);
Exit += (_, __) => Fass2SimFileLogger.Shutdown();
var window = new MainWindow();
MainWindow = window;
window.Show();
}
private static bool HasTestArg(string[] args)
{
if (args == null)
{
return false;
}
foreach (var arg in args)
{
if (string.Equals(arg, "--motion-test", StringComparison.OrdinalIgnoreCase)
|| string.Equals(arg, "--action-test", StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
}
}
@@ -0,0 +1,394 @@
using System;
using System.Collections.Generic;
namespace StandardScene.Fass2Simulator
{
/// <summary>
/// 到站动作闭环:0xB1 登记期望 → 0xA1 或定时器补全字段 → 供调度 AtStation 判定完成。
/// </summary>
public sealed class Fass2SimActionEngine
{
private readonly Fass2SimVehicle _vehicle;
private readonly Fass2SimConfig _config;
private readonly Fass2SimNodeProfileStore _profiles;
private readonly object _syncRoot = new object();
private readonly List<PendingAction> _pendingActions = new List<PendingAction>();
private Fass2SimNodeMessage _expectedStation;
public Fass2SimActionEngine(Fass2SimVehicle vehicle, Fass2SimConfig config, Fass2SimNodeProfileStore profiles)
{
_vehicle = vehicle;
_config = config;
_profiles = profiles ?? new Fass2SimNodeProfileStore();
}
public event Action StationActionsCompleted;
public void OnStationArrival(Fass2SimNodeMessage station)
{
if (station == null)
{
return;
}
lock (_syncRoot)
{
_pendingActions.Clear();
_expectedStation = Clone(station);
ApplyArrivalFields(station);
_vehicle.State = 2;
var pending = Fass2SimActionResolver.DescribePending(_expectedStation, _vehicle.CopySnapshotUnsafe());
if (string.IsNullOrEmpty(pending))
{
Fass2SimLog.WriteLine($"[{Now()}] -> 站点 node={station.Node} 无待执行动作");
NotifyStationCompleteIfReady();
return;
}
Fass2SimLog.WriteLine($"[{Now()}] -> 站点 node={station.Node} 待动作: {pending}");
if (_config.AutoCompleteStationActions)
{
SchedulePatch(Clone(station), 0, 0, ResolveActionDelayMs(station.Node));
Fass2SimLog.WriteLine($"[{Now()}] -> AutoComplete 已排队,delay={ResolveActionDelayMs(station.Node)}ms");
}
}
}
public void ResetForNewTask()
{
lock (_syncRoot)
{
_pendingActions.Clear();
_expectedStation = null;
}
}
public void OnActionCommand(ulong actionId, Fass2SimNodeMessage patch)
{
if (patch == null)
{
return;
}
lock (_syncRoot)
{
var targetNode = patch.Node != 0 ? patch.Node : _vehicle.Node;
if (patch.Node != 0 && patch.Node != _vehicle.Node)
{
Fass2SimLog.WriteLine(
$"[{Now()}] -> 0xA1 忽略: patch node={patch.Node}, current={_vehicle.Node}");
return;
}
var delay = ResolveActionDelayMs(targetNode);
MergeExpectedLocked(patch);
SchedulePatch(patch, actionId, 0, delay);
Fass2SimLog.WriteLine(
$"[{Now()}] -> 0xA1 actionId={actionId} 已排队,delay={delay}ms, pending={DescribePatch(patch)}");
}
}
public void Tick(int deltaMs)
{
if (deltaMs <= 0)
{
return;
}
List<PendingAction> due = null;
lock (_syncRoot)
{
for (var i = _pendingActions.Count - 1; i >= 0; i--)
{
_pendingActions[i].RemainingMs -= deltaMs;
if (_pendingActions[i].RemainingMs <= 0)
{
due ??= new List<PendingAction>();
due.Add(_pendingActions[i]);
_pendingActions.RemoveAt(i);
}
}
}
if (due == null)
{
return;
}
foreach (var action in due)
{
lock (_syncRoot)
{
ApplyPatch(action.Patch);
if (action.ActionId > 0)
{
Fass2SimLog.WriteLine(
$"[{Now()}] -> 动作完成 actionId={action.ActionId}, node={_vehicle.Node}, fields={DescribePatch(action.Patch)}");
}
else
{
Fass2SimLog.WriteLine(
$"[{Now()}] -> 自动动作完成 node={_vehicle.Node}, fields={DescribePatch(action.Patch)}");
}
var pending = Fass2SimActionResolver.DescribePending(_expectedStation, _vehicle.CopySnapshotUnsafe());
if (!string.IsNullOrEmpty(pending))
{
Fass2SimLog.WriteLine($"[{Now()}] -> 仍有待动作: {pending}");
}
NotifyStationCompleteIfReady();
}
}
}
public bool IsStationComplete()
{
lock (_syncRoot)
{
return IsStationCompleteLocked();
}
}
public bool CanLeaveStation()
{
lock (_syncRoot)
{
return IsStationCompleteLocked();
}
}
public Fass2SimNodeMessage ExpectedStation
{
get
{
lock (_syncRoot)
{
return _expectedStation;
}
}
}
public void MergeExpected(Fass2SimNodeMessage patch)
{
if (patch == null)
{
return;
}
lock (_syncRoot)
{
MergeExpectedLocked(patch);
}
}
private void MergeExpectedLocked(Fass2SimNodeMessage patch)
{
if (_expectedStation == null)
{
return;
}
if (patch.Node != 0 && patch.Node != _expectedStation.Node)
{
return;
}
if (patch.StartStop > 0)
{
_expectedStation.StartStop = patch.StartStop;
}
}
private bool IsStationCompleteLocked()
{
if (_pendingActions.Count > 0)
{
return false;
}
if (_expectedStation == null)
{
return true;
}
return Fass2SimActionResolver.IsStationActionComplete(
_expectedStation,
_vehicle.CopySnapshotUnsafe(),
_vehicle.State);
}
private void NotifyStationCompleteIfReady()
{
if (!IsStationCompleteLocked())
{
return;
}
if (_expectedStation != null)
{
Fass2SimLog.WriteLine($"[{Now()}] -> 站点 node={_expectedStation.Node} 动作已全部完成");
}
StationActionsCompleted?.Invoke();
}
private void ApplyArrivalFields(Fass2SimNodeMessage station)
{
if (station.StartStop > 0)
{
_vehicle.StartStop = station.StartStop;
}
if (station.Orientation > 0)
{
_vehicle.Orientation = station.Orientation;
}
if (station.Direction > 0)
{
_vehicle.Direction = station.Direction;
}
if (station.Byroad > 0)
{
_vehicle.Byroad = station.Byroad;
}
}
private void SchedulePatch(Fass2SimNodeMessage patch, ulong actionId, int source, int delayMs)
{
_pendingActions.Add(new PendingAction
{
Patch = Clone(patch),
ActionId = actionId,
RemainingMs = Math.Max(0, delayMs)
});
}
private void ApplyPatch(Fass2SimNodeMessage patch)
{
if (patch.Node != 0 && patch.Node != _vehicle.Node)
{
return;
}
if (patch.StartStop > 0)
{
_vehicle.StartStop = patch.StartStop;
}
if (patch.Speed > 0)
{
_vehicle.Speed = patch.Speed;
}
if (patch.Direction > 0)
{
_vehicle.Direction = patch.Direction;
}
if (patch.Orientation > 0)
{
_vehicle.Orientation = patch.Orientation;
}
if (patch.Byroad > 0)
{
_vehicle.Byroad = patch.Byroad;
}
if (patch.Obstacle > 0)
{
_vehicle.Obstacle = patch.Obstacle;
}
if (patch.Audio > 0)
{
_vehicle.Audio = patch.Audio;
}
if (patch.Light > 0)
{
_vehicle.Light = patch.Light;
}
if (patch.Charge > 0)
{
_vehicle.Charge = patch.Charge;
}
if (patch.Rest > 0)
{
_vehicle.Rest = patch.Rest;
}
if (patch.Lift > 0)
{
_vehicle.Lift = patch.Lift;
}
if (patch.Clamp > 0)
{
_vehicle.Clamp = patch.Clamp;
}
if (patch.Tray > 0)
{
_vehicle.Tray = patch.Tray;
}
if (patch.Roll > 0)
{
_vehicle.Roll = patch.Roll;
}
if (patch.Shutdown > 0)
{
_vehicle.Shutdown = patch.Shutdown;
}
}
private int ResolveActionDelayMs(ushort nodeId)
{
return _profiles.ResolveActionDelayMs(nodeId, _config.ActionDelayMs);
}
private static Fass2SimNodeMessage Clone(Fass2SimNodeMessage source)
{
return new Fass2SimNodeMessage
{
Node = source.Node,
Distance = source.Distance,
StartStop = source.StartStop,
Direction = source.Direction,
Orientation = source.Orientation,
Byroad = source.Byroad,
Speed = source.Speed,
Obstacle = source.Obstacle,
Audio = source.Audio,
Light = source.Light,
Charge = source.Charge,
Rest = source.Rest,
Lift = source.Lift,
Clamp = source.Clamp,
Tray = source.Tray,
Roll = source.Roll,
Shutdown = source.Shutdown
};
}
private static string DescribePatch(Fass2SimNodeMessage patch)
{
var parts = new List<string>();
if (patch.StartStop > 0) parts.Add($"StartStop={patch.StartStop}");
if (patch.Lift > 0) parts.Add($"Lift={patch.Lift}");
if (patch.Charge > 0) parts.Add($"Charge={patch.Charge}");
if (patch.Roll > 0) parts.Add($"Roll={patch.Roll}");
if (patch.Speed > 0) parts.Add($"Speed={patch.Speed}");
return parts.Count == 0 ? "(empty)" : string.Join(",", parts);
}
private static string Now()
{
return DateTime.Now.ToString("HH:mm:ss.fff");
}
private sealed class PendingAction
{
public Fass2SimNodeMessage Patch { get; set; }
public ulong ActionId { get; set; }
public int RemainingMs { get; set; }
}
}
}
@@ -0,0 +1,185 @@
using System;
using System.Collections.Generic;
namespace StandardScene.Fass2Simulator
{
/// <summary>
/// 对齐 <c>Fass2ActionResolver</c> 的到站动作判定(独立副本,避免引用 Magnetic)。
/// </summary>
public static class Fass2SimActionResolver
{
public const byte StartStopPass = 1;
public const byte StartStopStop = 2;
public const byte StartStopControlStart = 11;
public const byte StartStopControlStop = 12;
public const byte StartStopPrecision = 22;
public static bool VerifyTrajectoryFields { get; set; }
private static readonly (string Name, Func<Fass2SimNodeMessage, byte> Get)[] MechanismFields =
{
("Obstacle", n => n.Obstacle),
("Audio", n => n.Audio),
("Light", n => n.Light),
("Charge", n => n.Charge),
("Rest", n => n.Rest),
("Lift", n => n.Lift),
("Clamp", n => n.Clamp),
("Tray", n => n.Tray),
("Roll", n => n.Roll),
("Shutdown", n => n.Shutdown)
};
private static readonly (string Name, Func<Fass2SimNodeMessage, byte> Get)[] TrajectoryFields =
{
("Direction", n => n.Direction),
("Orientation", n => n.Orientation),
("Byroad", n => n.Byroad)
};
public static bool AllowsAutoContinue(byte startStop)
{
return startStop == StartStopPass || startStop == StartStopControlStart;
}
public static bool RequiresActionWait(Fass2SimNodeMessage expected)
{
if (expected == null)
{
return false;
}
if (expected.StartStop is StartStopStop or StartStopPrecision or StartStopControlStop or StartStopControlStart)
{
return true;
}
foreach (var field in MechanismFields)
{
if (field.Get(expected) != 0)
{
return true;
}
}
if (!VerifyTrajectoryFields)
{
return false;
}
foreach (var field in TrajectoryFields)
{
if (field.Get(expected) != 0)
{
return true;
}
}
return expected.Speed != 0;
}
public static bool IsStationActionComplete(Fass2SimNodeMessage expected, Fass2SimVehicleSnapshot actual, byte vehicleState)
{
if (expected == null || actual == null)
{
return false;
}
if (actual.Node != expected.Node)
{
return false;
}
if (vehicleState is 1 or 5)
{
return false;
}
if (!RequiresActionWait(expected))
{
return true;
}
if (expected.StartStop == StartStopPass)
{
return true;
}
if (expected.StartStop == StartStopControlStop)
{
return false;
}
return ListPendingFields(expected, actual).Count == 0;
}
public static List<string> ListPendingFields(Fass2SimNodeMessage expected, Fass2SimVehicleSnapshot actual)
{
var pending = new List<string>();
if (expected == null || actual == null)
{
return pending;
}
if (expected.StartStop != 0 && actual.StartStop != expected.StartStop)
{
pending.Add("StartStop");
}
if (VerifyTrajectoryFields)
{
if (expected.Speed != 0 && actual.Speed != expected.Speed)
{
pending.Add("Speed");
}
foreach (var field in TrajectoryFields)
{
var expectedValue = field.Get(expected);
if (expectedValue != 0 && GetActualField(actual, field.Name) != expectedValue)
{
pending.Add(field.Name);
}
}
}
foreach (var field in MechanismFields)
{
var expectedValue = field.Get(expected);
if (expectedValue != 0 && GetActualField(actual, field.Name) != expectedValue)
{
pending.Add(field.Name);
}
}
return pending;
}
public static string DescribePending(Fass2SimNodeMessage expected, Fass2SimVehicleSnapshot actual)
{
var pending = ListPendingFields(expected, actual);
return pending.Count == 0 ? string.Empty : string.Join(",", pending);
}
private static byte GetActualField(Fass2SimVehicleSnapshot actual, string name)
{
switch (name)
{
case "Direction": return actual.Direction;
case "Orientation": return actual.Orientation;
case "Byroad": return actual.Byroad;
case "Obstacle": return actual.Obstacle;
case "Audio": return actual.Audio;
case "Light": return actual.Light;
case "Charge": return actual.Charge;
case "Rest": return actual.Rest;
case "Lift": return actual.Lift;
case "Clamp": return actual.Clamp;
case "Tray": return actual.Tray;
case "Roll": return actual.Roll;
case "Shutdown": return actual.Shutdown;
default: return 0;
}
}
}
}
@@ -0,0 +1,95 @@
using System;
using System.Collections.Generic;
using System.IO;
using Microsoft.Extensions.Configuration;
using Newtonsoft.Json;
namespace StandardScene.Fass2Simulator
{
public static class Fass2SimBootstrap
{
public static string SettingsFile { get; private set; } = "appsettings.json";
public static void ApplyCommandLine(string[] args)
{
if (args == null)
{
return;
}
for (var i = 0; i < args.Length - 1; i++)
{
if (string.Equals(args[i], "--config", StringComparison.OrdinalIgnoreCase))
{
SettingsFile = args[i + 1];
}
}
}
public static Fass2SimConfig LoadConfig(string settingsFile = null)
{
var basePath = AppContext.BaseDirectory;
var builder = new ConfigurationBuilder()
.SetBasePath(basePath)
.AddJsonFile(settingsFile ?? SettingsFile, optional: true, reloadOnChange: false);
var configuration = builder.Build();
var config = new Fass2SimConfig();
configuration.GetSection("Fass2Simulator").Bind(config);
return config;
}
public static Fass2SimNodeProfileStore LoadNodeProfiles(Fass2SimConfig config)
{
var store = new Fass2SimNodeProfileStore();
var path = ResolveNodeProfilesPath(config.NodeProfilesPath);
if (!File.Exists(path))
{
return store;
}
try
{
var json = File.ReadAllText(path);
var profiles = JsonConvert.DeserializeObject<Dictionary<string, Fass2SimNodeProfile>>(json)
?? new Dictionary<string, Fass2SimNodeProfile>();
store.Load(profiles);
Fass2SimLog.WriteLine($"[{DateTime.Now:HH:mm:ss}] 已加载节点配置: {path} ({profiles.Count} 项)");
}
catch (Exception ex)
{
Fass2SimLog.WriteLine($"[{DateTime.Now:HH:mm:ss}] 加载节点配置失败: {ex.Message}");
}
return store;
}
public static Fass2SimRuntime CreateRuntime(Fass2SimConfig config)
{
var profiles = LoadNodeProfiles(config);
return new Fass2SimRuntime(config, profiles);
}
public static (Fass2SimVehicle vehicle, Fass2SimMotionEngine motion, Fass2SimActionEngine actions) CreateTestStack(
Fass2SimConfig config)
{
var vehicle = new Fass2SimVehicle(config);
var actions = new Fass2SimActionEngine(vehicle, config, new Fass2SimNodeProfileStore());
var motion = new Fass2SimMotionEngine(vehicle, config, actions);
actions.StationActionsCompleted += motion.OnStationActionsCompleted;
return (vehicle, motion, actions);
}
private static string ResolveNodeProfilesPath(string configuredPath)
{
if (string.IsNullOrWhiteSpace(configuredPath))
{
return Path.Combine(AppContext.BaseDirectory, "sim-nodes.json");
}
return Path.IsPathRooted(configuredPath)
? configuredPath
: Path.Combine(AppContext.BaseDirectory, configuredPath);
}
}
}
@@ -0,0 +1,131 @@
using System;
namespace StandardScene.Fass2Simulator
{
public sealed class Fass2SimCommandHandler
{
private readonly Fass2SimVehicle _vehicle;
private readonly Fass2SimMotionEngine _motion;
private readonly Fass2SimActionEngine _actions;
private readonly bool _logRawFrames;
private long _stateResponseCount;
public Fass2SimCommandHandler(
Fass2SimVehicle vehicle,
Fass2SimMotionEngine motion,
Fass2SimActionEngine actions,
bool logRawFrames)
{
_vehicle = vehicle;
_motion = motion;
_actions = actions;
_logRawFrames = logRawFrames;
}
public void Handle(byte[] packet, string remote)
{
if (!Fass2SimProtocol.TryGetCommand(packet, out var command, out var expectedLength))
{
Fass2SimLog.WriteLine($"[{Now()}] RX 非法报文 from {remote}, len={packet?.Length ?? 0}");
return;
}
if (packet.Length != expectedLength)
{
Fass2SimLog.WriteLine(
$"[{Now()}] RX 长度不匹配 from {remote}: cmd={Fass2SimProtocol.CommandName(command)} actual={packet.Length} expected={expectedLength}");
return;
}
if (packet[expectedLength - 1] != Fass2SimProtocol.End)
{
Fass2SimLog.WriteLine($"[{Now()}] RX 帧尾错误 from {remote}: cmd={Fass2SimProtocol.CommandName(command)}");
return;
}
_vehicle.RecordCommandReceived();
_vehicle.LastCommand = command;
if (command == Fass2SimProtocol.CmdStateResponse)
{
_vehicle.RecordAckReceived();
LogStateResponseAck(remote);
return;
}
if (_logRawFrames)
{
Fass2SimLog.WriteLine($"[{Now()}] RX {Fass2SimProtocol.CommandName(command)} from {remote}: {Fass2SimProtocol.ToHex(packet)}");
}
else
{
Fass2SimLog.WriteLine($"[{Now()}] RX {Fass2SimProtocol.CommandName(command)} from {remote}");
}
switch (command)
{
case Fass2SimProtocol.CmdStart:
_vehicle.State = 1;
_motion.OnStartCommand();
Fass2SimLog.WriteLine($"[{Now()}] -> State={Fass2SimProtocol.StateText(_vehicle.State)}");
break;
case Fass2SimProtocol.CmdStop:
_vehicle.State = 2;
_motion.OnStopCommand();
Fass2SimLog.WriteLine($"[{Now()}] -> State={Fass2SimProtocol.StateText(_vehicle.State)}");
break;
case Fass2SimProtocol.CmdEmergencyStop:
_vehicle.State = 3;
_motion.OnStopCommand();
Fass2SimLog.WriteLine($"[{Now()}] -> State={Fass2SimProtocol.StateText(_vehicle.State)}");
break;
case Fass2SimProtocol.CmdQuery:
Fass2SimLog.WriteLine($"[{Now()}] -> TCP 查询(UDP 模式通常不发 0x00");
break;
case Fass2SimProtocol.CmdNodes:
if (Fass2SimProtocol.TryParseNodes(packet, out var taskId, out var nodes))
{
_motion.OnNodesCommand(taskId, nodes);
}
else
{
Fass2SimLog.WriteLine($"[{Now()}] -> 0xB1 解析失败");
}
break;
case Fass2SimProtocol.CmdAction:
if (Fass2SimProtocol.TryParseAction(packet, out var actionId, out var patch))
{
_actions.OnActionCommand(actionId, patch);
}
else
{
Fass2SimLog.WriteLine($"[{Now()}] -> 0xA1 解析失败");
}
break;
default:
Fass2SimLog.WriteLine($"[{Now()}] -> 暂未处理");
break;
}
}
private void LogStateResponseAck(string remote)
{
_stateResponseCount++;
if (_stateResponseCount == 1)
{
Fass2SimLog.WriteLine($"[{Now()}] RX StateResponse(0x10) from {remote},调度在线(后续 0x10 仅写文件日志)");
return;
}
if (_stateResponseCount % 200 == 0)
{
Fass2SimLog.WriteFileOnly($"[{Now()}] 0x10 累计应答 {_stateResponseCount} 次");
}
}
private static string Now()
{
return DateTime.Now.ToString("HH:mm:ss.fff");
}
}
}
@@ -0,0 +1,30 @@
using System.Collections.Generic;
namespace StandardScene.Fass2Simulator
{
public sealed class Fass2SimConfig
{
public ushort VehicleCode { get; set; } = 1;
public string VehicleListenAddress { get; set; } = "0.0.0.0";
public int VehicleListenPort { get; set; } = 5000;
public string SchedulerHost { get; set; } = "127.0.0.1";
public int SchedulerListenPort { get; set; } = 20103;
public int ReportIntervalMs { get; set; } = 200;
public ushort InitialNode { get; set; } = 1;
public byte InitialState { get; set; } = 0;
public byte BatteryCharge { get; set; } = 100;
public ushort CarLength { get; set; } = 1200;
public ushort CarWidth { get; set; } = 800;
public bool LogRawFrames { get; set; }
public double DefaultSpeed { get; set; } = 500;
public double DefaultSegmentDistance { get; set; } = 1000;
public double SecondsPerSegment { get; set; } = 2;
public bool AutoContinueOnPass { get; set; } = true;
public int ActionDelayMs { get; set; } = 1000;
public bool AutoCompleteStationActions { get; set; }
public string NodeProfilesPath { get; set; } = "sim-nodes.json";
public bool EnableFileLog { get; set; } = true;
public string LogDirectory { get; set; } = "log";
public Dictionary<string, Fass2SimNodeProfile> NodeProfiles { get; set; } = new Dictionary<string, Fass2SimNodeProfile>();
}
}
@@ -0,0 +1,140 @@
using System;
using System.IO;
using System.Text;
namespace StandardScene.Fass2Simulator
{
public static class Fass2SimFileLogger
{
private static readonly object SyncRoot = new object();
private static StreamWriter _writer;
private static bool _enabled = true;
private static string _logDirectory;
public static string CurrentFilePath { get; private set; }
public static void Configure(Fass2SimConfig config)
{
_enabled = config == null || config.EnableFileLog;
_logDirectory = ResolveLogDirectory(config?.LogDirectory);
}
public static void BeginSession(Fass2SimConfig config)
{
if (config == null || !config.EnableFileLog)
{
return;
}
lock (SyncRoot)
{
CloseWriter();
Directory.CreateDirectory(_logDirectory);
var fileName = $"car{config.VehicleCode}_{DateTime.Now:yyyyMMdd_HHmmss}.log";
CurrentFilePath = Path.Combine(_logDirectory, fileName);
_writer = new StreamWriter(CurrentFilePath, false, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false))
{
AutoFlush = true
};
WriteDirect("===== FASS2 车体模拟器会话开始 =====");
WriteDirect($"时间: {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
WriteDirect($"车号: {config.VehicleCode}");
WriteDirect($"车体监听: {config.VehicleListenAddress}:{config.VehicleListenPort}");
WriteDirect($"调度目标: {config.SchedulerHost}:{config.SchedulerListenPort}");
WriteDirect($"上报周期: {config.ReportIntervalMs} ms");
WriteDirect($"日志文件: {CurrentFilePath}");
WriteDirect("====================================");
}
}
public static void Write(string message)
{
if (!_enabled || string.IsNullOrEmpty(message))
{
return;
}
lock (SyncRoot)
{
if (_writer == null)
{
EnsureDefaultWriter();
}
if (_writer == null)
{
return;
}
_writer.WriteLine(message);
}
}
public static void Shutdown()
{
lock (SyncRoot)
{
if (_writer != null)
{
WriteDirect($"===== 会话结束 {DateTime.Now:yyyy-MM-dd HH:mm:ss} =====");
}
CloseWriter();
}
}
public static string GetLogDirectory()
{
return _logDirectory ?? ResolveLogDirectory("log");
}
private static void EnsureDefaultWriter()
{
if (!_enabled)
{
return;
}
Directory.CreateDirectory(_logDirectory);
var fileName = $"app_{DateTime.Now:yyyyMMdd}.log";
CurrentFilePath = Path.Combine(_logDirectory, fileName);
_writer = new StreamWriter(CurrentFilePath, true, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false))
{
AutoFlush = true
};
WriteDirect($"[{DateTime.Now:HH:mm:ss}] 文件日志已启用: {CurrentFilePath}");
}
private static void WriteDirect(string message)
{
_writer?.WriteLine(message);
}
private static void CloseWriter()
{
try
{
_writer?.Flush();
_writer?.Dispose();
}
catch
{
}
_writer = null;
}
private static string ResolveLogDirectory(string configuredPath)
{
if (string.IsNullOrWhiteSpace(configuredPath))
{
return Path.Combine(AppContext.BaseDirectory, "log");
}
return Path.IsPathRooted(configuredPath)
? configuredPath
: Path.Combine(AppContext.BaseDirectory, configuredPath);
}
}
}
@@ -0,0 +1,33 @@
using System;
namespace StandardScene.Fass2Simulator
{
public static class Fass2SimLog
{
public static event Action<string> MessageWritten;
public static void WriteLine(string message)
{
if (string.IsNullOrEmpty(message))
{
return;
}
Fass2SimFileLogger.Write(message);
MessageWritten?.Invoke(message);
}
/// <summary>
/// 高频日志只写文件,避免 UI 线程被 BeginInvoke 淹没。
/// </summary>
public static void WriteFileOnly(string message)
{
if (string.IsNullOrEmpty(message))
{
return;
}
Fass2SimFileLogger.Write(message);
}
}
}
@@ -0,0 +1,368 @@
using System;
namespace StandardScene.Fass2Simulator
{
/// <summary>
/// 按 0xB1 节点序列模拟行驶:距离推进 → 到站更新 Node/字段 → State 切为停止供调度判定到站。
/// </summary>
public sealed class Fass2SimMotionEngine
{
private readonly Fass2SimVehicle _vehicle;
private readonly Fass2SimConfig _config;
private readonly Fass2SimActionEngine _actions;
private readonly object _syncRoot = new object();
private ulong _activeTaskId;
private Fass2SimNodeMessage[] _windowNodes = Array.Empty<Fass2SimNodeMessage>();
private Fass2SimNodeMessage _segmentTarget;
private double _segmentProgress;
private double _segmentLength;
private bool _segmentActive;
public Fass2SimMotionEngine(Fass2SimVehicle vehicle, Fass2SimConfig config, Fass2SimActionEngine actions)
{
_vehicle = vehicle;
_config = config;
_actions = actions;
}
public void OnNodesCommand(ulong taskId, Fass2SimNodeMessage[] nodes)
{
if (nodes == null || nodes.Length == 0)
{
Fass2SimLog.WriteLine($"[{Now()}] -> 0xB1 节点为空,忽略");
return;
}
lock (_syncRoot)
{
var taskChanged = _activeTaskId != taskId;
_activeTaskId = taskId;
_windowNodes = nodes;
_vehicle.Task = taskId;
if (taskChanged)
{
_actions.ResetForNewTask();
ResetSegment();
}
ApplyCurrentNodeFields(nodes);
LogNodes(nodes);
if (_vehicle.State == 3)
{
Fass2SimLog.WriteLine($"[{Now()}] -> 急停中,不启动运动");
return;
}
if (_segmentActive)
{
if (taskChanged)
{
Fass2SimLog.WriteLine($"[{Now()}] -> 新 taskId,重置路段");
ResetSegment();
}
else
{
return;
}
}
if (!ContainsNode(nodes, _vehicle.Node))
{
Fass2SimLog.WriteLine(
$"[{Now()}] -> 0xB1 窗口不含当前 node={_vehicle.Node},忽略启动路段(避免回退到窗口首站)");
return;
}
TryStartNextSegment();
}
}
private static bool ContainsNode(Fass2SimNodeMessage[] nodes, ushort node)
{
if (nodes == null)
{
return false;
}
for (var i = 0; i < nodes.Length; i++)
{
if (nodes[i].Node == node)
{
return true;
}
}
return false;
}
public void OnStartCommand()
{
lock (_syncRoot)
{
if (_vehicle.State == 3)
{
return;
}
TryStartNextSegment();
}
}
public void OnStopCommand()
{
lock (_syncRoot)
{
ResetSegment();
}
}
public void OnStationActionsCompleted()
{
lock (_syncRoot)
{
TryResumeFromStation();
}
}
public void Tick(int deltaMs)
{
if (deltaMs <= 0)
{
return;
}
lock (_syncRoot)
{
if (!_segmentActive || _segmentTarget == null || _vehicle.State != 1)
{
return;
}
var speed = ResolveSpeed(_segmentTarget);
var deltaDistance = speed * deltaMs / 1000.0;
_segmentProgress += deltaDistance;
_vehicle.Distance = (ushort)Math.Min(ushort.MaxValue, Math.Round(_segmentProgress));
if (_segmentProgress < _segmentLength)
{
return;
}
ArriveAtTarget();
}
}
private void TryStartNextSegment()
{
if (_vehicle.State != 1 && _vehicle.State != 2)
{
return;
}
if (_vehicle.State == 2 && !_actions.CanLeaveStation())
{
return;
}
var target = ResolveNextTarget(_windowNodes, _vehicle.Node);
if (target == null)
{
if (_vehicle.State == 2)
{
Fass2SimLog.WriteLine($"[{Now()}] -> 窗口内无下一段,等待下一帧 0xB1");
}
return;
}
_segmentTarget = target;
_segmentLength = ResolveSegmentLength(_vehicle.Node, target);
_segmentProgress = 0;
_segmentActive = true;
_vehicle.State = 1;
_vehicle.Distance = 0;
if (target.Speed > 0)
{
_vehicle.Speed = target.Speed;
}
Fass2SimLog.WriteLine(
$"[{Now()}] -> 开始路段: {_vehicle.Node} -> {target.Node}, dist={_segmentLength:F0}mm, speed={ResolveSpeed(target):F0}mm/s");
}
private void ArriveAtTarget()
{
var target = _segmentTarget;
_vehicle.Node = target.Node;
_vehicle.Distance = 0;
ResetSegment();
_actions.OnStationArrival(target);
Fass2SimLog.WriteLine(
$"[{Now()}] -> 到站 node={target.Node}, StartStop={target.StartStop}, state={Fass2SimProtocol.StateText(_vehicle.State)}");
TryResumeFromStation();
}
private void TryResumeFromStation()
{
if (!_config.AutoContinueOnPass)
{
return;
}
var expected = _actions.ExpectedStation;
if (expected == null || !Fass2SimActionResolver.AllowsAutoContinue(expected.StartStop))
{
return;
}
if (_vehicle.State != 2 || !_actions.CanLeaveStation() || !HasNextTarget(_vehicle.Node))
{
return;
}
_vehicle.State = 1;
TryStartNextSegment();
}
private void ResetSegment()
{
_segmentTarget = null;
_segmentProgress = 0;
_segmentLength = 0;
_segmentActive = false;
}
private bool HasNextTarget(ushort currentNode)
{
return ResolveNextTarget(_windowNodes, currentNode) != null;
}
private static Fass2SimNodeMessage ResolveNextTarget(Fass2SimNodeMessage[] nodes, ushort currentNode)
{
if (nodes == null || nodes.Length == 0)
{
return null;
}
// 必须先在窗口内定位当前站,再取其后一站。
// 旧逻辑在窗口不含当前站时会落到 nodes[0],把更早的站当成下一目标 → 车体 Node 瞬间回退/瞬移。
var foundCurrent = false;
var startIndex = 0;
for (var i = 0; i < nodes.Length; i++)
{
if (nodes[i].Node == currentNode)
{
foundCurrent = true;
startIndex = i + 1;
}
}
if (!foundCurrent)
{
return null;
}
for (var i = startIndex; i < nodes.Length; i++)
{
var node = nodes[i];
if (node.Node == 0 || node.Node == currentNode)
{
continue;
}
return node;
}
return null;
}
private void ApplyCurrentNodeFields(Fass2SimNodeMessage[] nodes)
{
foreach (var node in nodes)
{
if (node.Node == _vehicle.Node && node.StartStop > 0)
{
_vehicle.StartStop = node.StartStop;
_actions.MergeExpected(node);
}
}
}
private double ResolveSegmentLength(ushort fromNode, Fass2SimNodeMessage target)
{
foreach (var node in _windowNodes)
{
if (node.Node == fromNode && node.Distance > 0)
{
return node.Distance;
}
}
if (target.Distance > 0)
{
return target.Distance;
}
if (_config.DefaultSegmentDistance > 0)
{
return _config.DefaultSegmentDistance;
}
var speed = ResolveSpeed(target);
return Math.Max(100, speed * _config.SecondsPerSegment);
}
private double ResolveSpeed(Fass2SimNodeMessage target)
{
if (target.Speed > 0)
{
return Fass2SimProtocol.ProtocolSpeedToMmPerSec(target.Speed);
}
if (_vehicle.Speed > 0)
{
return Fass2SimProtocol.ProtocolSpeedToMmPerSec(_vehicle.Speed);
}
return _config.DefaultSpeed;
}
private void LogNodes(Fass2SimNodeMessage[] nodes)
{
var summary = string.Join(" -> ", Array.ConvertAll(nodes, n => n.Node.ToString()));
Fass2SimLog.WriteLine($"[{Now()}] -> 0xB1 taskId={_activeTaskId}, nodes=[{summary}]");
}
private static string Now()
{
return DateTime.Now.ToString("HH:mm:ss.fff");
}
/// <summary>
/// 供界面展示路段进度:行驶中返回目标节点与路段长度,到站后 node 才会在协议里跳变。
/// </summary>
public bool TryGetSegmentStatus(out ushort targetNode, out int segmentLengthMm, out int progressMm)
{
lock (_syncRoot)
{
if (!_segmentActive || _segmentTarget == null)
{
targetNode = 0;
segmentLengthMm = 0;
progressMm = 0;
return false;
}
targetNode = _segmentTarget.Node;
segmentLengthMm = (int)Math.Round(_segmentLength);
progressMm = (int)Math.Round(_segmentProgress);
return true;
}
}
}
}
@@ -0,0 +1,121 @@
namespace StandardScene.Fass2Simulator
{
public sealed class Fass2SimNodeMessage
{
public ushort Node { get; set; }
public ushort Distance { get; set; }
public byte StartStop { get; set; }
public byte Direction { get; set; }
public byte Orientation { get; set; }
public byte Byroad { get; set; }
public ushort Speed { get; set; }
public byte Obstacle { get; set; }
public byte Audio { get; set; }
public byte Light { get; set; }
public byte Charge { get; set; }
public byte Rest { get; set; }
public byte Lift { get; set; }
public byte Clamp { get; set; }
public byte Tray { get; set; }
public byte Roll { get; set; }
public byte Shutdown { get; set; }
public static Fass2SimNodeMessage FromBytes(byte[] bytes, int offset)
{
return new Fass2SimNodeMessage
{
Node = ReadUInt16(bytes, offset),
Distance = ReadUInt16(bytes, offset + 2),
StartStop = bytes[offset + 4],
Direction = bytes[offset + 5],
Orientation = bytes[offset + 6],
Byroad = bytes[offset + 7],
Speed = ReadUInt16(bytes, offset + 8),
Obstacle = bytes[offset + 10],
Audio = bytes[offset + 11],
Light = bytes[offset + 12],
Charge = bytes[offset + 13],
Rest = bytes[offset + 14],
Lift = bytes[offset + 15],
Clamp = bytes[offset + 16],
Tray = bytes[offset + 17],
Roll = bytes[offset + 18],
Shutdown = bytes[offset + 19]
};
}
public void ApplyTo(Fass2SimVehicle vehicle)
{
if (vehicle.Node != Node)
{
return;
}
if (StartStop > 0)
{
vehicle.StartStop = StartStop;
}
if (Direction > 0)
{
vehicle.Direction = Direction;
}
if (Orientation > 0)
{
vehicle.Orientation = Orientation;
}
if (Byroad > 0)
{
vehicle.Byroad = Byroad;
}
if (Speed > 0)
{
vehicle.Speed = Speed;
}
if (Obstacle > 0)
{
vehicle.Obstacle = Obstacle;
}
if (Audio > 0)
{
vehicle.Audio = Audio;
}
if (Light > 0)
{
vehicle.Light = Light;
}
if (Charge > 0)
{
vehicle.Charge = Charge;
}
if (Rest > 0)
{
vehicle.Rest = Rest;
}
if (Lift > 0)
{
vehicle.Lift = Lift;
}
if (Clamp > 0)
{
vehicle.Clamp = Clamp;
}
if (Tray > 0)
{
vehicle.Tray = Tray;
}
if (Roll > 0)
{
vehicle.Roll = Roll;
}
if (Shutdown > 0)
{
vehicle.Shutdown = Shutdown;
}
}
private static ushort ReadUInt16(byte[] bytes, int offset)
{
return System.BitConverter.ToUInt16(bytes, offset);
}
}
}
@@ -0,0 +1,44 @@
using System;
using System.Collections.Generic;
namespace StandardScene.Fass2Simulator
{
public sealed class Fass2SimNodeProfile
{
public int ActionDelayMs { get; set; }
}
public sealed class Fass2SimNodeProfileStore
{
private readonly Dictionary<ushort, Fass2SimNodeProfile> _profiles = new Dictionary<ushort, Fass2SimNodeProfile>();
public void Load(IDictionary<string, Fass2SimNodeProfile> nodes)
{
_profiles.Clear();
if (nodes == null)
{
return;
}
foreach (var pair in nodes)
{
if (!ushort.TryParse(pair.Key, out var nodeId))
{
continue;
}
_profiles[nodeId] = pair.Value ?? new Fass2SimNodeProfile();
}
}
public int ResolveActionDelayMs(ushort nodeId, int fallbackMs)
{
if (_profiles.TryGetValue(nodeId, out var profile) && profile.ActionDelayMs > 0)
{
return profile.ActionDelayMs;
}
return fallbackMs;
}
}
}
@@ -0,0 +1,239 @@
using System;
using System.Linq;
using System.Text;
namespace StandardScene.Fass2Simulator
{
/// <summary>
/// FASS 2.0 PCB/UDP 报文编解码,字段布局对齐
/// <c>StandardScene.Magnetic.Protocol.Fass2Protocol.ParseState</c> 与 backend PCB 仿真器。
/// </summary>
public static class Fass2SimProtocol
{
public const byte Begin = 0xBB;
public const byte End = 0xEE;
public const byte CmdQuery = 0x00;
public const byte CmdStart = 0x01;
public const byte CmdStop = 0x02;
public const byte CmdEmergencyStop = 0x03;
public const byte CmdReset = 0x04;
public const byte CmdRest = 0x05;
public const byte CmdShutdown = 0x06;
public const byte CmdAction = 0xA1;
public const byte CmdNodes = 0xB1;
public const byte CmdStateResponse = 0x10;
public const int ControlFrameLength = 50;
public const int StateFrameLength = 100;
public const int ActionFrameLength = 100;
public const int NodesFrameLength = 300;
public static byte[] BuildState(Fass2SimVehicleSnapshot snapshot)
{
var frame = new byte[StateFrameLength];
frame[0] = Begin;
frame[1] = snapshot.LastCommand;
WriteUInt16(frame, 2, snapshot.Car);
WriteUInt16(frame, 4, snapshot.Length);
WriteUInt16(frame, 6, snapshot.Width);
var carType = Encoding.ASCII.GetBytes("Fass2Sim".PadRight(16, '\0'));
Buffer.BlockCopy(carType, 0, frame, 12, Math.Min(16, carType.Length));
frame[28] = snapshot.BatteryCharge;
frame[29] = snapshot.BatteryHealth;
WriteUInt16(frame, 30, snapshot.BatteryCurrent);
WriteUInt16(frame, 32, snapshot.BatteryVoltage);
WriteUInt16(frame, 34, snapshot.HeadingAngle);
frame[36] = snapshot.State;
WriteUInt64(frame, 37, snapshot.Alarm);
WriteUInt64(frame, 45, snapshot.Task);
var nodeBytes = BuildNodeBytes(snapshot);
Buffer.BlockCopy(nodeBytes, 0, frame, 53, 25);
frame[98] = Xor(frame, 1, 97);
frame[99] = End;
return frame;
}
public static byte[] BuildNodeBytes(Fass2SimVehicleSnapshot snapshot)
{
var bytes = new byte[25];
WriteUInt16(bytes, 0, snapshot.Node);
WriteUInt16(bytes, 2, snapshot.Distance);
bytes[4] = snapshot.StartStop;
bytes[5] = snapshot.Direction;
bytes[6] = snapshot.Orientation;
bytes[7] = snapshot.Byroad;
WriteUInt16(bytes, 8, snapshot.Speed);
bytes[10] = snapshot.Obstacle;
bytes[11] = snapshot.Audio;
bytes[12] = snapshot.Light;
bytes[13] = snapshot.Charge;
bytes[14] = snapshot.Rest;
bytes[15] = snapshot.Lift;
bytes[16] = snapshot.Clamp;
bytes[17] = snapshot.Tray;
bytes[18] = snapshot.Roll;
bytes[19] = snapshot.Shutdown;
return bytes;
}
public static bool TryGetCommand(byte[] packet, out byte command, out int expectedLength)
{
command = 0;
expectedLength = 0;
if (packet == null || packet.Length < 2 || packet[0] != Begin)
{
return false;
}
command = packet[1];
expectedLength = ResolvePacketLength(command);
return expectedLength > 0;
}
public static int ResolvePacketLength(byte command)
{
switch (command)
{
case CmdQuery:
case CmdStart:
case CmdStop:
case CmdEmergencyStop:
case CmdReset:
case CmdRest:
case CmdShutdown:
case CmdStateResponse:
return ControlFrameLength;
case CmdAction:
return ActionFrameLength;
case CmdNodes:
return NodesFrameLength;
default:
return 0;
}
}
public static string CommandName(byte command)
{
switch (command)
{
case CmdQuery: return "Query(0x00)";
case CmdStart: return "Start(0x01)";
case CmdStop: return "Stop(0x02)";
case CmdEmergencyStop: return "EmergencyStop(0x03)";
case CmdReset: return "Reset(0x04)";
case CmdRest: return "Rest(0x05)";
case CmdShutdown: return "Shutdown(0x06)";
case CmdAction: return "Action(0xA1)";
case CmdNodes: return "Nodes(0xB1)";
case CmdStateResponse: return "StateResponse(0x10)";
default: return $"Unknown(0x{command:X2})";
}
}
public static string StateText(byte state)
{
switch (state)
{
case 0: return "未准备";
case 1: return "运行中";
case 2: return "停止中";
case 3: return "急停中";
case 4: return "故障中";
case 5: return "任务中";
case 6: return "休眠中";
case 7: return "关机中";
case 8: return "充电中";
default: return $"未知({state})";
}
}
/// <summary>协议速度(0.1 m/min) → mm/s。</summary>
public static double ProtocolSpeedToMmPerSec(ushort protocolSpeed)
{
if (protocolSpeed == 0)
{
return 0;
}
return protocolSpeed * 0.1 * 1000.0 / 60.0;
}
public static string ToHex(byte[] bytes)
{
return bytes == null ? string.Empty : string.Join(" ", bytes.Select(b => b.ToString("X2")));
}
public static byte Xor(byte[] data, int start, int length)
{
byte xor = 0;
for (var i = 0; i < length; i++)
{
xor ^= data[start + i];
}
return xor;
}
public static bool TryParseNodes(byte[] packet, out ulong taskId, out Fass2SimNodeMessage[] nodes)
{
taskId = 0;
nodes = Array.Empty<Fass2SimNodeMessage>();
if (packet == null || packet.Length < NodesFrameLength || packet[1] != CmdNodes)
{
return false;
}
taskId = BitConverter.ToUInt64(packet, 4);
var count = BitConverter.ToUInt16(packet, 12);
if (count == 0 || count > 10)
{
return false;
}
var requiredLength = 14 + count * 25 + 2;
if (packet.Length < requiredLength)
{
return false;
}
nodes = new Fass2SimNodeMessage[count];
for (var i = 0; i < count; i++)
{
nodes[i] = Fass2SimNodeMessage.FromBytes(packet, 14 + i * 25);
}
return true;
}
public static bool TryParseAction(byte[] packet, out ulong actionId, out Fass2SimNodeMessage node)
{
actionId = 0;
node = null;
if (packet == null || packet.Length < ActionFrameLength || packet[1] != CmdAction)
{
return false;
}
actionId = BitConverter.ToUInt64(packet, 4);
node = Fass2SimNodeMessage.FromBytes(packet, 14);
return true;
}
private static void WriteUInt16(byte[] buffer, int offset, ushort value)
{
var bytes = BitConverter.GetBytes(value);
buffer[offset] = bytes[0];
buffer[offset + 1] = bytes[1];
}
private static void WriteUInt64(byte[] buffer, int offset, ulong value)
{
var bytes = BitConverter.GetBytes(value);
Buffer.BlockCopy(bytes, 0, buffer, offset, 8);
}
}
}
@@ -0,0 +1,50 @@
using System;
namespace StandardScene.Fass2Simulator
{
public sealed class Fass2SimRuntime : IDisposable
{
private readonly Fass2SimUdpHost _host;
public Fass2SimRuntime(Fass2SimConfig config, Fass2SimNodeProfileStore profiles)
{
Config = config;
Vehicle = new Fass2SimVehicle(config);
Actions = new Fass2SimActionEngine(Vehicle, config, profiles);
Motion = new Fass2SimMotionEngine(Vehicle, config, Actions);
Actions.StationActionsCompleted += Motion.OnStationActionsCompleted;
var commandHandler = new Fass2SimCommandHandler(Vehicle, Motion, Actions, config.LogRawFrames);
_host = new Fass2SimUdpHost(config, Vehicle, commandHandler, Motion, Actions);
_host.StatusReported += (_, __) => StatusChanged?.Invoke();
}
public Fass2SimConfig Config { get; }
public Fass2SimVehicle Vehicle { get; }
public Fass2SimMotionEngine Motion { get; }
public Fass2SimActionEngine Actions { get; }
public bool IsRunning => _host.IsRunning;
public event Action StatusChanged;
public void Start()
{
_host.Start();
StatusChanged?.Invoke();
}
public void Stop()
{
_host.Stop();
StatusChanged?.Invoke();
}
public void Dispose()
{
_host.Dispose();
}
}
}
@@ -0,0 +1,125 @@
using System;
namespace StandardScene.Fass2Simulator
{
public static class Fass2SimSelfTests
{
public static int Run(string[] args)
{
if (HasArg(args, "--motion-test"))
{
return RunMotionSelfTest();
}
if (HasArg(args, "--action-test"))
{
return RunActionSelfTest();
}
return 1;
}
private static int RunMotionSelfTest()
{
Fass2SimFileLogger.Configure(new Fass2SimConfig { EnableFileLog = false });
var config = new Fass2SimConfig
{
InitialNode = 1,
DefaultSegmentDistance = 500,
DefaultSpeed = 500,
AutoContinueOnPass = true
};
var (vehicle, motion, _) = Fass2SimBootstrap.CreateTestStack(config);
var nodes = new[]
{
new Fass2SimNodeMessage { Node = 1, StartStop = 1, Distance = 500 },
new Fass2SimNodeMessage { Node = 2, StartStop = 1, Distance = 500 },
new Fass2SimNodeMessage { Node = 3, StartStop = 2 }
};
vehicle.State = 1;
motion.OnNodesCommand(1001, nodes);
for (var i = 0; i < 30; i++)
{
motion.Tick(200);
var snap = vehicle.Snapshot();
if (snap.Node == 3 && snap.State == 2)
{
Console.WriteLine($"motion self-test ok: node={snap.Node}, task={snap.Task}");
return 0;
}
}
var last = vehicle.Snapshot();
Console.WriteLine($"motion self-test failed: node={last.Node}, state={last.State}, task={last.Task}");
return 1;
}
private static int RunActionSelfTest()
{
Fass2SimFileLogger.Configure(new Fass2SimConfig { EnableFileLog = false });
var config = new Fass2SimConfig
{
InitialNode = 3,
ActionDelayMs = 500,
AutoCompleteStationActions = false
};
var (vehicle, _, actions) = Fass2SimBootstrap.CreateTestStack(config);
vehicle.State = 2;
vehicle.Node = 3;
vehicle.Lift = 0;
var station = new Fass2SimNodeMessage
{
Node = 3,
StartStop = Fass2SimActionResolver.StartStopStop,
Lift = 1
};
actions.OnStationArrival(station);
var pendingBefore = Fass2SimActionResolver.DescribePending(station, vehicle.Snapshot());
if (string.IsNullOrEmpty(pendingBefore) || vehicle.Lift != 0)
{
Console.WriteLine($"action self-test failed at setup: pending={pendingBefore}, lift={vehicle.Lift}");
return 1;
}
actions.OnActionCommand(2001, new Fass2SimNodeMessage { Node = 3, Lift = 1 });
for (var i = 0; i < 10; i++)
{
actions.Tick(100);
}
var snap = vehicle.Snapshot();
if (snap.Lift == 1 && actions.IsStationComplete())
{
Console.WriteLine($"action self-test ok: node={snap.Node}, lift={snap.Lift}");
return 0;
}
Console.WriteLine(
$"action self-test failed: lift={snap.Lift}, complete={actions.IsStationComplete()}, pending={Fass2SimActionResolver.DescribePending(station, snap)}");
return 1;
}
private static bool HasArg(string[] args, string name)
{
if (args == null)
{
return false;
}
foreach (var arg in args)
{
if (string.Equals(arg, name, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
}
}
@@ -0,0 +1,186 @@
using System;
using System.Net;
using System.Net.Sockets;
using System.Threading;
namespace StandardScene.Fass2Simulator
{
public sealed class Fass2SimUdpHost : IDisposable
{
private readonly Fass2SimConfig _config;
private readonly Fass2SimVehicle _vehicle;
private readonly Fass2SimCommandHandler _commandHandler;
private readonly Fass2SimMotionEngine _motionEngine;
private readonly Fass2SimActionEngine _actionEngine;
private readonly IPEndPoint _schedulerEndpoint;
private UdpClient _listener;
private UdpClient _sender;
private Thread _receiveThread;
private Timer _reportTimer;
private volatile bool _running;
public Fass2SimUdpHost(
Fass2SimConfig config,
Fass2SimVehicle vehicle,
Fass2SimCommandHandler commandHandler,
Fass2SimMotionEngine motionEngine,
Fass2SimActionEngine actionEngine)
{
_config = config;
_vehicle = vehicle;
_commandHandler = commandHandler;
_motionEngine = motionEngine;
_actionEngine = actionEngine;
_schedulerEndpoint = new IPEndPoint(IPAddress.Parse(config.SchedulerHost), config.SchedulerListenPort);
}
public bool IsRunning => _running;
public event EventHandler StatusReported;
public void Start()
{
if (_running)
{
return;
}
var listenAddress = IPAddress.Parse(_config.VehicleListenAddress);
_listener = new UdpClient(new IPEndPoint(listenAddress, _config.VehicleListenPort));
_sender = new UdpClient();
_running = true;
_receiveThread = new Thread(ReceiveLoop)
{
IsBackground = true,
Name = $"Fass2SimRx:{_config.VehicleListenPort}"
};
_receiveThread.Start();
var interval = Math.Max(50, _config.ReportIntervalMs);
_reportTimer = new Timer(SendStateReport, null, 0, interval);
Fass2SimLog.WriteLine($"[{Now()}] 模拟车已启动");
Fass2SimLog.WriteLine($" 车体监听 : {_config.VehicleListenAddress}:{_config.VehicleListenPort}");
Fass2SimLog.WriteLine($" 上报目标 : {_config.SchedulerHost}:{_config.SchedulerListenPort}");
Fass2SimLog.WriteLine($" 车号 : {_config.VehicleCode}");
Fass2SimLog.WriteLine($" 上报周期 : {interval} ms");
Fass2SimLog.WriteLine($" 路段模拟 : dist={_config.DefaultSegmentDistance}mm 或 node.Distance, speed={_config.DefaultSpeed}mm/s");
Fass2SimLog.WriteLine($" 动作延时 : {_config.ActionDelayMs}ms0xA1 到位),AutoComplete={_config.AutoCompleteStationActions}");
Fass2SimLog.WriteLine($" 初始节点 : {_config.InitialNode}, 状态={Fass2SimProtocol.StateText(_config.InitialState)}");
Fass2SimLog.WriteLine("等待 Mag2Car 联调(address=127.0.0.1, Port=5000, ListenPort=20103, VehicleCode=1");
}
public void Stop()
{
_running = false;
_reportTimer?.Dispose();
_reportTimer = null;
try
{
_listener?.Close();
}
catch
{
}
try
{
_receiveThread?.Join(1000);
}
catch
{
}
_receiveThread = null;
_listener = null;
try
{
_sender?.Close();
}
catch
{
}
_sender = null;
Fass2SimLog.WriteLine($"[{Now()}] 模拟车已停止");
}
public void Dispose()
{
Stop();
}
private void ReceiveLoop()
{
while (_running)
{
try
{
var remote = new IPEndPoint(IPAddress.Any, 0);
var packet = _listener.Receive(ref remote);
_commandHandler.Handle(packet, remote.ToString());
}
catch (SocketException) when (!_running)
{
break;
}
catch (ObjectDisposedException) when (!_running)
{
break;
}
catch (Exception ex)
{
Fass2SimLog.WriteLine($"[{Now()}] 接收异常: {ex.Message}");
}
}
}
private void SendStateReport(object _)
{
if (!_running)
{
return;
}
try
{
var interval = Math.Max(50, _config.ReportIntervalMs);
_actionEngine.Tick(interval);
_motionEngine.Tick(interval);
var snapshot = _vehicle.Snapshot();
var frame = Fass2SimProtocol.BuildState(snapshot);
_sender.Send(frame, frame.Length, _schedulerEndpoint);
_vehicle.RecordStateReportSent();
StatusReported?.Invoke(this, EventArgs.Empty);
if (snapshot.StateReportsSent == 0 || snapshot.StateReportsSent % 25 == 0)
{
var pending = _actionEngine.ExpectedStation == null
? "-"
: Fass2SimActionResolver.DescribePending(_actionEngine.ExpectedStation, snapshot);
if (string.IsNullOrEmpty(pending))
{
pending = "-";
}
Fass2SimLog.WriteLine(
$"[{Now()}] TX 状态 #{snapshot.StateReportsSent + 1}: node={snapshot.Node}, dist={snapshot.Distance}, state={Fass2SimProtocol.StateText(snapshot.State)}, lift={snapshot.Lift}, pending={pending}, task={snapshot.Task}");
}
}
catch (Exception ex)
{
Fass2SimLog.WriteLine($"[{Now()}] 上报异常: {ex.Message}");
}
}
private static string Now()
{
return DateTime.Now.ToString("HH:mm:ss.fff");
}
}
}
@@ -0,0 +1,171 @@
using System;
namespace StandardScene.Fass2Simulator
{
public sealed class Fass2SimVehicle
{
private readonly object _syncRoot = new object();
public Fass2SimVehicle(Fass2SimConfig config)
{
Car = config.VehicleCode;
Length = config.CarLength;
Width = config.CarWidth;
BatteryCharge = config.BatteryCharge;
BatteryHealth = 100;
BatteryVoltage = 480;
State = config.InitialState;
Node = config.InitialNode;
Speed = 500;
}
public ushort Car { get; }
public ushort Length { get; set; }
public ushort Width { get; set; }
public byte BatteryCharge { get; set; }
public byte BatteryHealth { get; set; }
public ushort BatteryCurrent { get; set; }
public ushort BatteryVoltage { get; set; }
public ushort HeadingAngle { get; set; }
public byte State { get; set; }
public ulong Alarm { get; set; }
public ulong Task { get; set; }
public byte LastCommand { get; set; }
public ushort Node { get; set; }
public ushort Distance { get; set; }
public byte StartStop { get; set; }
public byte Direction { get; set; }
public byte Orientation { get; set; }
public byte Byroad { get; set; }
public ushort Speed { get; set; }
public byte Obstacle { get; set; }
public byte Audio { get; set; }
public byte Light { get; set; }
public byte Charge { get; set; }
public byte Rest { get; set; }
public byte Lift { get; set; }
public byte Clamp { get; set; }
public byte Tray { get; set; }
public byte Roll { get; set; }
public byte Shutdown { get; set; }
public long StateReportsSent { get; private set; }
public long CommandsReceived { get; private set; }
public long AckReceived { get; private set; }
public DateTime LastAckUtc { get; private set; } = DateTime.MinValue;
public void RecordStateReportSent()
{
lock (_syncRoot)
{
StateReportsSent++;
}
}
public void RecordCommandReceived()
{
lock (_syncRoot)
{
CommandsReceived++;
}
}
public void RecordAckReceived()
{
lock (_syncRoot)
{
AckReceived++;
LastAckUtc = DateTime.UtcNow;
}
}
public Fass2SimVehicleSnapshot Snapshot()
{
lock (_syncRoot)
{
return CopySnapshotUnsafe();
}
}
/// <summary>
/// 供已持有其它引擎锁的代码路径读取快照,避免与 UI 线程 Snapshot() 争用 _syncRoot 导致死锁。
/// </summary>
internal Fass2SimVehicleSnapshot CopySnapshotUnsafe()
{
return new Fass2SimVehicleSnapshot
{
Car = Car,
Length = Length,
Width = Width,
BatteryCharge = BatteryCharge,
BatteryHealth = BatteryHealth,
BatteryCurrent = BatteryCurrent,
BatteryVoltage = BatteryVoltage,
HeadingAngle = HeadingAngle,
State = State,
Alarm = Alarm,
Task = Task,
LastCommand = LastCommand,
Node = Node,
Distance = Distance,
StartStop = StartStop,
Direction = Direction,
Orientation = Orientation,
Byroad = Byroad,
Speed = Speed,
Obstacle = Obstacle,
Audio = Audio,
Light = Light,
Charge = Charge,
Rest = Rest,
Lift = Lift,
Clamp = Clamp,
Tray = Tray,
Roll = Roll,
Shutdown = Shutdown,
StateReportsSent = StateReportsSent,
CommandsReceived = CommandsReceived,
AckReceived = AckReceived,
LastAckUtc = LastAckUtc
};
}
}
public sealed class Fass2SimVehicleSnapshot
{
public ushort Car { get; set; }
public ushort Length { get; set; }
public ushort Width { get; set; }
public byte BatteryCharge { get; set; }
public byte BatteryHealth { get; set; }
public ushort BatteryCurrent { get; set; }
public ushort BatteryVoltage { get; set; }
public ushort HeadingAngle { get; set; }
public byte State { get; set; }
public ulong Alarm { get; set; }
public ulong Task { get; set; }
public byte LastCommand { get; set; }
public ushort Node { get; set; }
public ushort Distance { get; set; }
public byte StartStop { get; set; }
public byte Direction { get; set; }
public byte Orientation { get; set; }
public byte Byroad { get; set; }
public ushort Speed { get; set; }
public byte Obstacle { get; set; }
public byte Audio { get; set; }
public byte Light { get; set; }
public byte Charge { get; set; }
public byte Rest { get; set; }
public byte Lift { get; set; }
public byte Clamp { get; set; }
public byte Tray { get; set; }
public byte Roll { get; set; }
public byte Shutdown { get; set; }
public long StateReportsSent { get; set; }
public long CommandsReceived { get; set; }
public long AckReceived { get; set; }
public DateTime LastAckUtc { get; set; }
}
}
@@ -0,0 +1,111 @@
<Window x:Class="StandardScene.Fass2Simulator.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="FASS2 车体模拟器"
Height="720"
Width="1080"
MinHeight="560"
MinWidth="900"
Background="White"
WindowStartupLocation="CenterScreen">
<Grid Margin="16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<DockPanel Grid.Row="0" Margin="0,0,0,12">
<StackPanel DockPanel.Dock="Left" Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="FASS2 车体模拟器" FontSize="22" FontWeight="SemiBold" Foreground="#111827"/>
<Border Margin="16,0,0,0" Padding="10,4" CornerRadius="12" Background="#E5E7EB">
<TextBlock x:Name="RunStateText" Text="已停止" FontWeight="SemiBold" Foreground="#6B7280"/>
</Border>
</StackPanel>
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" HorizontalAlignment="Right">
<Button x:Name="StartButton" Content="启动模拟" Width="110" Height="36" Margin="0,0,8,0"
Background="#2563EB" Foreground="White" BorderThickness="0" FontWeight="SemiBold" Click="StartButton_Click"/>
<Button x:Name="StopButton" Content="停止" Width="90" Height="36" Margin="0,0,8,0"
IsEnabled="False" Click="StopButton_Click"/>
<Button x:Name="ClearLogButton" Content="清空界面" Width="90" Height="36" Margin="0,0,8,0" Click="ClearLogButton_Click"/>
<Button x:Name="OpenLogFolderButton" Content="打开日志目录" Width="110" Height="36" Click="OpenLogFolderButton_Click"/>
</StackPanel>
</DockPanel>
<Grid Grid.Row="1" Margin="0,0,0,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="320"/>
<ColumnDefinition Width="16"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Background="{StaticResource PanelBrush}" BorderBrush="{StaticResource BorderBrush}"
BorderThickness="1" CornerRadius="8" Padding="14">
<StackPanel>
<TextBlock Text="联调参数" FontSize="16" FontWeight="SemiBold" Margin="0,0,0,12"/>
<TextBlock Text="车号" Margin="0,0,0,4"/>
<TextBox x:Name="VehicleCodeBox" Height="28" Margin="0,0,0,10"/>
<TextBlock Text="车体监听地址" Margin="0,0,0,4"/>
<TextBox x:Name="ListenAddressBox" Height="28" Margin="0,0,0,10"/>
<TextBlock Text="车体监听端口" Margin="0,0,0,4"/>
<TextBox x:Name="ListenPortBox" Height="28" Margin="0,0,0,10"/>
<TextBlock Text="调度地址" Margin="0,0,0,4"/>
<TextBox x:Name="SchedulerHostBox" Height="28" Margin="0,0,0,10"/>
<TextBlock Text="调度监听端口" Margin="0,0,0,4"/>
<TextBox x:Name="SchedulerPortBox" Height="28" Margin="0,0,0,10"/>
<TextBlock Text="上报周期 (ms)" Margin="0,0,0,4"/>
<TextBox x:Name="ReportIntervalBox" Height="28" Margin="0,0,0,10"/>
<TextBlock Text="初始节点" Margin="0,0,0,4"/>
<TextBox x:Name="InitialNodeBox" Height="28" Margin="0,0,0,10"/>
<CheckBox x:Name="AutoContinueBox" Content="途经站自动续跑" Margin="0,0,0,6"/>
<CheckBox x:Name="AutoCompleteBox" Content="到站自动补全动作" Margin="0,0,0,6"/>
<CheckBox x:Name="LogRawFramesBox" Content="记录原始报文"/>
</StackPanel>
</Border>
<Border Grid.Column="2" Background="{StaticResource PanelBrush}" BorderBrush="{StaticResource BorderBrush}"
BorderThickness="1" CornerRadius="8" Padding="14">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TextBlock Text="车体状态" FontSize="16" FontWeight="SemiBold" Margin="0,0,0,12"/>
<UniformGrid Grid.Row="1" Columns="4" Rows="4">
<StackPanel Margin="0,0,12,10"><TextBlock Text="上报节点" Foreground="#6B7280"/><TextBlock x:Name="NodeText" Text="-" FontSize="18" FontWeight="SemiBold"/></StackPanel>
<StackPanel Margin="0,0,12,10"><TextBlock Text="目标节点" Foreground="#6B7280"/><TextBlock x:Name="TargetNodeText" Text="-" FontSize="18" FontWeight="SemiBold"/></StackPanel>
<StackPanel Margin="0,0,12,10"><TextBlock Text="行驶距离 (mm)" Foreground="#6B7280"/><TextBlock x:Name="DistanceText" Text="-" FontSize="18" FontWeight="SemiBold"/></StackPanel>
<StackPanel Margin="0,0,12,10"><TextBlock Text="车体状态" Foreground="#6B7280"/><TextBlock x:Name="StateText" Text="-" FontSize="18" FontWeight="SemiBold"/></StackPanel>
<StackPanel Margin="0,0,12,10"><TextBlock Text="任务号" Foreground="#6B7280"/><TextBlock x:Name="TaskText" Text="-" FontSize="18" FontWeight="SemiBold"/></StackPanel>
<StackPanel Margin="0,0,12,10"><TextBlock Text="StartStop" Foreground="#6B7280"/><TextBlock x:Name="StartStopText" Text="-" FontSize="18" FontWeight="SemiBold"/></StackPanel>
<StackPanel Margin="0,0,12,10"><TextBlock Text="Lift" Foreground="#6B7280"/><TextBlock x:Name="LiftText" Text="-" FontSize="18" FontWeight="SemiBold"/></StackPanel>
<StackPanel Margin="0,0,12,10"><TextBlock Text="电量" Foreground="#6B7280"/><TextBlock x:Name="BatteryText" Text="-" FontSize="18" FontWeight="SemiBold"/></StackPanel>
<StackPanel Margin="0,0,12,10"><TextBlock Text="待动作" Foreground="#6B7280"/><TextBlock x:Name="PendingText" Text="-" FontSize="18" FontWeight="SemiBold"/></StackPanel>
<StackPanel Margin="0,0,12,10"><TextBlock Text="状态上报" Foreground="#6B7280"/><TextBlock x:Name="ReportsText" Text="0" FontSize="18" FontWeight="SemiBold"/></StackPanel>
<StackPanel Margin="0,0,12,10"><TextBlock Text="收令次数" Foreground="#6B7280"/><TextBlock x:Name="CommandsText" Text="0" FontSize="18" FontWeight="SemiBold"/></StackPanel>
<StackPanel Margin="0,0,12,10"><TextBlock Text="0x10 应答" Foreground="#6B7280"/><TextBlock x:Name="AckText" Text="0" FontSize="18" FontWeight="SemiBold"/></StackPanel>
<StackPanel Margin="0,0,12,10"><TextBlock Text="最近应答" Foreground="#6B7280"/><TextBlock x:Name="LastAckText" Text="-" FontSize="18" FontWeight="SemiBold"/></StackPanel>
</UniformGrid>
</Grid>
</Border>
</Grid>
<Border Grid.Row="2" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" CornerRadius="8" Padding="10">
<DockPanel>
<StackPanel DockPanel.Dock="Top" Margin="0,0,0,8">
<TextBlock Text="运行日志(界面仅保留最近 800 行,完整日志写入本地文件)" FontSize="16" FontWeight="SemiBold"/>
<TextBlock x:Name="LogFilePathText" Text="日志文件: -" FontSize="12" Foreground="#6B7280" Margin="0,4,0,0" TextWrapping="Wrap"/>
</StackPanel>
<TextBox x:Name="LogTextBox"
IsReadOnly="True"
TextWrapping="NoWrap"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Auto"
FontFamily="Consolas"
FontSize="12"
Background="#FAFAFA"
BorderThickness="0"/>
</DockPanel>
</Border>
</Grid>
</Window>
@@ -0,0 +1,365 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Windows;
using System.Windows.Media;
using System.Windows.Threading;
namespace StandardScene.Fass2Simulator
{
public partial class MainWindow : Window
{
private const int MaxLogLines = 800;
private readonly StringBuilder _logBuffer = new StringBuilder();
private int _logLineCount;
private Fass2SimConfig _config;
private Fass2SimRuntime _runtime;
private readonly DispatcherTimer _uiTimer;
private readonly Queue<string> _pendingLogLines = new Queue<string>();
private readonly object _logQueueLock = new object();
private bool _logFlushScheduled;
public MainWindow()
{
InitializeComponent();
_config = Fass2SimBootstrap.LoadConfig();
Fass2SimFileLogger.Configure(_config);
LoadConfigToUi(_config);
SetRunningUi(false);
UpdateLogFilePathText();
Fass2SimLog.MessageWritten += OnLogMessage;
_uiTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(250) };
_uiTimer.Tick += (_, __) => RefreshStatus();
_uiTimer.Start();
RefreshStatus();
Closed += (_, __) =>
{
Fass2SimLog.MessageWritten -= OnLogMessage;
_runtime?.Dispose();
Fass2SimFileLogger.Shutdown();
};
AppendLog("就绪。配置参数后点击「启动模拟」开始联调。完整日志将写入 log 目录。");
}
private void StartButton_Click(object sender, RoutedEventArgs e)
{
if (_runtime != null && _runtime.IsRunning)
{
return;
}
try
{
_config = ReadConfigFromUi();
Fass2SimFileLogger.Configure(_config);
Fass2SimFileLogger.BeginSession(_config);
UpdateLogFilePathText();
_runtime?.Dispose();
_runtime = Fass2SimBootstrap.CreateRuntime(_config);
_runtime.Start();
SetRunningUi(true);
RefreshStatus();
}
catch (Exception ex)
{
MessageBox.Show(this, ex.Message, "启动失败", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private void StopButton_Click(object sender, RoutedEventArgs e)
{
if (_runtime == null)
{
return;
}
_runtime.Stop();
SetRunningUi(false);
RefreshStatus();
}
private void ClearLogButton_Click(object sender, RoutedEventArgs e)
{
_logBuffer.Clear();
_logLineCount = 0;
LogTextBox.Clear();
}
private void OpenLogFolderButton_Click(object sender, RoutedEventArgs e)
{
var directory = Fass2SimFileLogger.GetLogDirectory();
Directory.CreateDirectory(directory);
Process.Start(new ProcessStartInfo
{
FileName = directory,
UseShellExecute = true
});
}
private void UpdateLogFilePathText()
{
if (!_config.EnableFileLog)
{
LogFilePathText.Text = "文件日志已关闭(appsettings.json: EnableFileLog=false";
return;
}
var path = Fass2SimFileLogger.CurrentFilePath;
if (string.IsNullOrEmpty(path))
{
LogFilePathText.Text = $"日志目录: {Fass2SimFileLogger.GetLogDirectory()}(启动模拟后生成会话日志)";
return;
}
LogFilePathText.Text = $"日志文件: {path}";
}
private void OnLogMessage(string message)
{
lock (_logQueueLock)
{
_pendingLogLines.Enqueue(message);
}
if (_logFlushScheduled)
{
return;
}
_logFlushScheduled = true;
Dispatcher.BeginInvoke(new Action(FlushPendingLogs), DispatcherPriority.Background);
}
private void FlushPendingLogs()
{
_logFlushScheduled = false;
List<string> batch;
lock (_logQueueLock)
{
if (_pendingLogLines.Count == 0)
{
return;
}
batch = new List<string>(_pendingLogLines.Count);
while (_pendingLogLines.Count > 0)
{
batch.Add(_pendingLogLines.Dequeue());
}
}
foreach (var line in batch)
{
AppendLog(line);
}
}
private void AppendLog(string message)
{
if (string.IsNullOrEmpty(message))
{
return;
}
_logBuffer.AppendLine(message);
_logLineCount++;
while (_logLineCount > MaxLogLines)
{
var bufferText = _logBuffer.ToString();
var firstBreak = bufferText.IndexOf('\n');
if (firstBreak < 0)
{
_logBuffer.Clear();
_logLineCount = 0;
break;
}
_logBuffer.Remove(0, firstBreak + 1);
_logLineCount--;
}
LogTextBox.Text = _logBuffer.ToString();
LogTextBox.CaretIndex = LogTextBox.Text.Length;
LogTextBox.ScrollToEnd();
}
private void RefreshStatus()
{
if (_runtime == null || !_runtime.IsRunning)
{
if (_runtime == null)
{
ApplyIdleStatus(
_config.InitialNode.ToString(),
"-",
"0",
Fass2SimProtocol.StateText(_config.InitialState),
"0",
"-",
"0",
$"{_config.BatteryCharge}%",
"-",
"0",
"0",
"0",
"无");
}
return;
}
var snap = _runtime.Vehicle.Snapshot();
var expected = _runtime.Actions.ExpectedStation;
var pending = expected == null
? "-"
: Fass2SimActionResolver.DescribePending(expected, snap);
if (string.IsNullOrEmpty(pending))
{
pending = "-";
}
var distanceText = snap.Distance.ToString();
var targetNodeText = "-";
if (_runtime.Motion.TryGetSegmentStatus(out var targetNode, out var segmentLengthMm, out var progressMm))
{
targetNodeText = targetNode.ToString();
distanceText = $"{progressMm} / {segmentLengthMm}";
}
ApplyIdleStatus(
snap.Node.ToString(),
targetNodeText,
distanceText,
Fass2SimProtocol.StateText(snap.State),
snap.Task.ToString(),
snap.StartStop.ToString(),
snap.Lift.ToString(),
$"{snap.BatteryCharge}%",
pending,
snap.StateReportsSent.ToString(),
snap.CommandsReceived.ToString(),
snap.AckReceived.ToString(),
snap.LastAckUtc == DateTime.MinValue
? "无"
: $"{(DateTime.UtcNow - snap.LastAckUtc).TotalSeconds:F1}s 前");
}
private void ApplyIdleStatus(
string node,
string targetNode,
string distance,
string state,
string task,
string startStop,
string lift,
string battery,
string pending,
string reports,
string commands,
string ack,
string lastAck)
{
NodeText.Text = node;
TargetNodeText.Text = targetNode;
DistanceText.Text = distance;
StateText.Text = state;
TaskText.Text = task;
StartStopText.Text = startStop;
LiftText.Text = lift;
BatteryText.Text = battery;
PendingText.Text = pending;
ReportsText.Text = reports;
CommandsText.Text = commands;
AckText.Text = ack;
LastAckText.Text = lastAck;
}
private void LoadConfigToUi(Fass2SimConfig config)
{
VehicleCodeBox.Text = config.VehicleCode.ToString();
ListenAddressBox.Text = config.VehicleListenAddress;
ListenPortBox.Text = config.VehicleListenPort.ToString();
SchedulerHostBox.Text = config.SchedulerHost;
SchedulerPortBox.Text = config.SchedulerListenPort.ToString();
ReportIntervalBox.Text = config.ReportIntervalMs.ToString();
InitialNodeBox.Text = config.InitialNode.ToString();
AutoContinueBox.IsChecked = config.AutoContinueOnPass;
AutoCompleteBox.IsChecked = config.AutoCompleteStationActions;
LogRawFramesBox.IsChecked = config.LogRawFrames;
}
private Fass2SimConfig ReadConfigFromUi()
{
return new Fass2SimConfig
{
VehicleCode = ParseUShort(VehicleCodeBox.Text, "车号"),
VehicleListenAddress = ListenAddressBox.Text?.Trim() ?? "0.0.0.0",
VehicleListenPort = ParseInt(ListenPortBox.Text, "车体监听端口"),
SchedulerHost = SchedulerHostBox.Text?.Trim() ?? "127.0.0.1",
SchedulerListenPort = ParseInt(SchedulerPortBox.Text, "调度监听端口"),
ReportIntervalMs = ParseInt(ReportIntervalBox.Text, "上报周期"),
InitialNode = ParseUShort(InitialNodeBox.Text, "初始节点"),
InitialState = _config?.InitialState ?? 0,
BatteryCharge = _config?.BatteryCharge ?? 100,
CarLength = _config?.CarLength ?? 1200,
CarWidth = _config?.CarWidth ?? 800,
DefaultSpeed = _config?.DefaultSpeed ?? 500,
DefaultSegmentDistance = _config?.DefaultSegmentDistance ?? 1000,
SecondsPerSegment = _config?.SecondsPerSegment ?? 2,
ActionDelayMs = _config?.ActionDelayMs ?? 1000,
NodeProfilesPath = _config?.NodeProfilesPath ?? "sim-nodes.json",
AutoContinueOnPass = AutoContinueBox.IsChecked == true,
AutoCompleteStationActions = AutoCompleteBox.IsChecked == true,
LogRawFrames = LogRawFramesBox.IsChecked == true,
EnableFileLog = _config?.EnableFileLog ?? true,
LogDirectory = _config?.LogDirectory ?? "log"
};
}
private void SetRunningUi(bool running)
{
StartButton.IsEnabled = !running;
StopButton.IsEnabled = running;
VehicleCodeBox.IsEnabled = !running;
ListenAddressBox.IsEnabled = !running;
ListenPortBox.IsEnabled = !running;
SchedulerHostBox.IsEnabled = !running;
SchedulerPortBox.IsEnabled = !running;
ReportIntervalBox.IsEnabled = !running;
InitialNodeBox.IsEnabled = !running;
AutoContinueBox.IsEnabled = !running;
AutoCompleteBox.IsEnabled = !running;
LogRawFramesBox.IsEnabled = !running;
RunStateText.Text = running ? "运行中" : "已停止";
RunStateText.Foreground = running
? new SolidColorBrush(Color.FromRgb(22, 163, 74))
: new SolidColorBrush(Color.FromRgb(107, 114, 128));
}
private static ushort ParseUShort(string text, string fieldName)
{
if (!ushort.TryParse(text?.Trim(), out var value))
{
throw new InvalidOperationException($"{fieldName} 无效:{text}");
}
return value;
}
private static int ParseInt(string text, string fieldName)
{
if (!int.TryParse(text?.Trim(), out var value))
{
throw new InvalidOperationException($"{fieldName} 无效:{text}");
}
return value;
}
}
}
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<RootNamespace>StandardScene.Fass2Simulator</RootNamespace>
<AssemblyName>StandardScene.Fass2Simulator</AssemblyName>
<LangVersion>latest</LangVersion>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>disable</Nullable>
<Deterministic>true</Deterministic>
<UseWPF>true</UseWPF>
<ApplicationIcon />
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
<None Update="appsettings.car2.json" CopyToOutputDirectory="PreserveNewest" />
<None Update="sim-nodes.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
@@ -0,0 +1,25 @@
{
"Fass2Simulator": {
"VehicleCode": 2,
"VehicleListenAddress": "0.0.0.0",
"VehicleListenPort": 5002,
"SchedulerHost": "127.0.0.1",
"SchedulerListenPort": 20103,
"ReportIntervalMs": 200,
"InitialNode": 5,
"InitialState": 1,
"BatteryCharge": 100,
"CarLength": 1200,
"CarWidth": 800,
"LogRawFrames": true,
"DefaultSpeed": 500,
"DefaultSegmentDistance": 1000,
"SecondsPerSegment": 2,
"AutoContinueOnPass": true,
"ActionDelayMs": 1000,
"AutoCompleteStationActions": false,
"NodeProfilesPath": "sim-nodes.json",
"EnableFileLog": true,
"LogDirectory": "log\\car2"
}
}
@@ -0,0 +1,25 @@
{
"Fass2Simulator": {
"VehicleCode": 1,
"VehicleListenAddress": "0.0.0.0",
"VehicleListenPort": 5001,
"SchedulerHost": "127.0.0.1",
"SchedulerListenPort": 20103,
"ReportIntervalMs": 200,
"InitialNode": 1,
"InitialState": 1,
"BatteryCharge": 100,
"CarLength": 1200,
"CarWidth": 800,
"LogRawFrames": false,
"DefaultSpeed": 500,
"DefaultSegmentDistance": 1000,
"SecondsPerSegment": 2,
"AutoContinueOnPass": true,
"ActionDelayMs": 1000,
"AutoCompleteStationActions": false,
"NodeProfilesPath": "sim-nodes.json",
"EnableFileLog": true,
"LogDirectory": "log"
}
}
@@ -0,0 +1,4 @@
{
"3": { "ActionDelayMs": 1200 },
"5": { "ActionDelayMs": 800 }
}
@@ -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
]
}
]
}
@@ -0,0 +1,171 @@
using StandardScene.Magnetic.Protocol;
using StandardScene.Magnetic.Tasking;
using StandardScene.Magnetic.Tests.TestHelpers;
using System;
using Xunit;
namespace StandardScene.Magnetic.Tests.Protocol
{
public sealed class Fass2ProtocolGoldenTests
{
[Fact]
public void BuildControl_Start_Car1_Param0_MatchesGoldenLayout()
{
var frame = Fass2Protocol.BuildControl(Fass2Protocol.CmdStart, 1, 0);
Assert.Equal(Fass2Protocol.ControlFrameLength, frame.Length);
Assert.Equal(Fass2Protocol.Begin, frame[0]);
Assert.Equal(Fass2Protocol.CmdStart, frame[1]);
Assert.Equal(0x01, frame[2]);
Assert.Equal(0x00, frame[3]);
Assert.Equal(0x00, frame[4]);
Assert.Equal(0x00, frame[5]);
Assert.Equal(Fass2Protocol.Xor(frame, 1, 47), frame[48]);
Assert.Equal(Fass2Protocol.End, frame[49]);
const string goldenPrefix = "BB 01 01 00 00 00";
Assert.StartsWith(goldenPrefix, Fass2TestFrameBuilder.ToHex(frame));
}
[Fact]
public void BuildNodes_TwoNodes_LittleEndianAndXor()
{
var nodes = new[]
{
new Fass2NodeMessage
{
Node = 1,
Distance = 500,
StartStop = 1,
Speed = 120
},
new Fass2NodeMessage
{
Node = 2,
StartStop = 2,
Lift = 1
}
};
var frame = Fass2Protocol.BuildNodes(1, 0x0102030405060708UL, nodes);
Assert.Equal(Fass2Protocol.NodesFrameLength, frame.Length);
Assert.Equal(Fass2Protocol.CmdNodes, frame[1]);
Assert.Equal(0x08, frame[4]);
Assert.Equal(0x01, frame[11]);
Assert.Equal(0x02, frame[12]);
Assert.Equal(0x00, frame[13]);
Assert.Equal(Fass2Protocol.Xor(frame, 1, 297), frame[298]);
Assert.Equal(Fass2Protocol.End, frame[299]);
var parsedNode0 = Fass2NodeMessage.FromBytes(CopyRange(frame, 14, 25));
Assert.Equal((ushort)1, parsedNode0.Node);
Assert.Equal((ushort)500, parsedNode0.Distance);
Assert.Equal((byte)1, parsedNode0.StartStop);
Assert.Equal((ushort)120, parsedNode0.Speed);
var parsedNode1 = Fass2NodeMessage.FromBytes(CopyRange(frame, 39, 25));
Assert.Equal((ushort)2, parsedNode1.Node);
Assert.Equal((byte)2, parsedNode1.StartStop);
Assert.Equal((byte)1, parsedNode1.Lift);
}
[Fact]
public void BuildAction_RoundTripsNodeBlock()
{
var node = new Fass2NodeMessage
{
Node = 3,
StartStop = 22,
Lift = 2,
Speed = 120
};
var frame = Fass2Protocol.BuildAction(2, 999, node);
Assert.Equal(Fass2Protocol.ActionFrameLength, frame.Length);
Assert.Equal(Fass2Protocol.CmdAction, frame[1]);
Assert.Equal(0x02, frame[2]);
Assert.Equal(0x00, frame[3]);
Assert.Equal(Fass2Protocol.Xor(frame, 1, 97), frame[98]);
var parsed = Fass2NodeMessage.FromBytes(CopyRange(frame, 14, 25));
Assert.Equal(node.Node, parsed.Node);
Assert.Equal(node.StartStop, parsed.StartStop);
Assert.Equal(node.Lift, parsed.Lift);
Assert.Equal(node.Speed, parsed.Speed);
}
[Fact]
public void BuildStateResponse_FixedTimestamp_EncodesBeijingCalendar()
{
var frame = Fass2Protocol.BuildStateResponse(1, 1_704_067_200_000UL);
Assert.Equal(Fass2Protocol.CmdStateResponse, frame[1]);
Assert.Equal(0x01, frame[2]);
Assert.Equal(0x00, frame[3]);
Assert.Equal(Fass2Protocol.Xor(frame, 1, 47), frame[48]);
Assert.Equal(Fass2Protocol.End, frame[49]);
}
[Fact]
public void ParseState_RoundTripsMinimalFrame()
{
var frame = Fass2TestFrameBuilder.BuildStateFrame(5, 2, 7, 42);
Assert.True(Fass2Protocol.TryExtractStateFrame(frame, frame.Length, out var extracted));
Assert.Equal(frame, extracted);
var report = Fass2Protocol.ParseState(frame);
Assert.Equal((ushort)5, report.Car);
Assert.Equal((byte)2, report.State);
Assert.Equal((ulong)42, report.Task);
Assert.Equal((ushort)7, report.Node.Node);
Assert.Equal((ushort)500, report.Node.Distance);
}
[Fact]
public void NodeMessage_ToBytes_FromBytes_RoundTrip()
{
var source = new Fass2NodeMessage
{
Node = 11,
Distance = 1234,
StartStop = 2,
Direction = 3,
Orientation = 64,
Byroad = 2,
Speed = 120,
Lift = 1,
Roll = 2,
Shutdown = 1
};
var roundTrip = Fass2NodeMessage.FromBytes(source.ToBytes());
Assert.Equal(source.Node, roundTrip.Node);
Assert.Equal(source.Distance, roundTrip.Distance);
Assert.Equal(source.StartStop, roundTrip.StartStop);
Assert.Equal(source.Direction, roundTrip.Direction);
Assert.Equal(source.Orientation, roundTrip.Orientation);
Assert.Equal(source.Byroad, roundTrip.Byroad);
Assert.Equal(source.Speed, roundTrip.Speed);
Assert.Equal(source.Lift, roundTrip.Lift);
Assert.Equal(source.Roll, roundTrip.Roll);
Assert.Equal(source.Shutdown, roundTrip.Shutdown);
}
[Fact]
public void SpeedToProtocol_UsesMPerMinTimesTen()
{
Assert.Equal((ushort)120, Fass2TaskBuilder.SpeedToProtocol(12));
Assert.Equal((ushort)120, Fass2TaskBuilder.SpeedToProtocol(0.2));
}
private static byte[] CopyRange(byte[] source, int offset, int length)
{
var copy = new byte[length];
Buffer.BlockCopy(source, offset, copy, 0, length);
return copy;
}
}
}
@@ -0,0 +1,63 @@
using StandardScene.Fass2Simulator;
using StandardScene.Magnetic.Protocol;
using Xunit;
namespace StandardScene.Magnetic.Tests.Protocol
{
/// <summary>
/// 验证 Magnetic 编解码与模拟器 100B 状态帧字段布局一致。
/// </summary>
public sealed class Fass2SimMagneticProtocolCrossTests
{
[Fact]
public void StateFrame_NodeBlockOffset_MatchesMagneticParser()
{
var vehicle = new Fass2SimVehicle(new Fass2SimConfig
{
VehicleCode = 1,
InitialNode = 9,
InitialState = 2
});
vehicle.StartStop = 2;
vehicle.Distance = 777;
vehicle.Speed = 120;
vehicle.Lift = 1;
vehicle.Task = 1001;
var simFrame = Fass2SimProtocol.BuildState(vehicle.Snapshot());
var report = Fass2Protocol.ParseState(simFrame);
Assert.Equal((ushort)1, report.Car);
Assert.Equal((byte)2, report.State);
Assert.Equal((ulong)1001, report.Task);
Assert.Equal((ushort)9, report.Node.Node);
Assert.Equal((ushort)777, report.Node.Distance);
Assert.Equal((byte)2, report.Node.StartStop);
Assert.Equal((ushort)120, report.Node.Speed);
Assert.Equal((byte)1, report.Node.Lift);
}
[Fact]
public void NodeBytes_Are25BytesAligned()
{
var node = new Fass2NodeMessage
{
Node = 4,
Distance = 100,
StartStop = 1,
Byroad = 2,
Speed = 60
};
var bytes = node.ToBytes();
Assert.Equal(25, bytes.Length);
var simNode = Fass2SimNodeMessage.FromBytes(bytes, 0);
Assert.Equal((ushort)4, simNode.Node);
Assert.Equal((ushort)100, simNode.Distance);
Assert.Equal((byte)1, simNode.StartStop);
Assert.Equal((byte)2, simNode.Byroad);
Assert.Equal((ushort)60, simNode.Speed);
}
}
}
@@ -0,0 +1,95 @@
using StandardScene.Magnetic.Protocol;
using StandardScene.Magnetic.Tests.TestHelpers;
using System;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using Xunit;
namespace StandardScene.Magnetic.Tests.Protocol
{
public sealed class Fass2UdpHubMultiCarTests : IDisposable
{
private readonly int _hubPort;
private readonly TestUdpCar _car1;
private readonly TestUdpCar _car2;
public Fass2UdpHubMultiCarTests()
{
_hubPort = AllocateUdpPort();
_car1 = new TestUdpCar(1, AllocateUdpPort());
_car2 = new TestUdpCar(2, AllocateUdpPort());
Fass2UdpHub.Register(_car1);
Fass2UdpHub.Register(_car2);
Fass2UdpHub.EnsureStarted(_hubPort);
Thread.Sleep(100);
}
[Fact]
public void Hub_RoutesStateToRegisteredCar_AndRepliesWith0x10()
{
SendState(_car1.VehicleCode, 2, 10, 100);
var ack1 = _car1.WaitForAck();
Assert.NotNull(ack1);
Assert.Equal(Fass2Protocol.ControlFrameLength, ack1.Length);
Assert.Equal(Fass2Protocol.CmdStateResponse, ack1[1]);
Assert.Equal(0x01, ack1[2]);
Assert.Single(_car1.Reports);
Assert.Equal((ushort)10, _car1.Reports[0].Node.Node);
Assert.Empty(_car2.Reports);
}
[Fact]
public void Hub_DispatchesMultipleCarsIndependently()
{
SendState(1, 1, 11, 201);
SendState(2, 2, 22, 202);
Assert.NotNull(_car1.WaitForAck());
Assert.NotNull(_car2.WaitForAck());
Assert.Single(_car1.Reports);
Assert.Single(_car2.Reports);
Assert.Equal((ushort)11, _car1.Reports[0].Node.Node);
Assert.Equal((ushort)22, _car2.Reports[0].Node.Node);
Assert.Equal((ulong)201, _car1.Reports[0].Task);
Assert.Equal((ulong)202, _car2.Reports[0].Task);
}
[Fact]
public void Hub_IgnoresUnregisteredCarCode()
{
SendState(99, 2, 30, 0);
Thread.Sleep(300);
Assert.Empty(_car1.Reports);
Assert.Empty(_car2.Reports);
}
public void Dispose()
{
Fass2UdpHub.Unregister(_car1);
Fass2UdpHub.Unregister(_car2);
Fass2UdpHub.Stop();
_car1.Dispose();
_car2.Dispose();
}
private void SendState(ushort carCode, byte state, ushort nodeId, ulong taskId)
{
var frame = Fass2TestFrameBuilder.BuildStateFrame(carCode, state, nodeId, taskId);
using var sender = new UdpClient();
sender.Send(frame, frame.Length, new IPEndPoint(IPAddress.Loopback, _hubPort));
Thread.Sleep(80);
}
private static int AllocateUdpPort()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
}
}
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<RootNamespace>StandardScene.Magnetic.Tests</RootNamespace>
<AssemblyName>StandardScene.Magnetic.Tests</AssemblyName>
<IsPackable>false</IsPackable>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>disable</Nullable>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StandardScene.Magnetic\StandardScene.Magnetic.csproj" />
<ProjectReference Include="..\StandardScene.Fass2Simulator\StandardScene.Fass2Simulator.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,164 @@
using StandardScene.Magnetic.Protocol;
using StandardScene.Magnetic.Tasking;
using System.Collections.Generic;
using Xunit;
namespace StandardScene.Magnetic.Tests.Tasking
{
public class Fass2ControlAreaGateTests
{
[Fact]
public void Resolve_WithoutArea_UsesCurrentSite()
{
var info = Fass2ControlAreaGate.Resolve(new Fass2SiteActionData(), 17);
Assert.Equal(new[] { 17 }, info.AreaSiteIds);
Assert.Equal("17", info.AreaId);
Assert.Equal(1, info.Capacity);
Assert.Equal(Fass2TaskBuilder.StartStopControlStart, info.ReleaseStartStop);
}
[Fact]
public void Resolve_ParsesSiteArray_AndReleasePass()
{
var info = Fass2ControlAreaGate.Resolve(new Fass2SiteActionData
{
ControlArea = "[10, 11, 12]",
ControlCapacity = 2,
ControlReleaseStartStop = Fass2TaskBuilder.StartStopPass
}, 8);
Assert.Equal(new[] { 10, 11, 12 }, info.AreaSiteIds);
Assert.Equal("10,11,12", info.AreaId);
Assert.Equal(2, info.Capacity);
Assert.Equal(Fass2TaskBuilder.StartStopPass, info.ReleaseStartStop);
}
[Fact]
public void ParseSiteIds_CommaAndSemicolon()
{
var ids = Fass2ControlAreaGate.ParseSiteIds("10;11|12", 8);
Assert.Equal(new[] { 10, 11, 12 }, ids);
}
[Fact]
public void LimitWindow_StopsAtFirstUnreleasedControlStop()
{
var nodes = new[]
{
Node(1, Fass2TaskBuilder.StartStopPass),
Node(2, Fass2TaskBuilder.StartStopControlStop),
Node(3, Fass2TaskBuilder.StartStopPass),
Node(4, Fass2TaskBuilder.StartStopPass)
};
var count = Fass2ControlAreaGate.LimitWindowCount(nodes, 0, 4, releasedIndex: -1);
Assert.Equal(2, count);
}
[Fact]
public void LimitWindow_AfterRelease_IncludesFollowingNodes()
{
var nodes = new[]
{
Node(1, Fass2TaskBuilder.StartStopPass),
Node(2, Fass2TaskBuilder.StartStopControlStart),
Node(3, Fass2TaskBuilder.StartStopPass)
};
var count = Fass2ControlAreaGate.LimitWindowCount(nodes, 1, 3, releasedIndex: 1);
Assert.Equal(2, count);
}
[Fact]
public void Evaluate_ExcludesSelf_AllowsFirstCar()
{
var area = Area(1, 10, 11);
var occupancies = new List<Fass2ControlOccupancy>
{
new Fass2ControlOccupancy(1, new[] { 10, 11 })
};
var check = Fass2ControlAreaGate.Evaluate(1, area, occupancies);
Assert.True(check.CanRelease);
Assert.Equal(0, check.OthersInArea);
}
[Fact]
public void Evaluate_OtherCarOnListedSite_EqualsCapacity_Blocks()
{
var area = Area(1, 10, 11);
var occupancies = new List<Fass2ControlOccupancy>
{
new Fass2ControlOccupancy(1, new[] { 8 }),
new Fass2ControlOccupancy(2, new[] { 11 })
};
var check = Fass2ControlAreaGate.Evaluate(1, area, occupancies);
Assert.False(check.CanRelease);
Assert.Equal(1, check.OthersInArea);
Assert.Contains("capacity=1", check.Reason);
}
[Fact]
public void Evaluate_OtherCarOutsideList_DoesNotBlock()
{
var area = Area(1, 10, 11);
var occupancies = new List<Fass2ControlOccupancy>
{
new Fass2ControlOccupancy(2, new[] { 99 })
};
var check = Fass2ControlAreaGate.Evaluate(1, area, occupancies);
Assert.True(check.CanRelease);
Assert.Equal(0, check.OthersInArea);
}
[Fact]
public void Evaluate_CapacityTwo_AllowsSecondCar()
{
var area = Area(2, 10, 11);
var occupancies = new List<Fass2ControlOccupancy>
{
new Fass2ControlOccupancy(1, new[] { 10 }),
new Fass2ControlOccupancy(2, new[] { 11 })
};
var check = Fass2ControlAreaGate.Evaluate(2, area, occupancies);
Assert.True(check.CanRelease);
Assert.Equal(1, check.OthersInArea);
}
[Fact]
public void ActionResolver_ControlStop_IsNeverComplete()
{
var expected = Node(5, Fass2TaskBuilder.StartStopControlStop);
var actual = Node(5, Fass2TaskBuilder.StartStopControlStop);
Assert.True(Fass2ActionResolver.RequiresActionWait(expected));
Assert.False(Fass2ActionResolver.IsStationActionComplete(expected, actual, vehicleState: 2));
}
private static Fass2ControlAreaInfo Area(int capacity, params int[] siteIds)
{
return new Fass2ControlAreaInfo
{
AreaSiteIds = siteIds,
AreaId = string.Join(",", siteIds),
Capacity = capacity,
ReleaseStartStop = 11
};
}
private static Fass2NodeMessage Node(ushort id, byte startStop)
{
return new Fass2NodeMessage { Node = id, StartStop = startStop };
}
}
}
@@ -0,0 +1,102 @@
using StandardScene.Magnetic.Protocol;
using StandardScene.Magnetic.Tasking;
using System.Collections.Generic;
using Xunit;
namespace StandardScene.Magnetic.Tests.Tasking
{
public class Fass2ControlStopStateMachineTests
{
[Fact]
public void ControlStop_TruncatesWindow_WaitsOccupancy_ThenReleases()
{
Fass2NodeMessage[] lastWindow = null;
Fass2NodeMessage lastPatch = null;
var canRelease = false;
var nodes = new List<Fass2NodeMessage>
{
new Fass2NodeMessage { Node = 1, StartStop = Fass2TaskBuilder.StartStopPass, Distance = 100 },
new Fass2NodeMessage { Node = 2, StartStop = Fass2TaskBuilder.StartStopControlStop },
new Fass2NodeMessage { Node = 3, StartStop = Fass2TaskBuilder.StartStopPass }
};
var plan = new Fass2TaskPlan
{
StartSiteId = 10,
GoalSiteId = 12,
SiteIds = new List<int>(),
Nodes = nodes,
FieldsSignature = "keep"
};
var machine = new Fass2TaskStateMachine(new Fass2TaskCallbacks
{
BuildPlan = (_, __, ___) => plan,
SendNodes = (window, _) => lastWindow = window,
SendAction = (patch, _) => lastPatch = patch,
SendControl = (_, __) => { },
ResolveNodeId = id => (ushort)id,
AllocateTaskId = () => 1UL,
AllocateActionId = () => 9UL,
CheckControlRelease = _ => new Fass2ControlReleaseCheck
{
CanRelease = canRelease,
AreaId = "DockA",
Capacity = 1,
OthersInArea = canRelease ? 0 : 1,
ReleaseStartStop = Fass2TaskBuilder.StartStopControlStart,
Reason = canRelease ? "area clear" : "area occupied"
}
})
{
StartBeforeMove = false,
LockCount = 4,
ResendIntervalMs = 60_000,
ActionRetryIntervalMs = 0
};
machine.Begin(10, 12);
machine.Tick(Report(1, moving: true, startStop: 1));
Assert.Equal(Fass2TaskPhase.Moving, machine.Context.Phase);
Assert.NotNull(lastWindow);
Assert.Equal(2, lastWindow.Length);
Assert.Equal(Fass2TaskBuilder.StartStopControlStop, lastWindow[1].StartStop);
machine.Tick(Report(2, moving: false, startStop: 12));
Assert.Equal(Fass2TaskPhase.AtStation, machine.Context.Phase);
Assert.Equal(1, machine.Context.CurrentIndex);
lastPatch = null;
machine.Tick(Report(2, moving: false, startStop: 12));
Assert.True(machine.Context.WaitingForRelease);
Assert.Null(lastPatch);
Assert.Equal(-1, machine.Context.ControlReleasedIndex);
canRelease = true;
machine.Tick(Report(2, moving: false, startStop: 12));
Assert.NotNull(lastPatch);
Assert.Equal(Fass2TaskBuilder.StartStopControlStart, lastPatch.StartStop);
Assert.Equal(1, machine.Context.ControlReleasedIndex);
Assert.Equal(Fass2TaskBuilder.StartStopControlStart, nodes[1].StartStop);
Assert.True(lastWindow.Length >= 2);
Assert.Equal((ushort)3, lastWindow[lastWindow.Length - 1].Node);
machine.Tick(Report(2, moving: false, startStop: 11));
Assert.Equal(Fass2TaskPhase.AtStation, machine.Context.Phase);
Assert.Equal(1, machine.Context.CurrentIndex);
}
private static Fass2StateReport Report(ushort node, bool moving, byte startStop)
{
return new Fass2StateReport
{
State = (byte)(moving ? 1 : 2),
Node = new Fass2NodeMessage
{
Node = node,
StartStop = startStop
}
};
}
}
}
@@ -0,0 +1,83 @@
using StandardScene.Magnetic.Tasking;
using System.Collections.Generic;
using Xunit;
namespace StandardScene.Magnetic.Tests.Tasking
{
public class Fass2ReconnectChainSelectorTests
{
[Fact]
public void PreferGoal_OnRemainingPath_Wins()
{
var matches = new List<Fass2ChainMatch>
{
Match(1, start: 6, target: 10, site: 8, index: 1, remain: new[] { 8, 9, 10 }, prio: 9),
Match(2, start: 1, target: 5, site: 8, index: 3, remain: new[] { 8, 4, 5 }, prio: 1)
};
var ok = Fass2ReconnectChainSelector.TrySelect(matches, preferGoalSiteId: 5, out var selected, out var reason);
Assert.True(ok);
Assert.Equal(5, selected.TargetSiteId);
Assert.Contains("preferGoal=5", reason);
}
[Fact]
public void StartPoint_Beats_Priority_WhenGoalNotOnPath()
{
var matches = new List<Fass2ChainMatch>
{
Match(1, start: 8, target: 12, site: 8, index: 0, remain: new[] { 8, 12 }, prio: 1, atStart: true),
Match(2, start: 6, target: 10, site: 8, index: 1, remain: new[] { 8, 9, 10 }, prio: 9)
};
var ok = Fass2ReconnectChainSelector.TrySelect(matches, preferGoalSiteId: 99, out var selected, out _);
Assert.True(ok);
Assert.Equal(12, selected.TargetSiteId);
Assert.True(selected.IsAtStartPoint);
}
[Fact]
public void Ambiguous_DifferentTargets_SameScore_Fails()
{
var matches = new List<Fass2ChainMatch>
{
Match(1, start: 1, target: 5, site: 3, index: 1, remain: new[] { 3, 4, 5 }, prio: 1),
Match(2, start: 9, target: 7, site: 3, index: 1, remain: new[] { 3, 6, 7 }, prio: 1)
};
var ok = Fass2ReconnectChainSelector.TrySelect(matches, preferGoalSiteId: null, out var selected, out var reason);
Assert.False(ok);
Assert.Null(selected);
Assert.Contains("ambiguous", reason);
}
[Fact]
public void Empty_Fails()
{
var ok = Fass2ReconnectChainSelector.TrySelect(new List<Fass2ChainMatch>(), null, out _, out var reason);
Assert.False(ok);
Assert.Equal("no chain", reason);
}
private static Fass2ChainMatch Match(
int taskId, int start, int target, int site, int index, int[] remain, int prio, bool atStart = false)
{
return new Fass2ChainMatch
{
TaskId = taskId,
TaskPriority = prio,
CurrentSiteId = site,
TargetSiteId = target,
IndexInPath = index,
DistanceToTarget = remain.Length > 0 ? remain.Length - 1 : 0,
IsAtStartPoint = atStart || index == 0,
IsAtEndPoint = remain.Length <= 1,
RemainingPath = remain,
FullPath = remain
};
}
}
}
@@ -0,0 +1,62 @@
using StandardScene.Fass2Simulator;
using Xunit;
namespace StandardScene.Magnetic.Tests.Tasking
{
public class Fass2SimControlStopTests
{
[Fact]
public void ControlStop_StaysUntilReleasePatch()
{
var config = new Fass2SimConfig
{
InitialNode = 1,
DefaultSegmentDistance = 200,
DefaultSpeed = 1000,
AutoContinueOnPass = true,
AutoCompleteStationActions = false,
ActionDelayMs = 0
};
var (vehicle, motion, actions) = Fass2SimBootstrap.CreateTestStack(config);
vehicle.State = 1;
motion.OnNodesCommand(3001, new[]
{
new Fass2SimNodeMessage { Node = 1, StartStop = 1, Distance = 200 },
new Fass2SimNodeMessage { Node = 2, StartStop = 12 }
});
for (var i = 0; i < 20 && vehicle.Node != 2; i++)
{
motion.Tick(50);
actions.Tick(50);
}
Assert.Equal((ushort)2, vehicle.Node);
Assert.Equal((byte)2, vehicle.State);
Assert.Equal((byte)12, vehicle.StartStop);
Assert.False(actions.CanLeaveStation());
motion.OnNodesCommand(3001, new[]
{
new Fass2SimNodeMessage { Node = 2, StartStop = 11, Distance = 200 },
new Fass2SimNodeMessage { Node = 3, StartStop = 1 }
});
actions.OnActionCommand(9, new Fass2SimNodeMessage
{
Node = 2,
StartStop = 11
});
actions.Tick(10);
motion.OnStationActionsCompleted();
for (var i = 0; i < 30 && vehicle.Node != 3; i++)
{
motion.Tick(50);
actions.Tick(50);
}
Assert.Equal((ushort)3, vehicle.Node);
}
}
}
@@ -0,0 +1,69 @@
using StandardScene.Magnetic.Protocol;
using System;
namespace StandardScene.Magnetic.Tests.TestHelpers
{
internal static class Fass2TestFrameBuilder
{
public static byte[] BuildStateFrame(
ushort car,
byte state,
ushort nodeId,
ulong taskId = 0,
byte command = 0)
{
var frame = new byte[Fass2Protocol.StateFrameLength];
frame[0] = Fass2Protocol.Begin;
frame[1] = command;
WriteUInt16(frame, 2, car);
WriteUInt16(frame, 4, 1200);
WriteUInt16(frame, 6, 800);
frame[28] = 100;
frame[29] = 100;
frame[36] = state;
WriteUInt64(frame, 45, taskId);
var node = new Fass2NodeMessage
{
Node = nodeId,
StartStop = 1,
Distance = 500,
Speed = 120
};
Buffer.BlockCopy(node.ToBytes(), 0, frame, 53, 25);
frame[98] = Fass2Protocol.Xor(frame, 1, 97);
frame[99] = Fass2Protocol.End;
return frame;
}
public static string ToHex(byte[] bytes)
{
if (bytes == null)
{
return string.Empty;
}
var parts = new string[bytes.Length];
for (var i = 0; i < bytes.Length; i++)
{
parts[i] = bytes[i].ToString("X2");
}
return string.Join(" ", parts);
}
private static void WriteUInt16(byte[] buffer, int offset, ushort value)
{
var bytes = BitConverter.GetBytes(value);
buffer[offset] = bytes[0];
buffer[offset + 1] = bytes[1];
}
private static void WriteUInt64(byte[] buffer, int offset, ulong value)
{
var bytes = BitConverter.GetBytes(value);
Buffer.BlockCopy(bytes, 0, buffer, offset, 8);
}
}
}
@@ -0,0 +1,64 @@
using StandardScene.Magnetic.Protocol;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Threading;
namespace StandardScene.Magnetic.Tests.TestHelpers
{
internal sealed class TestUdpCar : IFass2UdpCar, IDisposable
{
private readonly UdpClient _ackListener;
private readonly object _syncRoot = new object();
public TestUdpCar(ushort vehicleCode, int remotePort)
{
VehicleCode = vehicleCode;
RemotePort = remotePort;
_ackListener = new UdpClient(new IPEndPoint(IPAddress.Loopback, remotePort));
_ackListener.Client.ReceiveTimeout = 500;
}
public ushort VehicleCode { get; }
public string RemoteAddress => "127.0.0.1";
public int RemotePort { get; }
public List<Fass2StateReport> Reports { get; } = new List<Fass2StateReport>();
public void OnUdpStateReceived(Fass2StateReport report)
{
lock (_syncRoot)
{
Reports.Add(report);
}
}
public byte[] WaitForAck(int timeoutMs = 2000)
{
var deadline = Environment.TickCount64 + timeoutMs;
while (Environment.TickCount64 < deadline)
{
try
{
var remote = new IPEndPoint(IPAddress.Any, 0);
return _ackListener.Receive(ref remote);
}
catch (SocketException)
{
Thread.Sleep(20);
}
}
return null;
}
public void Dispose()
{
_ackListener?.Close();
_ackListener?.Dispose();
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,209 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using SimpleCore;
using SimpleCore.Library;
using SimpleLite;
using SimpleLite.Props;
using SimpleLite.RCS;
using SimpleLite.RCS.CarTypes;
using StandardScene.CarTypes;
using StandardScene.Chained;
using StandardScene.Magnetic.Tasking;
using StandardScene.Model;
namespace StandardScene
{
/// <summary>
/// 磁导航 FASS 2.0 环线任务进程。
/// <para>任务分配/触发/流量逻辑继承 Core <see cref="LoopMission"/>。</para>
/// <para>导航执行由 <see cref="Mag2Car"/> 拦截 Loop 编译脚本(含 <c>Mag2Go</c> + <c>goalSite</c>),
/// 改为单次全程 <c>0xB1</c> 状态机任务。</para>
/// </summary>
[MissionType(Name = "磁导航FASS2环线", editor = typeof(Mag2LoopMission))]
public class Mag2LoopMission : LoopMission
{
[JsonIgnore] public override MissionStatus status { get; set; } = new LoopMissionStatus();
/// <summary>
/// Mag2 空闲时可能尚未 TrafficResetholdingLocks 为空),仍按上报站点/siteID 匹配在站车辆,
/// 否则 AutoLoop 永远找不到车、加不上 goalSite。
/// </summary>
protected override Car FindCarArrivedAtSite(int siteId)
{
var byLock = base.FindCarArrivedAtSite(siteId);
if (byLock != null)
return byLock;
try
{
return SimpleLib.GetAllCars()
.OfType<Mag2Car>()
.FirstOrDefault(car =>
{
if (car?.tags == null || car.tags.Contains("occupied") || !car.tags.Contains("Online"))
return false;
if (car.status?.pendingLocks != null && car.status.pendingLocks.Length > 0)
return false;
if (car.status?.holdingLocks != null && car.status.holdingLocks.Length > 1)
return false;
var physical = car.siteID > 0 ? car.siteID : car.GetLastSite();
return physical == siteId;
});
}
catch
{
return null;
}
}
/// <summary>
/// 分配前尽量补上本站交管锁,保证 SelectCar/GoSite 的 GetLastSite 可用。
/// </summary>
protected override void AssignCarToTarget(Car car, int targetSiteId)
{
if (car is Mag2Car mag2)
{
EnsureMag2HoldingCurrentSite(mag2);
}
base.AssignCarToTarget(car, targetSiteId);
}
/// <summary>
/// 用当前占点匹配全部 LoopTask 展开路径(不过滤 IsViaPoint)。
/// 路径用 BFS,避免 FindRoute 污染交管锁。
/// </summary>
public static List<Fass2ChainMatch> FindChainsContaining(int siteId)
{
var matches = new List<Fass2ChainMatch>();
if (siteId <= 0)
{
return matches;
}
IEnumerable<AbstractLoopMission> missions = null;
try
{
missions = SimpleProject.proj?.Missions?.OfType<AbstractLoopMission>();
}
catch
{
missions = null;
}
if (missions == null)
{
return matches;
}
foreach (var mission in missions)
{
IReadOnlyList<LoopTask> tasks;
try
{
tasks = mission.GetTasks();
}
catch
{
continue;
}
if (tasks == null)
{
continue;
}
foreach (var task in tasks)
{
var match = TryMatchTask(task, siteId);
if (match != null)
{
matches.Add(match);
}
}
}
return matches;
}
public static bool TryResolveBelongingChain(
int siteId,
int? preferGoalSiteId,
out Fass2ChainMatch selected,
out string reason)
{
var matches = FindChainsContaining(siteId);
return Fass2ReconnectChainSelector.TrySelect(matches, preferGoalSiteId, out selected, out reason);
}
private static Fass2ChainMatch TryMatchTask(LoopTask task, int siteId)
{
if (task == null || task.CurrentStationId <= 0 || task.TargetStationId <= 0)
{
return null;
}
List<int> fullPath;
try
{
fullPath = Fass2RouteHelper.GetSitesBetween(task.CurrentStationId, task.TargetStationId, null);
}
catch
{
return null;
}
if (fullPath == null || fullPath.Count == 0)
{
return null;
}
var index = fullPath.IndexOf(siteId);
if (index < 0)
{
return null;
}
var remaining = fullPath.Skip(index).ToList();
return new Fass2ChainMatch
{
TaskId = task.Id,
TaskPriority = task.Priority,
CurrentSiteId = siteId,
TargetSiteId = task.TargetStationId,
IndexInPath = index,
DistanceToTarget = Math.Max(0, fullPath.Count - index - 1),
IsAtStartPoint = index == 0,
IsAtEndPoint = index == fullPath.Count - 1,
FullPath = fullPath,
RemainingPath = remaining
};
}
private static void EnsureMag2HoldingCurrentSite(Mag2Car car)
{
try
{
if (car.status?.holdingLocks != null && car.status.holdingLocks.Length == 1)
return;
var siteId = car.siteID > 0 ? car.siteID : car.GetLastSite();
if (siteId <= 0)
return;
var site = SimpleLib.GetSite(siteId);
if (site == null)
return;
car.TrafficReset(site, true, strict: false);
}
catch (Exception)
{
// 交管失败时仍允许打上 goalSite,后续 GoSite/状态机会再处理
}
}
}
}
@@ -0,0 +1,351 @@
[
{
"TriggerEnable": true,
"TriggerSource": "Car",
"TriggerEvent": "TimerTick",
"TriggerCarCode": null,
"TriggerCarPrevNodeCode": null,
"TriggerCarCurrentNodeCode": "3",
"TriggerCarNextNodeCode": null,
"TriggerCarState": "Stopping",
"TriggerCarAngle": null,
"ConditionEvents": [
{
"ConditionEnable": true,
"ConditionType": "Method",
"ConditionAssemblyFile": null,
"ConditionTypeName": null,
"ConditionTypeArgs": null,
"ConditionMethodName": "NodeAllUnlockedNotCar",
"ConditionMethodArgs": "3,53,21"
}
],
"TaskEnable": true,
"TaskType": "Start"
},
{
"TriggerEnable": true,
"TriggerSource": "Car",
"TriggerEvent": "TimerTick",
"TriggerCarCode": null,
"TriggerCarPrevNodeCode": null,
"TriggerCarCurrentNodeCode": "16",
"TriggerCarNextNodeCode": null,
"TriggerCarState": "WaitPass",
"TriggerCarAngle": null,
"ConditionEvents": [
{
"ConditionEnable": true,
"ConditionType": "Method",
"ConditionAssemblyFile": null,
"ConditionTypeName": null,
"ConditionTypeArgs": null,
"ConditionMethodName": "NodeAllUnlockedNotCar",
"ConditionMethodArgs": "16,3"
}
],
"TaskEnable": true,
"TaskType": "Start"
},
{
"TriggerEnable": true,
"TriggerSource": "Car",
"TriggerEvent": "TimerTick",
"TriggerCarCode": null,
"TriggerCarPrevNodeCode": null,
"TriggerCarCurrentNodeCode": "21",
"TriggerCarNextNodeCode": null,
"TriggerCarState": "Stopping",
"TriggerCarAngle": null,
"ConditionEvents": [
{
"ConditionEnable": true,
"ConditionType": "Method",
"ConditionAssemblyFile": null,
"ConditionTypeName": null,
"ConditionTypeArgs": null,
"ConditionMethodName": "NodeAllUnlockedNotCar",
"ConditionMethodArgs": "21,1,4"
},
{
"ConditionEnable": true,
"ConditionType": "Method",
"ConditionAssemblyFile": null,
"ConditionTypeName": null,
"ConditionTypeArgs": null,
"ConditionMethodName": "requestStaion",
"ConditionMethodArgs": "DB5524.0.0"
}
],
"TaskEnable": true,
"TaskType": "Start"
},
{
"TriggerEnable": true,
"TriggerSource": "Car",
"TriggerEvent": "TimerTick",
"TriggerCarCode": null,
"TriggerCarPrevNodeCode": null,
"TriggerCarCurrentNodeCode": "1",
"TriggerCarNextNodeCode": null,
"TriggerCarState": "Stopping",
"TriggerCarAngle": null,
"ConditionEvents": [
{
"ConditionEnable": true,
"ConditionType": "Method",
"ConditionAssemblyFile": null,
"ConditionTypeName": null,
"ConditionTypeArgs": null,
"ConditionMethodName": "InplaceStaion",
"ConditionMethodArgs": "DB5524.2.0,DB5524.0.1"
}
],
"TaskEnable": true,
"TaskType": "Start"
},
{
"TriggerEnable": true,
"TriggerSource": "Car",
"TriggerEvent": "TimerTick",
"TriggerCarCode": null,
"TriggerCarPrevNodeCode": null,
"TriggerCarCurrentNodeCode": "2",
"TriggerCarNextNodeCode": null,
"TriggerCarState": "Stopping",
"TriggerCarAngle": null,
"ConditionEvents": [
{
"ConditionEnable": true,
"ConditionType": "Method",
"ConditionAssemblyFile": null,
"ConditionTypeName": null,
"ConditionTypeArgs": null,
"ConditionMethodName": "clearStaion",
"ConditionMethodArgs": "DB5524.2.0"
}
],
"TaskEnable": true,
"TaskType": "Start"
},
{
"TriggerEnable": true,
"TriggerSource": "Car",
"TriggerEvent": "TimerTick",
"TriggerCarCode": null,
"TriggerCarPrevNodeCode": null,
"TriggerCarCurrentNodeCode": "53",
"TriggerCarNextNodeCode": null,
"TriggerCarState": "Running",
"TriggerCarAngle": null,
"ConditionEvents": [
{
"ConditionEnable": true,
"ConditionType": "Method",
"ConditionAssemblyFile": null,
"ConditionTypeName": null,
"ConditionTypeArgs": null,
"ConditionMethodName": "EmegerStop",
"ConditionMethodArgs": "DB5524.0.2"
}
],
"TaskEnable": true,
"TaskType": "Stop"
},
{
"TriggerEnable": true,
"TriggerSource": "Car",
"TriggerEvent": "TimerTick",
"TriggerCarCode": null,
"TriggerCarPrevNodeCode": null,
"TriggerCarCurrentNodeCode": "53",
"TriggerCarNextNodeCode": null,
"TriggerCarState": "Stopping",
"TriggerCarAngle": null,
"ConditionEvents": [
{
"ConditionEnable": true,
"ConditionType": "Method",
"ConditionAssemblyFile": null,
"ConditionTypeName": null,
"ConditionTypeArgs": null,
"ConditionMethodName": "EmegerStart",
"ConditionMethodArgs": "DB5524.0.2"
}
],
"TaskEnable": true,
"TaskType": "Start"
},
{
"TriggerEnable": true,
"TriggerSource": "Car",
"TriggerEvent": "TimerTick",
"TriggerCarCode": null,
"TriggerCarPrevNodeCode": null,
"TriggerCarCurrentNodeCode": "5",
"TriggerCarNextNodeCode": null,
"TriggerCarState": "Stopping,Charging",
"TriggerCarAngle": null,
"ConditionEvents": [
{
"ConditionEnable": true,
"ConditionType": "Method",
"ConditionAssemblyFile": null,
"ConditionTypeName": null,
"ConditionTypeArgs": null,
"ConditionMethodName": "NodeAllUnlockedNotCar",
"ConditionMethodArgs": "5,54"
}
],
"TaskEnable": true,
"TaskType": "Start"
},
{
"TriggerEnable": true,
"TriggerSource": "Car",
"TriggerEvent": "TimerTick",
"TriggerCarCode": null,
"TriggerCarPrevNodeCode": null,
"TriggerCarCurrentNodeCode": "7",
"TriggerCarNextNodeCode": null,
"TriggerCarState": "Running",
"TriggerCarAngle": null,
"ConditionEvents": [
{
"ConditionEnable": true,
"ConditionType": "Method",
"ConditionAssemblyFile": null,
"ConditionTypeName": null,
"ConditionTypeArgs": null,
"ConditionMethodName": "NodeAllUnlockedNotCar",
"ConditionMethodArgs": "7,8"
}
],
"TaskEnable": false,
"TaskType": "Start"
},
{
"TriggerEnable": true,
"TriggerSource": "Car",
"TriggerEvent": "TimerTick",
"TriggerCarCode": null,
"TriggerCarPrevNodeCode": null,
"TriggerCarCurrentNodeCode": "9",
"TriggerCarNextNodeCode": null,
"TriggerCarState": "Running",
"TriggerCarAngle": null,
"ConditionEvents": [
{
"ConditionEnable": true,
"ConditionType": "Method",
"ConditionAssemblyFile": null,
"ConditionTypeName": null,
"ConditionTypeArgs": null,
"ConditionMethodName": "NodeAllUnlockedNotCar",
"ConditionMethodArgs": "9"
}
],
"TaskEnable": false,
"TaskType": "Start"
},
{
"TriggerEnable": true,
"TriggerSource": "Car",
"TriggerEvent": "TimerTick",
"TriggerCarCode": null,
"TriggerCarPrevNodeCode": null,
"TriggerCarCurrentNodeCode": "11",
"TriggerCarNextNodeCode": null,
"TriggerCarState": "Running",
"TriggerCarAngle": null,
"ConditionEvents": [
{
"ConditionEnable": true,
"ConditionType": "Method",
"ConditionAssemblyFile": null,
"ConditionTypeName": null,
"ConditionTypeArgs": null,
"ConditionMethodName": "NodeAllUnlockedNotCar",
"ConditionMethodArgs": "11"
}
],
"TaskEnable": false,
"TaskType": "Start"
},
{
"TriggerEnable": true,
"TriggerSource": "Car",
"TriggerEvent": "TimerTick",
"TriggerCarCode": null,
"TriggerCarPrevNodeCode": null,
"TriggerCarCurrentNodeCode": "14",
"TriggerCarNextNodeCode": null,
"TriggerCarState": "Running",
"TriggerCarAngle": null,
"ConditionEvents": [
{
"ConditionEnable": true,
"ConditionType": "Method",
"ConditionAssemblyFile": null,
"ConditionTypeName": null,
"ConditionTypeArgs": null,
"ConditionMethodName": "NodeAllUnlockedNotCar",
"ConditionMethodArgs": "14"
}
],
"TaskEnable": false,
"TaskType": "Start"
},
{
"TriggerEnable": true,
"TriggerSource": "Car",
"TriggerEvent": "TimerTick",
"TriggerCarCode": null,
"TriggerCarPrevNodeCode": null,
"TriggerCarCurrentNodeCode": "15",
"TriggerCarNextNodeCode": null,
"TriggerCarState": "Running",
"TriggerCarAngle": null,
"ConditionEvents": [
{
"ConditionEnable": true,
"ConditionType": "Method",
"ConditionAssemblyFile": null,
"ConditionTypeName": null,
"ConditionTypeArgs": null,
"ConditionMethodName": "NodeAllUnlockedNotCar",
"ConditionMethodArgs": "15"
}
],
"TaskEnable": false,
"TaskType": "Start"
},
{
"TriggerEnable": true,
"TriggerSource": "Car",
"TriggerEvent": "TimerTick",
"TriggerCarCode": null,
"TriggerCarPrevNodeCode": null,
"TriggerCarCurrentNodeCode": "27",
"TriggerCarNextNodeCode": null,
"TriggerCarState": "Stopping,Charging",
"TriggerCarAngle": null,
"ConditionEvents": [
{
"ConditionEnable": true,
"ConditionType": "Method",
"ConditionAssemblyFile": null,
"ConditionTypeName": null,
"ConditionTypeArgs": null,
"ConditionMethodName": "ChargeStart",
"ConditionMethodArgs": "95"
}
],
"TaskEnable": true,
"TaskType": "Start"
}
]
@@ -7,8 +7,9 @@ namespace StandardScene.Magnetic
{
/// <summary>
/// scene.mag 平台画像:磁导航场景插件。
/// <para>车型为磁导航专用 <see cref="MagCar"/>与下位机通过 UDP 协议交互);路径由
/// MagneticTrackCoder 生成 agv.MagGo / agv.NaiveMagGo 脚本(按 track.Magnet 触发)。</para>
/// <para>车型<see cref="MagCar"/>FASS 1.0 TCP)、<see cref="Mag2Car"/>FASS 2.0 UDP/任务状态机)。
/// Loop 导航:继续使用 Core <c>LoopMission</c> 或本插件 <c>Mag2LoopMission</c> 分配 <c>goalSite</c>
/// <see cref="Mag2Car"/> 拦截编译脚本后一次下发全程 <c>0xB1</c>。</para>
/// 宿主(SimpleLite)加载本 dll 后反射实例化并 OnActivate / 注册。
/// </summary>
public sealed class MagneticSceneProfile : NavigationProfileBase
@@ -22,11 +23,12 @@ namespace StandardScene.Magnetic
public override IReadOnlyList<Type> CarTypes => new[]
{
typeof(MagCar),
typeof(Mag2Car),
};
public override void OnActivate(ISceneContext context)
{
context.Log($"{DisplayName} 已激活(车型:磁导航车 MagCar");
context.Log($"{DisplayName} 已激活(车型:MagCar / Mag2Car");
}
}
}
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StandardScene.Magnetic.Tests")]
@@ -0,0 +1,14 @@
namespace StandardScene.Magnetic.Protocol
{
/// <summary>
/// FASS 2.0 通讯模式:PLC/TCP 主动查询,或 PCB/UDP 被动接收状态上报。
/// </summary>
public enum Fass2CommMode
{
/// <summary>PLCTCP 同步收发,调度主动发 0x00 查询。</summary>
Tcp = 0,
/// <summary>PCBUDP 监听 AGV 上报,回 0x10,命令单向下发。</summary>
Udp = 1
}
}
@@ -0,0 +1,336 @@
using System;
using System.Linq;
namespace StandardScene.Magnetic.Protocol
{
/// <summary>
/// FASS 2.0 通用控制接口(PLC)报文编解码,对齐
/// <c>FASS.Extend.Car.Fairyland.Plc</c> 与调度器 <c>CarRequestService</c> 逻辑。
/// </summary>
internal static class Fass2Protocol
{
public const byte Begin = 0xBB;
public const byte End = 0xEE;
public const byte CmdQuery = 0x00;
public const byte CmdStart = 0x01;
public const byte CmdStop = 0x02;
public const byte CmdEmergencyStop = 0x03;
public const byte CmdReset = 0x04;
public const byte CmdRest = 0x05;
public const byte CmdShutdown = 0x06;
public const byte CmdAction = 0xA1;
public const byte CmdNodes = 0xB1;
public const byte CmdStateResponse = 0x10;
public const int ControlFrameLength = 50;
public const int StateFrameLength = 100;
public const int ActionFrameLength = 100;
public const int NodesFrameLength = 300;
public static byte Xor(byte[] data, int start, int length)
{
byte xor = 0;
for (var i = 0; i < length; i++)
{
xor ^= data[start + i];
}
return xor;
}
public static byte[] BuildControl(byte command, ushort car, ushort param)
{
var frame = new byte[ControlFrameLength];
frame[0] = Begin;
frame[1] = command;
WriteUInt16(frame, 2, car);
WriteUInt16(frame, 4, param);
frame[48] = Xor(frame, 1, 47);
frame[49] = End;
return frame;
}
public static byte[] BuildNodes(ushort car, ulong taskId, Fass2NodeMessage[] nodes)
{
if (nodes == null || nodes.Length == 0)
{
throw new ArgumentException("nodes required");
}
if (nodes.Length > 10)
{
throw new ArgumentException("FASS2 nodes count must be <= 10");
}
var frame = new byte[NodesFrameLength];
frame[0] = Begin;
frame[1] = CmdNodes;
WriteUInt16(frame, 2, car);
WriteUInt64(frame, 4, taskId);
WriteUInt16(frame, 12, (ushort)nodes.Length);
for (var i = 0; i < nodes.Length; i++)
{
Buffer.BlockCopy(nodes[i].ToBytes(), 0, frame, 14 + i * 25, 25);
}
frame[298] = Xor(frame, 1, 297);
frame[299] = End;
return frame;
}
public static byte[] BuildAction(ushort car, ulong actionId, Fass2NodeMessage node)
{
if (node == null)
{
throw new ArgumentException("node required");
}
var frame = new byte[ActionFrameLength];
frame[0] = Begin;
frame[1] = CmdAction;
WriteUInt16(frame, 2, car);
WriteUInt64(frame, 4, actionId);
Buffer.BlockCopy(node.ToBytes(), 0, frame, 14, 25);
frame[98] = Xor(frame, 1, 97);
frame[99] = End;
return frame;
}
/// <summary>
/// PCB/UDP 模式:收到 AGV 主动上报的 100B 状态后,回 0x10(50B)时间戳应答。
/// </summary>
public static byte[] BuildStateResponse(ushort car, ulong timestampMs)
{
var frame = new byte[ControlFrameLength];
frame[0] = Begin;
frame[1] = CmdStateResponse;
WriteUInt16(frame, 2, car);
WriteUInt64(frame, 4, timestampMs);
var epoch = new DateTime(1970, 1, 1, 8, 0, 0, DateTimeKind.Unspecified);
var date = epoch.AddMilliseconds(timestampMs > long.MaxValue ? long.MaxValue : (long)timestampMs);
frame[12] = (byte)(date.Year - 1970);
frame[13] = (byte)date.Month;
frame[14] = (byte)date.Day;
frame[15] = (byte)date.Hour;
frame[16] = (byte)date.Minute;
frame[17] = (byte)date.Second;
WriteUInt16(frame, 18, (ushort)date.Millisecond);
frame[48] = Xor(frame, 1, 47);
frame[49] = End;
return frame;
}
public static ulong BeijingUnixTimeMs()
{
var epoch = new DateTime(1970, 1, 1, 8, 0, 0, DateTimeKind.Unspecified);
return (ulong)(DateTime.Now - epoch).TotalMilliseconds;
}
public static bool TryExtractStateFrame(byte[] buffer, int length, out byte[] frame)
{
frame = null;
if (buffer == null || length < StateFrameLength)
{
return false;
}
for (var i = 0; i <= length - StateFrameLength; i++)
{
if (buffer[i] != Begin || buffer[i + StateFrameLength - 1] != End)
{
continue;
}
frame = new byte[StateFrameLength];
Buffer.BlockCopy(buffer, i, frame, 0, StateFrameLength);
return true;
}
if (length == StateFrameLength && buffer[0] == Begin && buffer[StateFrameLength - 1] == End)
{
frame = new byte[StateFrameLength];
Buffer.BlockCopy(buffer, 0, frame, 0, StateFrameLength);
return true;
}
return false;
}
public static Fass2StateReport ParseState(byte[] bytes)
{
if (bytes == null || bytes.Length < StateFrameLength)
{
throw new ArgumentException("FASS2 state frame must be 100 bytes");
}
if (bytes[0] != Begin || bytes[99] != End)
{
throw new ArgumentException($"invalid FASS2 state frame: begin=0x{bytes[0]:X2}, end=0x{bytes[99]:X2}");
}
var nodeBytes = new byte[25];
Buffer.BlockCopy(bytes, 53, nodeBytes, 0, 25);
return new Fass2StateReport
{
Command = bytes[1],
Car = ReadUInt16(bytes, 2),
Length = ReadUInt16(bytes, 4),
Width = ReadUInt16(bytes, 6),
BatteryCharge = bytes[28],
BatteryHealth = bytes[29],
BatteryCurrent = ReadUInt16(bytes, 30),
BatteryVoltage = ReadUInt16(bytes, 32),
HeadingAngle = ReadUInt16(bytes, 34),
State = bytes[36],
Alarm = ReadUInt64(bytes, 37),
Task = ReadUInt64(bytes, 45),
Node = Fass2NodeMessage.FromBytes(nodeBytes)
};
}
public static string ToHex(byte[] bytes)
{
return bytes == null ? string.Empty : string.Join(" ", bytes.Select(b => b.ToString("X2")));
}
public static string StateText(byte state)
{
switch (state)
{
case 0: return "未准备";
case 1: return "运行中";
case 2: return "停止中";
case 3: return "急停中";
case 4: return "故障中";
case 5: return "任务中";
case 6: return "休眠中";
case 7: return "关机中";
case 8: return "充电中";
default: return $"未知({state})";
}
}
private static void WriteUInt16(byte[] buffer, int offset, ushort value)
{
var bytes = BitConverter.GetBytes(value);
buffer[offset] = bytes[0];
buffer[offset + 1] = bytes[1];
}
private static void WriteUInt64(byte[] buffer, int offset, ulong value)
{
var bytes = BitConverter.GetBytes(value);
Buffer.BlockCopy(bytes, 0, buffer, offset, 8);
}
private static ushort ReadUInt16(byte[] bytes, int offset)
{
return BitConverter.ToUInt16(bytes, offset);
}
private static ulong ReadUInt64(byte[] bytes, int offset)
{
return BitConverter.ToUInt64(bytes, offset);
}
}
public sealed class Fass2NodeMessage
{
public ushort Node { get; set; }
public ushort Distance { get; set; }
public byte StartStop { get; set; }
public byte Direction { get; set; }
public byte Orientation { get; set; }
public byte Byroad { get; set; }
public ushort Speed { get; set; }
public byte Obstacle { get; set; }
public byte Audio { get; set; }
public byte Light { get; set; }
public byte Charge { get; set; }
public byte Rest { get; set; }
public byte Lift { get; set; }
public byte Clamp { get; set; }
public byte Tray { get; set; }
public byte Roll { get; set; }
public byte Shutdown { get; set; }
public byte[] ToBytes()
{
var bytes = new byte[25];
WriteUInt16(bytes, 0, Node);
WriteUInt16(bytes, 2, Distance);
bytes[4] = StartStop;
bytes[5] = Direction;
bytes[6] = Orientation;
bytes[7] = Byroad;
WriteUInt16(bytes, 8, Speed);
bytes[10] = Obstacle;
bytes[11] = Audio;
bytes[12] = Light;
bytes[13] = Charge;
bytes[14] = Rest;
bytes[15] = Lift;
bytes[16] = Clamp;
bytes[17] = Tray;
bytes[18] = Roll;
bytes[19] = Shutdown;
return bytes;
}
public static Fass2NodeMessage FromBytes(byte[] bytes)
{
if (bytes == null || bytes.Length < 25)
{
throw new ArgumentException("node frame must be 25 bytes");
}
return new Fass2NodeMessage
{
Node = ReadUInt16(bytes, 0),
Distance = ReadUInt16(bytes, 2),
StartStop = bytes[4],
Direction = bytes[5],
Orientation = bytes[6],
Byroad = bytes[7],
Speed = ReadUInt16(bytes, 8),
Obstacle = bytes[10],
Audio = bytes[11],
Light = bytes[12],
Charge = bytes[13],
Rest = bytes[14],
Lift = bytes[15],
Clamp = bytes[16],
Tray = bytes[17],
Roll = bytes[18],
Shutdown = bytes[19]
};
}
private static void WriteUInt16(byte[] buffer, int offset, ushort value)
{
var bytes = BitConverter.GetBytes(value);
buffer[offset] = bytes[0];
buffer[offset + 1] = bytes[1];
}
private static ushort ReadUInt16(byte[] bytes, int offset)
{
return BitConverter.ToUInt16(bytes, offset);
}
}
public sealed class Fass2StateReport
{
public byte Command { get; set; }
public ushort Car { get; set; }
public ushort Length { get; set; }
public ushort Width { get; set; }
public byte BatteryCharge { get; set; }
public byte BatteryHealth { get; set; }
public ushort BatteryCurrent { get; set; }
public ushort BatteryVoltage { get; set; }
public ushort HeadingAngle { get; set; }
public byte State { get; set; }
public ulong Alarm { get; set; }
public ulong Task { get; set; }
public Fass2NodeMessage Node { get; set; } = new Fass2NodeMessage();
}
}
@@ -0,0 +1,59 @@
using System;
using System.Text;
using StandardScene.Magnetic.Protocol;
namespace StandardScene.Magnetic.Protocol
{
/// <summary>
/// 100B 状态帧编码,供模拟器或测试工具复用,字段布局与 <see cref="Fass2Protocol.ParseState"/> 一致。
/// </summary>
public static class Fass2StateCodec
{
public static byte[] BuildState(Fass2StateReport report, string carType = "Mag2")
{
if (report == null)
{
throw new ArgumentNullException(nameof(report));
}
var frame = new byte[Fass2Protocol.StateFrameLength];
frame[0] = Fass2Protocol.Begin;
frame[1] = report.Command;
WriteUInt16(frame, 2, report.Car);
WriteUInt16(frame, 4, report.Length);
WriteUInt16(frame, 6, report.Width);
var typeBytes = Encoding.ASCII.GetBytes((carType ?? string.Empty).PadRight(16, '\0'));
Buffer.BlockCopy(typeBytes, 0, frame, 12, Math.Min(16, typeBytes.Length));
frame[28] = report.BatteryCharge;
frame[29] = report.BatteryHealth;
WriteUInt16(frame, 30, report.BatteryCurrent);
WriteUInt16(frame, 32, report.BatteryVoltage);
WriteUInt16(frame, 34, report.HeadingAngle);
frame[36] = report.State;
WriteUInt64(frame, 37, report.Alarm);
WriteUInt64(frame, 45, report.Task);
var node = report.Node ?? new Fass2NodeMessage();
Buffer.BlockCopy(node.ToBytes(), 0, frame, 53, 25);
frame[98] = Fass2Protocol.Xor(frame, 1, 97);
frame[99] = Fass2Protocol.End;
return frame;
}
private static void WriteUInt16(byte[] buffer, int offset, ushort value)
{
var bytes = BitConverter.GetBytes(value);
buffer[offset] = bytes[0];
buffer[offset + 1] = bytes[1];
}
private static void WriteUInt64(byte[] buffer, int offset, ulong value)
{
var bytes = BitConverter.GetBytes(value);
Buffer.BlockCopy(bytes, 0, buffer, offset, 8);
}
}
}
@@ -0,0 +1,235 @@
using SimpleCore.Library;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Threading;
namespace StandardScene.Magnetic.Protocol
{
/// <summary>
/// FASS 2.0 PCB/UDP 共享监听枢纽。
/// AGV 主动上报 100B 状态 → 解析并分发给对应车辆 → 回 0x10 时间戳应答到车辆 address:Port。
/// 下发 0xB1/0xA1/0x01 等命令亦通过本枢纽单播到车辆 address:Port。
/// </summary>
internal static class Fass2UdpHub
{
private static readonly object SyncRoot = new object();
private static readonly Dictionary<ushort, IFass2UdpCar> CarsByCode = new Dictionary<ushort, IFass2UdpCar>();
private static UdpClient _listener;
private static Thread _receiveThread;
private static volatile bool _running;
private static int _listenPort = 20103;
private static readonly Dictionary<ushort, long> _lastUnknownCarLogUtcTicks = new Dictionary<ushort, long>();
public static void Register(IFass2UdpCar car)
{
if (car == null)
{
return;
}
lock (SyncRoot)
{
CarsByCode[car.VehicleCode] = car;
}
}
public static void Unregister(IFass2UdpCar car)
{
if (car == null)
{
return;
}
Unregister(car.VehicleCode, car);
}
public static void Unregister(ushort vehicleCode, IFass2UdpCar car)
{
if (car == null)
{
return;
}
lock (SyncRoot)
{
if (CarsByCode.TryGetValue(vehicleCode, out var existing) && ReferenceEquals(existing, car))
{
CarsByCode.Remove(vehicleCode);
}
}
}
public static void EnsureStarted(int listenPort)
{
if (listenPort > 0)
{
_listenPort = listenPort;
}
lock (SyncRoot)
{
if (_running)
{
return;
}
_listener = new UdpClient(_listenPort);
_running = true;
_receiveThread = new Thread(ReceiveLoop)
{
IsBackground = true,
Name = $"Fass2UdpHub:{_listenPort}"
};
_receiveThread.Start();
Diagnosis.Post($"Fass2UdpHub 已启动,监听 UDP {_listenPort}", "Fass2UdpHub", true);
}
}
public static void Stop()
{
lock (SyncRoot)
{
_running = false;
try
{
_listener?.Close();
}
catch
{
}
_listener = null;
}
try
{
_receiveThread?.Join(1000);
}
catch
{
}
_receiveThread = null;
}
public static void SendToCar(IFass2UdpCar car, byte[] payload)
{
if (car == null || payload == null || payload.Length == 0)
{
return;
}
if (string.IsNullOrWhiteSpace(car.RemoteAddress) || car.RemotePort <= 0)
{
throw new InvalidOperationException(
$"Fass2UdpHub send failed: car VehicleCode={car.VehicleCode} remote endpoint not configured");
}
using var sender = new UdpClient();
var remote = new IPEndPoint(IPAddress.Parse(car.RemoteAddress), car.RemotePort);
sender.Send(payload, payload.Length, remote);
}
private static void ReceiveLoop()
{
while (_running)
{
try
{
var remote = new IPEndPoint(IPAddress.Any, 0);
var packet = _listener.Receive(ref remote);
ProcessPacket(packet, remote);
}
catch (SocketException) when (!_running)
{
break;
}
catch (ObjectDisposedException) when (!_running)
{
break;
}
catch (Exception ex)
{
Diagnosis.Post($"Fass2UdpHub receive error: {ex.Message}", "Fass2UdpHub", true);
}
}
}
private static void ProcessPacket(byte[] packet, IPEndPoint remote)
{
if (!Fass2Protocol.TryExtractStateFrame(packet, packet.Length, out var frame))
{
return;
}
Fass2StateReport report;
try
{
report = Fass2Protocol.ParseState(frame);
}
catch (Exception ex)
{
Diagnosis.Post($"Fass2UdpHub parse error from {remote}: {ex.Message}", "Fass2UdpHub", true);
return;
}
IFass2UdpCar car;
lock (SyncRoot)
{
if (!CarsByCode.TryGetValue(report.Car, out car))
{
LogUnknownCar(report.Car);
return;
}
}
try
{
car.OnUdpStateReceived(report);
}
catch (Exception ex)
{
Diagnosis.Post($"Fass2UdpHub dispatch error car={report.Car}: {ex.Message}", "Fass2UdpHub", true);
}
try
{
var ack = Fass2Protocol.BuildStateResponse(report.Car, Fass2Protocol.BeijingUnixTimeMs());
SendToCar(car, ack);
}
catch (Exception ex)
{
Diagnosis.Post($"Fass2UdpHub 0x10 reply failed car={report.Car}: {ex.Message}", "Fass2UdpHub", true);
}
}
private static void LogUnknownCar(ushort carCode)
{
var nowTicks = DateTime.UtcNow.Ticks;
lock (SyncRoot)
{
if (_lastUnknownCarLogUtcTicks.TryGetValue(carCode, out var lastTicks)
&& nowTicks - lastTicks < TimeSpan.FromSeconds(5).Ticks)
{
return;
}
_lastUnknownCarLogUtcTicks[carCode] = nowTicks;
}
var registered = string.Join(",", CarsByCode.Keys);
if (string.IsNullOrEmpty(registered))
{
registered = "(无)";
}
Diagnosis.Post(
$"Fass2UdpHub 收到 Car={carCode} 的状态,但未注册该编号(已注册: {registered})。请检查 Mag2Car.VehicleCode 与模拟器车号是否一致,并重新启动场景。",
"Fass2UdpHub",
true);
}
}
}
@@ -0,0 +1,16 @@
namespace StandardScene.Magnetic.Protocol
{
/// <summary>
/// 由 <see cref="Fass2UdpHub"/> 回调的 UDP/PCB 车型处理接口。
/// </summary>
internal interface IFass2UdpCar
{
ushort VehicleCode { get; }
string RemoteAddress { get; }
int RemotePort { get; }
void OnUdpStateReceived(Fass2StateReport report);
}
}
@@ -0,0 +1,251 @@
using LessokajiWeaverUtilities.Utilities;
using Newtonsoft.Json;
using SimpleLite.RCS;
using SimpleLite.Props;
using SimpleCore;
using SimpleCore.Library;
using SimpleCore.PropType;
using StandardScene;
using StandardScene.Scheduler.FassEvent;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using SimpleLite.RCS.CarTypes;
namespace StandardScene.Scheduler
{
/// <summary>
/// FASS 车辆事件调度进程。
/// <para>
/// 移植自 <c>fass/FASS.Scheduler/Services/Events/EventCarService.cs</c>
/// 按 JSON 配置定时轮询全场车辆,匹配触发条件后执行交管/PLC/电量等条件判断,
/// 最终对 MagCar 下发 Start(放行)或 Stop(停车)。
/// </para>
/// <para>
/// 使用方式:在场景中添加本 Mission →「启动进程」→ 默认加载 <c>Configs\EventCar\FG2512073.json</c>。
/// </para>
/// </summary>
[MissionType(Name = "FASS车辆事件进程", editor = typeof(EventCarMission))]
[I18N.DocumentTranslation(Name = "FASS Event Car Mission", locale = "en")]
public class EventCarMission : Mission
{
/// <summary>EventCar JSON 配置文件路径(相对程序目录或绝对路径)。</summary>
[FieldMember] public string EventConfigPath = "Config\\EventCar\\traffic.json";
/// <summary>主循环轮询间隔(毫秒),对应 FASS TimerTick 频率,默认 500ms。</summary>
[FieldMember] public int TickIntervalMs = 500;
/// <summary>与下料站/安全信号交互用的西门子 PLC IP。</summary>
[FieldMember] public string PlcIpAddress = "127.0.0.1";
/// <summary>PLC 端口,S7 默认 102。</summary>
[FieldMember] public int PlcPort = 102;
/// <summary>PLC 型号字符串,如 S7_1500、S7_300,传给 IoTClient SiemensClient。</summary>
[FieldMember] public string PlcVersion = "S7_1500";
/// <summary>是否启用 ME11 下料站 PLC 信号交互(false 时 requestStaion/InplaceStaion 等直接返回 true)。</summary>
[FieldMember] public bool IsEnableMEll = true;
/// <summary>充电完成放行默认电量阈值(%),可被事件内 ChargeStart 参数覆盖。</summary>
[FieldMember] public double ChargeStartThreshold = 93;
[JsonIgnore] private bool _started;
[JsonIgnore] private Thread _workerThread;
[JsonIgnore] private FassEventCarHandler _handler;
/// <summary>当前生效的车辆事件配置列表(TriggerSource=Car 且 TriggerEnable=true)。</summary>
[JsonIgnore] private List<FassEventCarConfig> _eventCars = new List<FassEventCarConfig>();
/// <summary>记录每辆车上一周期的 CurrentNode,用于 PrevNode 触发与地标变化时清除任务防重复标记。</summary>
[JsonIgnore] private readonly Dictionary<int, string> _prevNodeByCarId = new Dictionary<int, string>();
/// <summary>进程运行状态扩展字段。</summary>
public class EventCarMissionStatus : MissionStatus
{
/// <summary>主循环已执行次数。</summary>
public int TickCount { get; set; }
/// <summary>已加载的事件规则条数。</summary>
public int LoadedEventCount { get; set; }
/// <summary>实际加载的配置文件绝对/解析路径。</summary>
public string ConfigPath { get; set; }
}
public override MissionStatus status { get; set; } = new EventCarMissionStatus();
public static Mission Create()
{
return new EventCarMission();
}
/// <summary>
/// 启动后台轮询线程:加载配置 → 创建 Handler → 周期性 ProcessAllCars。
/// </summary>
[MethodMember(Name = "启动进程", Description = "加载 FASS EventCar 配置并开始轮询车辆事件")]
public override void Execute()
{
if (_started)
{
status.status = "已在运行";
return;
}
try
{
ReloadConfig();
}
catch (Exception ex)
{
status.status = "配置加载失败";
Diagnosis.Post($"FASS车辆事件进程配置加载失败:{ExceptionFormatter.FormatEx(ex)}", "FassEventCar", true);
throw;
}
_handler = new FassEventCarHandler(this);
_started = true;
status.status = "运行中";
_workerThread = new Thread(WorkerLoop)
{
IsBackground = true,
Name = "EventCarMission"
};
_workerThread.Start();
Diagnosis.Post($"FASS车辆事件进程已启动,配置数={_eventCars.Count}", "FassEventCar", true);
}
/// <summary>热加载 JSON 配置,运行中也可调用。</summary>
[MethodMember(Name = "重新加载配置", Description = "重新读取 EventCar JSON 配置")]
public void ReloadConfig()
{
var path = ResolveConfigPath();
_eventCars = FassEventConfigLoader.Load(path)
.Where(item => item.TriggerEnable && string.Equals(item.TriggerSource, "Car", StringComparison.OrdinalIgnoreCase))
.ToList();
var missionStatus = (EventCarMissionStatus)status;
missionStatus.LoadedEventCount = _eventCars.Count;
missionStatus.ConfigPath = path;
status.status = _started ? "运行中" : "配置已加载";
Diagnosis.Post($"FASS EventCar 配置已加载:{path},事件数={_eventCars.Count}", "FassEventCar", true);
}
/// <summary>停止轮询并等待工作线程结束(最多 2 秒)。</summary>
[MethodMember(Name = "停止进程", Description = "停止 FASS 车辆事件轮询")]
public void Stop()
{
_started = false;
try
{
_workerThread?.Join(2000);
}
catch
{
}
status.status = "已停止";
Diagnosis.Post("FASS车辆事件进程已停止", "FassEventCar", true);
}
/// <summary>
/// 后台主循环:每 TickIntervalMs 扫描一次全场车辆。
/// 单轮异常不会终止进程,仅写诊断日志。
/// </summary>
private void WorkerLoop()
{
while (_started)
{
try
{
ProcessAllCars();
var missionStatus = (EventCarMissionStatus)status;
missionStatus.TickCount++;
status.status = $"运行中:{missionStatus.TickCount}";
}
catch (Exception ex)
{
Diagnosis.Post($"FASS车辆事件进程循环异常:{ExceptionFormatter.FormatEx(ex)}", "FassEventCar", true);
}
Thread.Sleep(Math.Max(100, TickIntervalMs));
}
}
/// <summary>
/// 遍历所有 Car,构建快照并异步调用 Handler(与 FASS Task.Run 行为一致)。
/// <para>
/// 跳过 status 为空或 usage.refreshing=false 的车辆(未参与调度刷新的车不处理)。
/// 当地标变化时清除 <see cref="FassEventCarHandler.ActiveTaskTag"/>,允许在新地标重新触发事件。
/// </para>
/// </summary>
private void ProcessAllCars()
{
foreach (var car in SimpleLib.GetAllCars().OfType<Car>())
{
if (car.status == null)
{
continue;
}
var usage = car.status.usage?.Get();
if (usage != null && usage.refreshing == false)
{
continue;
}
var currentNode = FassEventCarSnapshotFactory.GetCurrentNodeCode(car);
_prevNodeByCarId.TryGetValue(car.id, out var prevNode);
var snapshot = FassEventCarSnapshotFactory.Create(car, prevNode, FassEventCarSnapshotFactory.GetNextNodeCode(car));
Task.Run(() => _handler.HandleCarTimerTick(car, snapshot, _eventCars));
if (!string.Equals(prevNode, currentNode, StringComparison.OrdinalIgnoreCase))
{
_prevNodeByCarId[car.id] = currentNode;
ClearTaskTags(car);
}
}
}
/// <summary>车辆换地标后删除防重复执行标签,使同一规则可在新站点再次触发。</summary>
private static void ClearTaskTags(Car car)
{
if (car.tags == null)
{
return;
}
Commons.DeleteTag(car.tags, FassEventCarHandler.ActiveTaskTag);
}
/// <summary>
/// 解析配置文件路径:绝对路径直接用;相对路径依次尝试 BaseDirectory、CurrentDirectory、上级目录。
/// </summary>
private string ResolveConfigPath()
{
if (Path.IsPathRooted(EventConfigPath))
{
return EventConfigPath;
}
var candidates = new[]
{
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, EventConfigPath),
Path.Combine(Directory.GetCurrentDirectory(), EventConfigPath),
Path.GetFullPath(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", EventConfigPath))
};
return candidates.FirstOrDefault(File.Exists) ?? candidates[0];
}
}
}
@@ -0,0 +1,577 @@
using IoTClient.Clients.PLC;
using IoTClient.Common.Enums;
using LessokajiWeaverUtilities.Utilities;
using SimpleLite.RCS;
using SimpleCore.Library;
using SimpleCore.PropType;
using StandardScene;
using StandardScene.CarTypes;
using StandardScene.Scheduler;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using SimpleLite.RCS.CarTypes;
namespace StandardScene.Scheduler.FassEvent
{
/// <summary>
/// FASS 车辆事件核心处理器,逻辑对齐 <c>EventCarService</c>。
/// <para>处理流程:TriggerHandler(触发匹配)→ ConditionHandler(条件 AND)→ ExecuteTaskStart/Stop)。</para>
/// </summary>
internal sealed class FassEventCarHandler
{
/// <summary>
/// 防重复执行任务的车辆标签名。同一地标周期内只执行一次,换地标后由 EventCarMission 清除。
/// 标签值为 <see cref="BuildTaskKey"/> 生成的任务指纹。
/// </summary>
internal const string ActiveTaskTag = "FassEvent:Active";
private readonly EventCarMission _mission;
/// <summary>PLC 读写串行锁,避免多车并发条件中同时 Open/Close 同一连接。</summary>
private readonly object _plcLock = new object();
/// <summary>JSON ConditionMethodName → 内置条件方法 映射表。</summary>
private readonly Dictionary<string, Func<FassEventCarSnapshot, FassConditionEventConfig, bool>> _eventMethods;
public FassEventCarHandler(EventCarMission mission)
{
_mission = mission;
_eventMethods = BuildEventMethods();
}
/// <summary>
/// 单次 TimerTick 对一辆车的处理入口(通常在 Task.Run 中调用)。
/// 仅处理 TriggerEvent=TimerTick 的配置项。
/// </summary>
public void HandleCarTimerTick(Car car, FassEventCarSnapshot snapshot, IReadOnlyList<FassEventCarConfig> eventCars)
{
foreach (var eventConfig in eventCars.Where(item => string.Equals(item.TriggerEvent, "TimerTick", StringComparison.OrdinalIgnoreCase)))
{
if (!TriggerHandler(snapshot, eventConfig))
{
continue;
}
if (!ConditionHandler(car, snapshot, eventConfig))
{
continue;
}
if (!eventConfig.TaskEnable)
{
continue;
}
ExecuteTask(car, snapshot, eventConfig);
}
}
/// <summary>注册与 FASS EventCarService.InitEventMethod 一致的条件方法名(保留历史拼写)。</summary>
private Dictionary<string, Func<FassEventCarSnapshot, FassConditionEventConfig, bool>> BuildEventMethods()
{
return new Dictionary<string, Func<FassEventCarSnapshot, FassConditionEventConfig, bool>>(StringComparer.OrdinalIgnoreCase)
{
["NodeAllUnlockedNotCar"] = NodeAllUnlockedNotCar,
["NodeAllUnlocked"] = NodeAllUnlocked,
["NodeAnyLockedNotCar"] = NodeAnyLockedNotCar,
["NodeAnyLocked"] = NodeAnyLocked,
["NodeAllUnlockedNotCarNotEndCarState"] = NodeAllUnlockedNotCarNotEndCarState,
["requestStaion"] = RequestStation,
["clearStaion"] = ClearStation,
["EmegerStop"] = EmergencyStop,
["EmegerStart"] = EmergencyStart,
["IsExistRunningStateCar"] = IsExistRunningStateCar,
["ChargeStart"] = ChargeStart
};
}
/// <summary>
/// 触发器匹配:车辆编号、前后地标、状态、角度等 CSV 过滤。
/// 配置项为空表示“不限制”;车辆对应字段为空则无法匹配非空配置。
/// </summary>
private bool TriggerHandler(FassEventCarSnapshot snapshot, FassEventCarConfig config)
{
if (!MatchesCsvFilter(config.TriggerCarCode, snapshot.Code))
{
return false;
}
if (!MatchesCsvFilter(config.TriggerCarPrevNodeCode, snapshot.PrevNodeCode))
{
return false;
}
if (!MatchesCsvFilter(config.TriggerCarCurrentNodeCode, snapshot.CurrentNodeCode))
{
return false;
}
if (!MatchesCsvFilter(config.TriggerCarNextNodeCode, snapshot.NextNodeCode))
{
return false;
}
if (!MatchesCsvFilter(config.TriggerCarState, snapshot.State))
{
return false;
}
if (!MatchesCsvFilter(config.TriggerCarAngle, snapshot.Angle.ToString(CultureInfo.InvariantCulture)))
{
return false;
}
return true;
}
/// <summary>所有 ConditionEvents 必须全部通过(逻辑与)。</summary>
private bool ConditionHandler(Car car, FassEventCarSnapshot snapshot, FassEventCarConfig config)
{
if (config.ConditionEvents == null || config.ConditionEvents.Count == 0)
{
return true;
}
foreach (var condition in config.ConditionEvents)
{
if (!SingleConditionHandler(car, snapshot, condition))
{
return false;
}
}
return true;
}
/// <summary>单条条件分发:Method / Assembly / Plugin。</summary>
private bool SingleConditionHandler(Car car, FassEventCarSnapshot snapshot, FassConditionEventConfig condition)
{
if (!condition.ConditionEnable)
{
return true;
}
var conditionType = condition.ConditionType ?? "Method";
if (string.Equals(conditionType, "Method", StringComparison.OrdinalIgnoreCase))
{
return MethodInvoke(snapshot, condition);
}
if (string.Equals(conditionType, "Assembly", StringComparison.OrdinalIgnoreCase) ||
string.Equals(conditionType, "Plugin", StringComparison.OrdinalIgnoreCase))
{
LogSignal($"暂不支持的 ConditionType={conditionType}, Method={condition.ConditionMethodName}");
return false;
}
return false;
}
/// <summary>按方法名调用内置条件实现。</summary>
private bool MethodInvoke(FassEventCarSnapshot snapshot, FassConditionEventConfig condition)
{
if (string.IsNullOrWhiteSpace(condition.ConditionMethodName))
{
return false;
}
if (_eventMethods.TryGetValue(condition.ConditionMethodName, out var handler))
{
return handler(snapshot, condition);
}
LogSignal($"未注册的条件方法:{condition.ConditionMethodName}");
return false;
}
/// <summary>
/// 执行任务:MagCar 调用 SendStart/SendStop;其他车型仅打日志。
/// 通过 ActiveTaskTag 防止同一地标周期内重复下发。
/// </summary>
private void ExecuteTask(Car car, FassEventCarSnapshot snapshot, FassEventCarConfig config)
{
if (car.tags != null && car.tags.Contains(ActiveTaskTag))
{
return;
}
var taskType = config.TaskType ?? "Start";
if (car is MagCar magCar)
{
switch (taskType)
{
case "Start":
magCar.SendStart((ushort)Math.Round(magCar.th));
break;
case "Stop":
magCar.SendStop(0);
break;
default:
LogTraffic($"车辆[{snapshot.Code}] 收到任务类型[{taskType}],当前仅显式处理 Start/Stop");
break;
}
}
else
{
LogTraffic($"车辆[{snapshot.Code}] 非 MagCar,任务类型[{taskType}] 仅记录标签");
}
var taskKey = BuildTaskKey(snapshot, config);
Commons.AddOrUpdateTag(car.tags, ActiveTaskTag, taskKey);
LogTraffic($"车辆[{snapshot.Code}] 触发事件任务:{taskType} @ node={snapshot.CurrentNodeCode}, state={snapshot.State}");
}
/// <summary>生成任务指纹,写入 ActiveTaskTag 的值,便于日志追溯。</summary>
private static string BuildTaskKey(FassEventCarSnapshot snapshot, FassEventCarConfig config)
{
return $"FassEvent:{snapshot.CurrentNodeCode}:{config.TriggerCarState}:{config.TaskType}:{string.Join("|", config.ConditionEvents?.Select(item => item.ConditionMethodName) ?? Array.Empty<string>())}";
}
#region
/// <summary>指定节点全部未被其他车辆占用(排除自身)。参数:逗号分隔节点号。</summary>
private bool NodeAllUnlockedNotCar(FassEventCarSnapshot snapshot, FassConditionEventConfig condition)
{
return FassEventNodeHelper.NodeAllUnlockedNotCar(snapshot.Code, SplitArgs(condition.ConditionMethodArgs));
}
/// <summary>指定节点全部空闲(含自身锁也计入)。</summary>
private bool NodeAllUnlocked(FassEventCarSnapshot snapshot, FassConditionEventConfig condition)
{
return FassEventNodeHelper.NodeAllUnlocked(SplitArgs(condition.ConditionMethodArgs));
}
/// <summary>指定节点中存在被其他车占用的点。</summary>
private bool NodeAnyLockedNotCar(FassEventCarSnapshot snapshot, FassConditionEventConfig condition)
{
return FassEventNodeHelper.NodeAnyLockedNotCar(snapshot.Code, SplitArgs(condition.ConditionMethodArgs));
}
/// <summary>
/// 指定节点存在占用时先 SendStart 再判断(与 FASS NodeAnyLocked 行为一致)。
/// </summary>
private bool NodeAnyLocked(FassEventCarSnapshot snapshot, FassConditionEventConfig condition)
{
var car = SimpleCore.SimpleLib.GetCar(snapshot.CarId) as MagCar;
car?.SendStart((ushort)Math.Round(car.th));
return FassEventNodeHelper.NodeAnyLocked(SplitArgs(condition.ConditionMethodArgs));
}
/// <summary>节点全解锁且路径上无其他运行中车辆。</summary>
private bool NodeAllUnlockedNotCarNotEndCarState(FassEventCarSnapshot snapshot, FassConditionEventConfig condition)
{
return FassEventNodeHelper.NodeAllUnlockedNotCarNotEndCarState(snapshot.Code, "Runing", SplitArgs(condition.ConditionMethodArgs));
}
/// <summary>判断指定节点范围是否不存在其他“运行中”车辆(返回 true 表示可以放行)。</summary>
private bool IsExistRunningStateCar(FassEventCarSnapshot snapshot, FassConditionEventConfig condition)
{
var param = SplitArgs(condition.ConditionMethodArgs);
LogTraffic($"IsExistRunningStateCar 参数:{condition.ConditionMethodArgs}");
return !FassEventNodeHelper.ExistsRunningCarOnNodes(snapshot.Code, "Runing", param);
}
#endregion
#region
/// <summary>
/// 电量达到阈值方可放行。阈值优先取 ConditionMethodArgs 首参,否则用 Mission.ChargeStartThreshold。
/// </summary>
private bool ChargeStart(FassEventCarSnapshot snapshot, FassConditionEventConfig condition)
{
var threshold = _mission.ChargeStartThreshold;
var configured = SplitArgs(condition.ConditionMethodArgs).FirstOrDefault();
if (!string.IsNullOrWhiteSpace(configured) &&
double.TryParse(configured, NumberStyles.Float, CultureInfo.InvariantCulture, out var eventThreshold))
{
threshold = eventThreshold;
}
var result = snapshot.Charge >= threshold;
if (result)
{
LogSignal($"ChargeStart 车辆[{snapshot.Code}] 电量[{snapshot.Charge}] >= 阈值[{threshold}]");
}
return result;
}
/// <summary>
/// 设备安全急停:读 PLC 布尔位,false 表示故障需停车(条件返回 true 以触发 Stop 任务)。
/// 参数 param[0]PLC 地址。
/// </summary>
private bool EmergencyStop(FassEventCarSnapshot snapshot, FassConditionEventConfig condition)
{
return ExecutePlcCondition(snapshot, condition, 1, (client, param) =>
{
var allowIn = client.ReadBoolean(param[0]).Value;
if (!allowIn)
{
LogSignal($"设备安全故障急停:{allowIn}");
return true;
}
return false;
});
}
/// <summary>
/// 设备安全恢复:PLC 布尔位为 true 时允许 Start。
/// </summary>
private bool EmergencyStart(FassEventCarSnapshot snapshot, FassConditionEventConfig condition)
{
return ExecutePlcCondition(snapshot, condition, 1, (client, param) =>
{
var allowIn = client.ReadBoolean(param[0]).Value;
if (allowIn)
{
LogSignal($"设备安全恢复放行:{allowIn}");
return true;
}
return false;
});
}
#endregion
#region ME11 PLC
/// <summary>
/// 清除站点到位与离开中信号。
/// 参数:param[0] 到位信号地址,param[1] 离开中信号地址。
/// </summary>
private bool ClearStation(FassEventCarSnapshot snapshot, FassConditionEventConfig condition)
{
var context = BuildStationLogContext(nameof(ClearStation), snapshot, condition);
LogSignal($"{context} 开始清除站点到位/离开中信号");
if (!_mission.IsEnableMEll)
{
LogSignal($"车辆[{snapshot.Code}] ME11 未启用,直接返回 true");
return true;
}
return ExecutePlcCondition(snapshot, condition, 2, (client, param) =>
{
var afterClear = WriteAndRead(client, param[0], false, "清除到位信号", context);
WriteStationSignal(client, param[1], false, "清除离开中信号", context);
var leavingClear = client.ReadBoolean(param[1]).Value;
var result = !afterClear && !leavingClear;
LogSignal($"{context} 清除站点信号结束,返回[{result}]");
return result;
});
}
/// <summary>
/// 请求进入工位:读“请求进入”,为 true 时写“正在进入中”,并以进入中信号作为放行条件结果。
/// 参数:param[0] 请求进入,param[1] 正在进入中。
/// </summary>
private bool RequestStation(FassEventCarSnapshot snapshot, FassConditionEventConfig condition)
{
var context = BuildStationLogContext(nameof(RequestStation), snapshot, condition);
LogSignal($"{context} 开始请求进入信号判断");
if (!_mission.IsEnableMEll)
{
LogSignal($"车辆[{snapshot.Code}] ME11 未启用,直接返回 true");
return true;
}
return ExecutePlcCondition(snapshot, condition, 2, (client, param) =>
{
var requestIn = ReadStationSignal(client, param[0], "请求进入", context);
if (requestIn)
{
WriteStationSignal(client, param[1], true, "正在进入中", context);
}
var result = ReadStationSignal(client, param[1], "正在进入中", context);
LogSignal($"{context} 请求进入判断结束,返回[{result}]");
return result;
});
}
/// <summary>
/// 到位与允许离开交互(ME11 下料核心逻辑)。
/// <para>
/// 参数:param[0] 到位,param[1] 允许离开,param[2] 正在进入中,param[3] 正在离开中。
/// ME11 车型:气缸伸出(Read3&amp;Read5)后写到位;读到允许离开后切换为放行车型。
/// 放行车型:气缸收回(Read4&amp;Read6)后写离开中、清除到位。
/// </para>
/// </summary>
/// <summary>将车辆 fields["CarModel"] 切换为放行车型,供后续触发器匹配。</summary>
private void SaveCarModel(int carId, string carModel, string reason)
{
var car = SimpleCore.SimpleLib.GetCar(carId) as Car;
if (car == null)
{
return;
}
car.fields ??= new Dictionary<string, string>();
if (string.Equals(FassEventCarSnapshotFactory.GetCarModel(car), carModel, StringComparison.OrdinalIgnoreCase))
{
return;
}
car.fields["CarModel"] = carModel;
LogSignal($"{reason}:车辆[{FassEventCarSnapshotFactory.GetCarCode(car)}] 车型切换为 [{carModel}]");
}
#endregion
#region PLC
/// <summary>在 PLC 锁内打开连接、执行读写、关闭连接。</summary>
private bool ExecutePlcCondition(
FassEventCarSnapshot snapshot,
FassConditionEventConfig condition,
int minArgs,
Func<SiemensClient, string[], bool> action)
{
if (!TryGetConditionArgs(condition, minArgs, out var param))
{
return false;
}
lock (_plcLock)
{
SiemensClient client = null;
try
{
client = CreatePlcClient();
client.Open();
return action(client, param);
}
catch (Exception ex)
{
LogSignalError(ex, "交互站点报错");
return false;
}
finally
{
client?.Close();
}
}
}
/// <summary>根据 Mission 配置创建西门子 PLC 客户端。</summary>
private SiemensClient CreatePlcClient()
{
var version = ParsePlcVersion(_mission.PlcVersion);
return new SiemensClient(version, _mission.PlcIpAddress, _mission.PlcPort, 0);
}
private static SiemensVersion ParsePlcVersion(string version)
{
if (string.IsNullOrWhiteSpace(version))
{
return SiemensVersion.S7_1500;
}
return Enum.TryParse(version, true, out SiemensVersion parsed) ? parsed : SiemensVersion.S7_1500;
}
/// <summary>拼接站点交互日志上下文,便于现场对照 PLC 地址排查。</summary>
private string BuildStationLogContext(string methodName, FassEventCarSnapshot snapshot, FassConditionEventConfig condition)
{
return $"{methodName} 车辆[{snapshot.Code}] 状态[{snapshot.State}] 当前地标[{snapshot.CurrentNodeCode}] 下一地标[{snapshot.NextNodeCode}] 车型[{snapshot.CarModel}] 参数[{condition.ConditionMethodArgs}]";
}
private bool ReadStationSignal(SiemensClient client, string address, string signalName, string context)
{
var value = client.ReadBoolean(address).Value;
LogSignal($"{context} 读取信号[{signalName}] 地址[{address}] 值[{value}]");
return value;
}
private void WriteStationSignal(SiemensClient client, string address, bool value, string signalName, string context)
{
LogSignal($"{context} 写入信号[{signalName}] 地址[{address}] 值[{value}]");
client.Write(address, value);
var readBackValue = client.ReadBoolean(address).Value;
LogSignal($"{context} 回读信号[{signalName}] 地址[{address}] 值[{readBackValue}]");
}
private bool WriteAndRead(SiemensClient client, string address, bool value, string signalName, string context)
{
WriteStationSignal(client, address, value, signalName, context);
return client.ReadBoolean(address).Value;
}
/// <summary>校验条件参数个数是否满足 PLC 方法要求。</summary>
private static bool TryGetConditionArgs(FassConditionEventConfig condition, int minLength, out string[] args)
{
args = SplitArgs(condition.ConditionMethodArgs);
return args.Length >= minLength;
}
/// <summary>将逗号分隔的配置参数字符串拆分为数组。</summary>
private static string[] SplitArgs(string args)
{
if (string.IsNullOrWhiteSpace(args))
{
return Array.Empty<string>();
}
return args.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)
.Select(item => item.Trim())
.ToArray();
}
/// <summary>
/// CSV 过滤器:filter 为空则通过;否则 value 必须等于 filter 中某项(忽略大小写)。
/// 用于 TriggerCarState 等多值匹配,如 "Stopping,Charging"。
/// </summary>
private static bool MatchesCsvFilter(string filter, string value)
{
if (string.IsNullOrWhiteSpace(filter))
{
return true;
}
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
return filter.Split(',').Select(item => item.Trim()).Any(item => string.Equals(item, value, StringComparison.OrdinalIgnoreCase));
}
private void LogTraffic(string message) => Diagnosis.Post(message, "Fass交管", true);
private void LogSignal(string message) => Diagnosis.Post(message, "Fass信号交互", true);
private void LogSignalError(Exception ex, string message) =>
Diagnosis.Post($"{message}: {ExceptionFormatter.FormatEx(ex)}", "Fass信号交互", true);
#endregion
}
/// <summary>从磁盘加载 EventCar JSON 配置数组。</summary>
internal static class FassEventConfigLoader
{
/// <summary>
/// 读取并反序列化配置文件。
/// </summary>
/// <param name="configPath">JSON 文件路径。</param>
/// <returns>事件配置列表;文件内容为空数组时返回空列表。</returns>
/// <exception cref="FileNotFoundException">文件不存在时抛出。</exception>
public static List<FassEventCarConfig> Load(string configPath)
{
if (string.IsNullOrWhiteSpace(configPath) || !File.Exists(configPath))
{
throw new FileNotFoundException($"未找到 FASS 车辆事件配置文件:{configPath}");
}
var json = File.ReadAllText(configPath);
return Newtonsoft.Json.JsonConvert.DeserializeObject<List<FassEventCarConfig>>(json)
?? new List<FassEventCarConfig>();
}
}
}
@@ -0,0 +1,165 @@
using SimpleLite.RCS;
using SimpleCore.PropType;
using StandardScene.CarTypes;
using System;
using System.Globalization;
using SimpleLite.RCS.CarTypes;
namespace StandardScene.Scheduler.FassEvent
{
/// <summary>
/// 将 StandardScene 车辆对象转换为 FASS 事件系统可识别的快照字段。
/// 负责编号、地标、状态、电量等与 <c>EventCarService.TriggerHandler</c> 对齐的映射。
/// </summary>
internal static class FassEventCarSnapshotFactory
{
/// <summary>
/// 获取车辆“下一地标”编码。
/// 优先级:status.enums["NextNode"] → tags["NextNode"] → pendingLocks 首站(TagValue 或站点 ID)。
/// </summary>
public static string GetNextNodeCode(Car car)
{
if (car.status?.enums != null && car.status.enums.TryGetValue("NextNode", out var node) && !string.IsNullOrWhiteSpace(node))
{
return node;
}
if (car.tags != null && car.tags.TryGetValue("NextNode", out var tagNode) && !string.IsNullOrWhiteSpace(tagNode))
{
return tagNode;
}
// 调度 pendingLocks 通常表示车辆即将前往的站点,可近似为 FASS NextNodeCode。
if (car.status?.pendingLocks != null && car.status.pendingLocks.Length > 0)
{
var pendingSiteId = car.status.pendingLocks[0];
var site = SimpleCore.SimpleLib.GetSite(pendingSiteId);
if (site?.fields != null && site.fields.TryGetValue("TagValue", out var tagValue) && !string.IsNullOrWhiteSpace(tagValue))
{
return tagValue;
}
return pendingSiteId.ToString(CultureInfo.InvariantCulture);
}
return string.Empty;
}
/// <summary>
/// 根据车辆与上一周期地标构建完整快照。
/// </summary>
/// <param name="car">场景车辆。</param>
/// <param name="prevNodeCode">上一周期 CurrentNode,由 EventCarMission 维护。</param>
/// <param name="nextNodeCode">下一地标;为空时自动调用 <see cref="GetNextNodeCode"/>。</param>
public static FassEventCarSnapshot Create(Car car, string prevNodeCode, string nextNodeCode)
{
return new FassEventCarSnapshot
{
CarId = car.id,
Code = GetCarCode(car),
CarModel = GetCarModel(car),
PrevNodeCode = prevNodeCode,
CurrentNodeCode = GetCurrentNodeCode(car),
NextNodeCode = string.IsNullOrWhiteSpace(nextNodeCode) ? GetNextNodeCode(car) : nextNodeCode,
State = GetFassState(car),
Angle = (int)Math.Round(car.th),
Charge = GetCharge(car)
};
}
/// <summary>
/// 获取 FASS 车辆编号。MagCar 优先使用 VehicleCode,否则 fields["Code"],最后回退 name。
/// </summary>
public static string GetCarCode(Car car)
{
if (car is MagCar magCar && magCar.VehicleCode > 0)
{
return magCar.VehicleCode.ToString(CultureInfo.InvariantCulture);
}
if (car.fields != null && car.fields.TryGetValue("Code", out var code) && !string.IsNullOrWhiteSpace(code))
{
return code;
}
return car.name;
}
/// <summary>获取车型。下料站 InplaceStaion 根据 CarModel 在 ME11 / 放行车型间切换。</summary>
public static string GetCarModel(Car car)
{
if (car.fields != null && car.fields.TryGetValue("CarModel", out var model) && !string.IsNullOrWhiteSpace(model))
{
return model;
}
return car.GetType().Name;
}
/// <summary>
/// 获取当前地标号。MagCar 上报后写入 status.enums["CurrentNode"]
/// 其他车型可回退到 siteID。
/// </summary>
public static string GetCurrentNodeCode(Car car)
{
if (car.status?.enums != null && car.status.enums.TryGetValue("CurrentNode", out var node) && !string.IsNullOrWhiteSpace(node))
{
return node;
}
return car.siteID > 0 ? car.siteID.ToString(CultureInfo.InvariantCulture) : string.Empty;
}
/// <summary>
/// 将 StandardScene 车辆状态映射为 FASS 英文字符串。
/// <para>
/// 可手动覆盖:tags["FassState"] 或 enums["FassState"]。
/// WaitPass 需在停止中且 tags 含 WaitPass(FASS 中“等待放行”态)。
/// </para>
/// </summary>
public static string GetFassState(Car car)
{
if (car.tags != null && car.tags.TryGetValue("FassState", out var tagState) && !string.IsNullOrWhiteSpace(tagState))
{
return tagState;
}
if (car.status?.enums != null && car.status.enums.TryGetValue("FassState", out var enumState) && !string.IsNullOrWhiteSpace(enumState))
{
return enumState;
}
if (car.status?.enums != null && car.status.enums.TryGetValue("State", out var state))
{
switch (state)
{
case "运行中":
return "Running";
case "停止中":
return car.tags != null && car.tags.ContainsKey("WaitPass") ? "WaitPass" : "Stopping";
case "未准备":
return "NotReady";
case "充电中":
return "Charging";
default:
return state;
}
}
return string.Empty;
}
/// <summary>从 status.enums["Soc"] 解析电量百分比。</summary>
private static double GetCharge(Car car)
{
if (car.status?.enums != null &&
car.status.enums.TryGetValue("Soc", out var soc) &&
double.TryParse(soc, NumberStyles.Float, CultureInfo.InvariantCulture, out var value))
{
return value;
}
return 0;
}
}
}
@@ -0,0 +1,132 @@
using System.Collections.Generic;
namespace StandardScene.Scheduler.FassEvent
{
/// <summary>
/// FASS 车辆事件配置项,对应 <c>Configs/EventCar/*.json</c> 数组中的单个元素。
/// 配置格式与 FASS.Scheduler 的 EventCar 模型保持一致,便于直接复用现场 JSON。
/// </summary>
public class FassEventCarConfig
{
/// <summary>是否启用本条事件规则。</summary>
public bool TriggerEnable { get; set; } = true;
/// <summary>
/// 事件来源。StandardScene 进程仅处理值为 <c>Car</c> 的项(车辆定时轮询触发)。
/// </summary>
public string TriggerSource { get; set; } = "Car";
/// <summary>
/// 触发时机。当前实现仅支持 <c>TimerTick</c>(与 FASS <c>EventCarService.CarTimerTick</c> 等价)。
/// </summary>
public string TriggerEvent { get; set; } = "TimerTick";
/// <summary>触发条件:车辆编号(逗号分隔,空表示不限制)。</summary>
public string TriggerCarCode { get; set; }
/// <summary>触发条件:上一地标号(逗号分隔)。</summary>
public string TriggerCarPrevNodeCode { get; set; }
/// <summary>触发条件:当前地标号(逗号分隔)。</summary>
public string TriggerCarCurrentNodeCode { get; set; }
/// <summary>触发条件:下一地标号(逗号分隔)。</summary>
public string TriggerCarNextNodeCode { get; set; }
/// <summary>
/// 触发条件:车辆 FASS 状态(逗号分隔)。
/// 常见值:Running、Stopping、WaitPass、Charging、Stopping,Charging 等。
/// </summary>
public string TriggerCarState { get; set; }
/// <summary>触发条件:车头角度(逗号分隔的整数字符串)。</summary>
public string TriggerCarAngle { get; set; }
/// <summary>
/// 条件事件列表。全部满足后才执行任务;
/// 对应 FASS 中 <c>ConditionHandler</c> 的逐项 AND 逻辑。
/// </summary>
public List<FassConditionEventConfig> ConditionEvents { get; set; } = new List<FassConditionEventConfig>();
/// <summary>条件满足后是否执行任务(false 时仅做条件判断,不下发 Start/Stop)。</summary>
public bool TaskEnable { get; set; } = true;
/// <summary>
/// 任务类型。MagCar 当前支持 <c>Start</c>(放行)与 <c>Stop</c>(急停)。
/// </summary>
public string TaskType { get; set; }
}
/// <summary>
/// 单条条件事件配置,描述一个条件判断(交管、PLC 信号、电量等)。
/// </summary>
public class FassConditionEventConfig
{
/// <summary>是否启用本条件;false 时跳过(视为通过)。</summary>
public bool ConditionEnable { get; set; } = true;
/// <summary>
/// 条件类型:<c>Method</c>(内置方法)、<c>Assembly</c>、<c>Plugin</c>。
/// StandardScene 当前仅实现 Method;后两者会记录日志并返回 false。
/// </summary>
public string ConditionType { get; set; } = "Method";
/// <summary>Assembly 条件:程序集文件路径(未实现)。</summary>
public string ConditionAssemblyFile { get; set; }
/// <summary>Assembly/Plugin 条件:类型名(未实现)。</summary>
public string ConditionTypeName { get; set; }
/// <summary>Assembly/Plugin 条件:构造参数(未实现)。</summary>
public string ConditionTypeArgs { get; set; }
/// <summary>
/// Method 条件:方法名,如 NodeAllUnlockedNotCar、requestStaion、EmegerStop 等。
/// 名称与 FASS <c>EventCarService.InitEventMethod</c> 注册表一致(含历史拼写)。
/// </summary>
public string ConditionMethodName { get; set; }
/// <summary>
/// Method 条件:方法参数,一般为逗号分隔的节点号或 PLC 地址。
/// 例如交管:<c>3,53,21</c>PLC<c>DB5524.0.0,DB5524.0.1</c>。
/// </summary>
public string ConditionMethodArgs { get; set; }
}
/// <summary>
/// 车辆事件处理用的只读快照,从 StandardScene <see cref="SimpleLite.RCS.Car"/> 提取 FASS 语义字段。
/// 每次 TimerTick 为每辆车构建一份,供触发器与条件方法使用。
/// </summary>
public class FassEventCarSnapshot
{
/// <summary>场景内车辆 IDSimpleLib)。</summary>
public int CarId { get; set; }
/// <summary>FASS 车辆编号(MagCar.VehicleCode 或 fields["Code"])。</summary>
public string Code { get; set; }
/// <summary>车型标识,用于 ME11 下料站逻辑(fields["CarModel"])。</summary>
public string CarModel { get; set; }
/// <summary>上一周期记录的地标号,用于 TriggerCarPrevNodeCode 匹配。</summary>
public string PrevNodeCode { get; set; }
/// <summary>当前地标号(status.enums["CurrentNode"] 或 siteID)。</summary>
public string CurrentNodeCode { get; set; }
/// <summary>下一地标号(pendingLocks / tags / enums 推断)。</summary>
public string NextNodeCode { get; set; }
/// <summary>
/// FASS 状态字符串:Running、Stopping、WaitPass、Charging 等。
/// 由 <see cref="FassEventCarSnapshotFactory.GetFassState"/> 从中文状态映射。
/// </summary>
public string State { get; set; }
/// <summary>车头角度(度),对应 FASS Car.Angle。</summary>
public int Angle { get; set; }
/// <summary>电量百分比(Soc)。</summary>
public double Charge { get; set; }
}
}
@@ -0,0 +1,196 @@
using SimpleLite.RCS;
using SimpleCore;
using SimpleCore.PropType;
using System;
using System.Linq;
using SimpleLite.RCS.CarTypes;
namespace StandardScene.Scheduler.FassEvent
{
/// <summary>
/// 节点(地标)交管判断辅助类,替代 FASS <c>AlgorithmService</c> 中与 EventCar 相关的方法。
/// <para>
/// “锁定”判定依据 StandardScene 交通锁:holdingLocks、pendingLocks、aquiringLock
/// 以及站点 tags 中的 unavailable。
/// </para>
/// </summary>
internal static class FassEventNodeHelper
{
/// <summary>指定节点列表是否全部未被任何车辆占用。</summary>
public static bool NodeAllUnlocked(string[] nodeCodes)
{
return nodeCodes == null || nodeCodes.All(node => !IsNodeLocked(node, -1));
}
/// <summary>指定节点列表是否全部未被占用(排除当前车辆自身锁)。</summary>
public static bool NodeAllUnlockedNotCar(string carCode, string[] nodeCodes)
{
var excludeCarId = ResolveCarId(carCode);
return nodeCodes == null || nodeCodes.All(node => !IsNodeLocked(node, excludeCarId));
}
/// <summary>指定节点列表中是否存在任意一个被占用的节点。</summary>
public static bool NodeAnyLocked(string[] nodeCodes)
{
return nodeCodes != null && nodeCodes.Any(node => IsNodeLocked(node, -1));
}
/// <summary>指定节点列表中是否存在被其他车辆占用的节点(排除自身)。</summary>
public static bool NodeAnyLockedNotCar(string carCode, string[] nodeCodes)
{
var excludeCarId = ResolveCarId(carCode);
return nodeCodes != null && nodeCodes.Any(node => IsNodeLocked(node, excludeCarId));
}
/// <summary>
/// 节点全部解锁(排除自身)且指定节点范围内不存在处于运行态的其他车辆。
/// 对应 FASS <c>NodeAllUnlockedNotCarNotEndCarState</c>。
/// </summary>
public static bool NodeAllUnlockedNotCarNotEndCarState(string carCode, string runningState, string[] nodeCodes)
{
if (!NodeAllUnlockedNotCar(carCode, nodeCodes))
{
return false;
}
return !ExistsRunningCarOnNodes(carCode, runningState, nodeCodes);
}
/// <summary>
/// 判断除指定车辆外,是否有车辆在给定节点列表上处于“运行中”状态。
/// 逻辑对齐 FASS <c>IsExistRunningStateCar</c>:支持首参 Contains 匹配及 Skip(1) 尾节点匹配。
/// </summary>
public static bool ExistsRunningCarOnNodes(string excludeCarCode, string runningState, string[] nodeCodes)
{
if (nodeCodes == null || nodeCodes.Length == 0)
{
return false;
}
var excludeCarId = ResolveCarId(excludeCarCode);
foreach (var car in SimpleLib.GetAllCars().OfType<Car>())
{
if (car.id == excludeCarId)
{
continue;
}
var currentNode = FassEventCarSnapshotFactory.GetCurrentNodeCode(car);
if (string.IsNullOrWhiteSpace(currentNode))
{
continue;
}
var state = FassEventCarSnapshotFactory.GetFassState(car);
if (!IsRunningState(state, runningState))
{
continue;
}
// 与 FASS 一致:当前地标包含参数首项,或等于后续任一项。
if (nodeCodes.Any(node => string.Equals(node, currentNode, StringComparison.OrdinalIgnoreCase)
|| currentNode.Contains(node)))
{
return true;
}
if (nodeCodes.Length > 1)
{
var tailNodes = nodeCodes.Skip(1).ToArray();
if (tailNodes.Any(node => string.Equals(node, currentNode, StringComparison.OrdinalIgnoreCase)))
{
return true;
}
}
}
return false;
}
/// <summary>
/// 判断单个地标是否被锁定。
/// 节点号可解析为站点 ID,或通过 fields["TagValue"] 匹配地标标签。
/// </summary>
private static bool IsNodeLocked(string nodeCode, int excludeCarId)
{
if (!TryResolveSiteId(nodeCode, out var siteId))
{
return false;
}
var site = SimpleLib.GetSite(siteId);
if (site?.tags?.Contains("unavailable") == true)
{
return true;
}
return SimpleLib.GetAllCars().Any(car =>
car.id != excludeCarId &&
(car.status.holdingLocks?.Contains(siteId) == true ||
car.status.pendingLocks?.Contains(siteId) == true ||
car.status.aquiringLock == siteId));
}
/// <summary>根据 FASS 车辆编号解析场景内 car.id。</summary>
private static int ResolveCarId(string carCode)
{
if (string.IsNullOrWhiteSpace(carCode))
{
return -1;
}
var car = SimpleLib.GetAllCars()
.OfType<Car>()
.FirstOrDefault(item => string.Equals(FassEventCarSnapshotFactory.GetCarCode(item), carCode, StringComparison.OrdinalIgnoreCase));
return car?.id ?? -1;
}
/// <summary>
/// 将地标编码解析为站点 ID:纯数字直接查站;否则按 TagValue 字段匹配。
/// </summary>
private static bool TryResolveSiteId(string nodeCode, out int siteId)
{
siteId = 0;
if (string.IsNullOrWhiteSpace(nodeCode))
{
return false;
}
if (int.TryParse(nodeCode, out siteId))
{
return SimpleLib.GetSite(siteId) != null;
}
var site = SimpleLib.GetAllSites().FirstOrDefault(item =>
item.fields.TryGetValue("TagValue", out var tag) &&
string.Equals(tag, nodeCode, StringComparison.OrdinalIgnoreCase));
if (site == null)
{
return false;
}
siteId = site.id;
return true;
}
/// <summary>
/// 判断状态是否视为“运行中”。兼容 FASS 历史拼写 Runing 与 Running。
/// </summary>
private static bool IsRunningState(string state, string runningState)
{
if (string.IsNullOrWhiteSpace(state))
{
return false;
}
if (string.Equals(state, runningState, StringComparison.OrdinalIgnoreCase))
{
return true;
}
return string.Equals(state, "Running", StringComparison.OrdinalIgnoreCase) &&
(string.Equals(runningState, "Runing", StringComparison.OrdinalIgnoreCase) ||
string.Equals(runningState, "Running", StringComparison.OrdinalIgnoreCase));
}
}
}
@@ -21,6 +21,7 @@
<!-- 插件清单:随 dll 输出,供 SimpleLite plugins 选择性加载(约定 <dll>.scene.json -->
<ItemGroup>
<None Update="StandardScene.Magnetic.scene.json" CopyToOutputDirectory="PreserveNewest" />
<None Update="Configs\EventCar\FG2512073.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<!-- 基座:车型基类字段袋 / 通用 coder / 任务调度等留 Core;本 dll 为 scene.mag 平台(磁导航车型 + 磁循迹 coder) -->
@@ -29,24 +30,25 @@
</ItemGroup>
<ItemGroup>
<Reference Include="SimpleLite">
<HintPath>E:\Work\Core\Simple-FR\Simple\SimpleLite\bin\Debug\SimpleLite.dll</HintPath>
</Reference>
<Reference Include="SimpleCore">
<HintPath>E:\Work\Core\Simple-FR\Simple\SimpleCore\bin\Debug\netstandard2.0\SimpleCore.dll</HintPath>
</Reference>
<Reference Include="CommonUsage">
<HintPath>D:\MDCS\Dependencies\Commons\CommonUsage.dll</HintPath>
</Reference>
<Reference Include="Topaz">
<HintPath>E:\Work\Core\Simple-FR\Simple\tools\Topaz.dll</HintPath>
<HintPath>$(StandRefDir)CommonUsage.dll</HintPath>
</Reference>
<Reference Include="LessokajiWeaverUtilities">
<HintPath>E:\Work\Core\Simple-FR\Simple\SimpleLite\bin\Debug\LessokajiWeaverUtilities.dll</HintPath>
<HintPath>$(StandRefDir)LessokajiWeaverUtilities.dll</HintPath>
</Reference>
<Reference Include="SimpleCore">
<HintPath>$(StandRefDir)SimpleCore.dll</HintPath>
</Reference>
<Reference Include="SimpleLite">
<HintPath>$(StandSimpleLiteDir)SimpleLite.dll</HintPath>
</Reference>
<Reference Include="Topaz">
<HintPath>$(StandRefDir)Topaz.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<PackageReference Include="IoTClient" Version="1.0.40" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
</ItemGroup>
@@ -6,7 +6,7 @@
"coreVersion": ">=1.0.0",
"requiresCore": "StandardScene.dll",
"provides": {
"carTypes": [ "MagCar" ],
"missionTypes": []
"carTypes": [ "MagCar", "Mag2Car" ],
"missionTypes": [ "EventCarMission", "Mag2LoopMission" ]
}
}
@@ -0,0 +1,234 @@
using StandardScene.Magnetic.Protocol;
using System;
using System.Collections.Generic;
using System.Text;
namespace StandardScene.Magnetic.Tasking
{
/// <summary>
/// 比对期望节点与上报节点,判定到站与动作是否完成(对齐 backend <c>CarResponseService</c>)。
/// 默认只闭环机构类动作;轨迹字段(Speed/Orientation 等)需显式开启。
/// </summary>
public static class Fass2ActionResolver
{
/// <summary>是否在停车站比对 Speed/Orientation/Direction/Byroad 等轨迹字段。</summary>
public static bool VerifyTrajectoryFields { get; set; }
private static readonly (string Name, Func<Fass2NodeMessage, byte> Get, Action<Fass2NodeMessage, byte> Set)[] MechanismFields =
{
("Obstacle", n => n.Obstacle, (n, v) => n.Obstacle = v),
("Audio", n => n.Audio, (n, v) => n.Audio = v),
("Light", n => n.Light, (n, v) => n.Light = v),
("Charge", n => n.Charge, (n, v) => n.Charge = v),
("Rest", n => n.Rest, (n, v) => n.Rest = v),
("Lift", n => n.Lift, (n, v) => n.Lift = v),
("Clamp", n => n.Clamp, (n, v) => n.Clamp = v),
("Tray", n => n.Tray, (n, v) => n.Tray = v),
("Roll", n => n.Roll, (n, v) => n.Roll = v),
("Shutdown", n => n.Shutdown, (n, v) => n.Shutdown = v)
};
private static readonly (string Name, Func<Fass2NodeMessage, byte> Get, Action<Fass2NodeMessage, byte> Set)[] TrajectoryFields =
{
("Direction", n => n.Direction, (n, v) => n.Direction = v),
("Orientation", n => n.Orientation, (n, v) => n.Orientation = v),
("Byroad", n => n.Byroad, (n, v) => n.Byroad = v)
};
public static bool IsAtNode(Fass2StateReport report, ushort targetNode)
{
return report?.Node != null && report.Node.Node == targetNode;
}
public static bool IsVehicleMoving(byte vehicleState)
{
return vehicleState is 1 or 5;
}
public static bool RequiresActionWait(Fass2NodeMessage expected)
{
if (expected == null)
{
return false;
}
if (expected.StartStop is Fass2TaskBuilder.StartStopStop
or Fass2TaskBuilder.StartStopPrecision
or Fass2TaskBuilder.StartStopControlStop
or Fass2TaskBuilder.StartStopControlStart)
{
return true;
}
foreach (var field in MechanismFields)
{
if (field.Get(expected) != 0)
{
return true;
}
}
if (!VerifyTrajectoryFields)
{
return false;
}
foreach (var field in TrajectoryFields)
{
if (field.Get(expected) != 0)
{
return true;
}
}
return expected.Speed != 0;
}
public static bool IsStationActionComplete(Fass2NodeMessage expected, Fass2NodeMessage actual, byte vehicleState)
{
if (expected == null || actual == null)
{
return false;
}
if (actual.Node != expected.Node)
{
return false;
}
if (IsVehicleMoving(vehicleState))
{
return false;
}
if (!RequiresActionWait(expected))
{
return true;
}
if (expected.StartStop == Fass2TaskBuilder.StartStopPass)
{
return true;
}
if (expected.StartStop == Fass2TaskBuilder.StartStopControlStop)
{
return false;
}
return ListPendingFields(expected, actual).Count == 0;
}
public static IReadOnlyList<string> ListPendingFields(Fass2NodeMessage expected, Fass2NodeMessage actual)
{
var pending = new List<string>();
if (expected == null || actual == null)
{
return pending;
}
if (expected.StartStop != 0 && actual.StartStop != expected.StartStop)
{
pending.Add("StartStop");
}
if (VerifyTrajectoryFields)
{
if (expected.Speed != 0 && actual.Speed != expected.Speed)
{
pending.Add("Speed");
}
foreach (var field in TrajectoryFields)
{
var expectedValue = field.Get(expected);
if (expectedValue != 0 && field.Get(actual) != expectedValue)
{
pending.Add(field.Name);
}
}
}
foreach (var field in MechanismFields)
{
var expectedValue = field.Get(expected);
if (expectedValue != 0 && field.Get(actual) != expectedValue)
{
pending.Add(field.Name);
}
}
return pending;
}
public static Fass2NodeMessage BuildActionPatch(Fass2NodeMessage expected, Fass2NodeMessage actual)
{
if (expected == null)
{
return null;
}
var patch = new Fass2NodeMessage { Node = expected.Node };
var hasPatch = false;
if (expected.StartStop != 0 && (actual == null || actual.StartStop != expected.StartStop))
{
patch.StartStop = expected.StartStop;
hasPatch = true;
}
if (VerifyTrajectoryFields)
{
if (expected.Speed != 0 && (actual == null || actual.Speed != expected.Speed))
{
patch.Speed = expected.Speed;
hasPatch = true;
}
foreach (var field in TrajectoryFields)
{
var expectedValue = field.Get(expected);
if (expectedValue != 0 && (actual == null || field.Get(actual) != expectedValue))
{
field.Set(patch, expectedValue);
hasPatch = true;
}
}
}
foreach (var field in MechanismFields)
{
var expectedValue = field.Get(expected);
if (expectedValue != 0 && (actual == null || field.Get(actual) != expectedValue))
{
field.Set(patch, expectedValue);
hasPatch = true;
}
}
return hasPatch ? patch : null;
}
public static string DescribePending(Fass2NodeMessage expected, Fass2NodeMessage actual)
{
var pending = ListPendingFields(expected, actual);
if (pending.Count == 0)
{
return string.Empty;
}
var builder = new StringBuilder();
for (var i = 0; i < pending.Count; i++)
{
if (i > 0)
{
builder.Append(',');
}
builder.Append(pending[i]);
}
return builder.ToString();
}
}
}
@@ -0,0 +1,214 @@
using StandardScene.Magnetic.Protocol;
using System;
using System.Collections.Generic;
using System.Globalization;
namespace StandardScene.Magnetic.Tasking
{
/// <summary>
/// 管控停止(StartStop=12)按车放行:地图站点保持 12,仅改本车任务报文。
/// </summary>
public sealed class Fass2ControlAreaInfo
{
public int SiteId { get; set; }
public string AreaId { get; set; } = string.Empty;
public int[] AreaSiteIds { get; set; } = Array.Empty<int>();
public int Capacity { get; set; } = 1;
public byte ReleaseStartStop { get; set; } = Fass2TaskBuilder.StartStopControlStart;
}
public sealed class Fass2ControlOccupancy
{
public Fass2ControlOccupancy(int carId, IReadOnlyList<int> occupiedSiteIds)
{
CarId = carId;
OccupiedSiteIds = occupiedSiteIds ?? Array.Empty<int>();
}
public int CarId { get; }
public IReadOnlyList<int> OccupiedSiteIds { get; }
}
public sealed class Fass2ControlReleaseCheck
{
public bool CanRelease { get; set; }
public string AreaId { get; set; } = string.Empty;
public int Capacity { get; set; } = 1;
public int OthersInArea { get; set; }
public byte ReleaseStartStop { get; set; } = Fass2TaskBuilder.StartStopControlStart;
public string Reason { get; set; } = string.Empty;
}
public static class Fass2ControlAreaGate
{
private static readonly char[] SiteIdSeparators = { ',', ';', '|', ' ', '\t', '[', ']', '(', ')', '{', '}' };
public static bool IsControlStop(byte startStop)
{
return startStop == Fass2TaskBuilder.StartStopControlStop;
}
public static bool AllowsLeave(byte startStop)
{
return startStop == Fass2TaskBuilder.StartStopPass
|| startStop == Fass2TaskBuilder.StartStopControlStart;
}
public static Fass2ControlAreaInfo Resolve(Fass2SiteActionData data, int siteId)
{
data ??= new Fass2SiteActionData();
var areaSites = ParseSiteIds(data.ControlArea, siteId);
var capacity = data.ControlCapacity.GetValueOrDefault(1);
if (capacity < 1)
{
capacity = 1;
}
var release = data.ControlReleaseStartStop ?? Fass2TaskBuilder.StartStopControlStart;
if (release != Fass2TaskBuilder.StartStopPass &&
release != Fass2TaskBuilder.StartStopControlStart)
{
release = Fass2TaskBuilder.StartStopControlStart;
}
return new Fass2ControlAreaInfo
{
SiteId = siteId,
AreaSiteIds = areaSites,
AreaId = string.Join(",", areaSites),
Capacity = capacity,
ReleaseStartStop = release
};
}
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 int LimitWindowCount(
IReadOnlyList<Fass2NodeMessage> nodes,
int startIndex,
int count,
int releasedIndex)
{
if (nodes == null || count <= 0 || startIndex < 0 || startIndex >= nodes.Count)
{
return 0;
}
var limited = Math.Min(count, nodes.Count - startIndex);
for (var i = 0; i < limited; i++)
{
var node = nodes[startIndex + i];
if (node != null && IsControlStop(node.StartStop) && releasedIndex != startIndex + i)
{
return i + 1;
}
}
return limited;
}
public static Fass2ControlReleaseCheck Evaluate(
int selfCarId,
Fass2ControlAreaInfo area,
IReadOnlyList<Fass2ControlOccupancy> occupancies)
{
area ??= new Fass2ControlAreaInfo();
if (area.AreaSiteIds == null || area.AreaSiteIds.Length == 0)
{
area.AreaSiteIds = area.SiteId > 0 ? new[] { area.SiteId } : Array.Empty<int>();
}
if (string.IsNullOrEmpty(area.AreaId))
{
area.AreaId = string.Join(",", area.AreaSiteIds);
}
if (area.Capacity < 1)
{
area.Capacity = 1;
}
var others = 0;
if (occupancies != null)
{
foreach (var occupancy in occupancies)
{
if (occupancy == null || occupancy.CarId == selfCarId)
{
continue;
}
if (OccupiesListedSites(occupancy.OccupiedSiteIds, area.AreaSiteIds))
{
others++;
}
}
}
var canRelease = others < area.Capacity;
return new Fass2ControlReleaseCheck
{
CanRelease = canRelease,
AreaId = area.AreaId,
Capacity = area.Capacity,
OthersInArea = others,
ReleaseStartStop = area.ReleaseStartStop == 0
? Fass2TaskBuilder.StartStopControlStart
: area.ReleaseStartStop,
Reason = canRelease
? "area clear"
: $"area occupied cars={others}, capacity={area.Capacity}"
};
}
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;
}
}
}
@@ -0,0 +1,80 @@
using SimpleCore;
using SimpleCore.PropType;
using SimpleLite.RCS.CarTypes;
using System;
using System.Collections.Generic;
using System.Linq;
namespace StandardScene.Magnetic.Tasking
{
/// <summary>
/// 按 holdingLocks + 物理 siteID 统计管控站点列表上的他车占用。
/// Mag2 仍读地图 Fass2_ControlArea 字段;磁条交管进程只放行 MagCar,不介入本路径。
/// </summary>
internal static class Fass2ControlAreaOccupancy
{
public static Fass2ControlReleaseCheck Check(Car self, int controlSiteId)
{
var site = SimpleLib.GetSite(controlSiteId);
var area = Fass2ControlAreaGate.Resolve(Fass2SiteFieldReader.Read(site), controlSiteId);
var occupancies = new List<Fass2ControlOccupancy>();
IEnumerable<AbstractCar> cars;
try
{
cars = SimpleLib.GetAllCars();
}
catch (Exception ex)
{
return new Fass2ControlReleaseCheck
{
CanRelease = false,
AreaId = area.AreaId,
Capacity = area.Capacity,
ReleaseStartStop = area.ReleaseStartStop,
Reason = "car list unavailable: " + ex.Message
};
}
if (cars != null)
{
foreach (var other in cars.OfType<Car>())
{
if (other == null)
{
continue;
}
occupancies.Add(new Fass2ControlOccupancy(other.id, CollectOccupiedSiteIds(other)));
}
}
return Fass2ControlAreaGate.Evaluate(self?.id ?? 0, area, occupancies);
}
private static IReadOnlyList<int> CollectOccupiedSiteIds(Car car)
{
var ids = new List<int>();
if (car.siteID > 0)
{
ids.Add(car.siteID);
}
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,90 @@
using SimpleCore;
using System.Collections.Generic;
namespace StandardScene.Magnetic.Tasking
{
internal static class Fass2PathFinder
{
public static List<int> GetSitesBetweenBfs(int startSiteId, int endSiteId)
{
if (startSiteId == endSiteId)
{
return new List<int> { startSiteId };
}
var adjacency = BuildSiteAdjacency();
var visited = new HashSet<int> { startSiteId };
var queue = new Queue<List<int>>();
queue.Enqueue(new List<int> { startSiteId });
while (queue.Count > 0)
{
var path = queue.Dequeue();
var current = path[path.Count - 1];
if (current == endSiteId)
{
return path;
}
if (!adjacency.TryGetValue(current, out var neighbors))
{
continue;
}
foreach (var neighbor in neighbors)
{
if (visited.Contains(neighbor))
{
continue;
}
visited.Add(neighbor);
var nextPath = new List<int>(path) { neighbor };
queue.Enqueue(nextPath);
}
}
return new List<int>();
}
private static Dictionary<int, HashSet<int>> BuildSiteAdjacency()
{
var adjacency = new Dictionary<int, HashSet<int>>();
foreach (var track in SimpleLib.GetAllTracks())
{
var siteA = track.siteA;
var siteB = track.siteB;
switch (track.direction)
{
case 0:
AddAdjacency(adjacency, siteA, siteB);
AddAdjacency(adjacency, siteB, siteA);
break;
case 1:
AddAdjacency(adjacency, siteA, siteB);
break;
case 2:
AddAdjacency(adjacency, siteB, siteA);
break;
default:
AddAdjacency(adjacency, siteA, siteB);
AddAdjacency(adjacency, siteB, siteA);
break;
}
}
return adjacency;
}
private static void AddAdjacency(Dictionary<int, HashSet<int>> adjacency, int from, int to)
{
if (!adjacency.TryGetValue(from, out var set))
{
set = new HashSet<int>();
adjacency[from] = set;
}
set.Add(to);
}
}
}
@@ -0,0 +1,127 @@
using System.Collections.Generic;
using System.Linq;
namespace StandardScene.Magnetic.Tasking
{
/// <summary>
/// 断线重连后,用当前占点匹配 tasklist 链路的结果。
/// </summary>
public sealed class Fass2ChainMatch
{
public int TaskId { get; set; }
public int TaskPriority { get; set; }
public int CurrentSiteId { get; set; }
public int TargetSiteId { get; set; }
public int IndexInPath { get; set; }
public int DistanceToTarget { get; set; }
public bool IsAtStartPoint { get; set; }
public bool IsAtEndPoint { get; set; }
public IReadOnlyList<int> FullPath { get; set; } = new List<int>();
public IReadOnlyList<int> RemainingPath { get; set; } = new List<int>();
public override string ToString()
{
var position = IsAtStartPoint ? "start" : (IsAtEndPoint ? "end" : "mid");
return $"task={TaskId} {CurrentSiteId}->{TargetSiteId} pos={position} remain={DistanceToTarget} prio={TaskPriority}";
}
}
/// <summary>
/// 一站多链路时的固定裁决:原 goal 仍在剩余路径 → 起点 → 优先级 → 距目标更近。
/// </summary>
public static class Fass2ReconnectChainSelector
{
public static bool TrySelect(
IReadOnlyList<Fass2ChainMatch> matches,
int? preferGoalSiteId,
out Fass2ChainMatch selected,
out string reason)
{
selected = null;
reason = "no chain";
if (matches == null || matches.Count == 0)
{
return false;
}
var candidates = matches.Where(m => m != null && m.TargetSiteId > 0).ToList();
if (candidates.Count == 0)
{
return false;
}
if (preferGoalSiteId != null && preferGoalSiteId.Value > 0)
{
var keepGoal = candidates
.Where(m => RemainingContains(m, preferGoalSiteId.Value))
.ToList();
if (keepGoal.Count == 1)
{
selected = keepGoal[0];
reason = $"preferGoal={preferGoalSiteId.Value}";
return true;
}
if (keepGoal.Count > 1)
{
candidates = keepGoal;
reason = $"preferGoal={preferGoalSiteId.Value} narrowed={keepGoal.Count}";
}
}
var ranked = candidates
.OrderByDescending(m => m.IsAtStartPoint ? 1 : 0)
.ThenByDescending(m => m.TaskPriority)
.ThenBy(m => m.DistanceToTarget)
.ThenBy(m => m.TaskId)
.ToList();
if (ranked.Count == 0)
{
return false;
}
var best = ranked[0];
if (ranked.Count > 1)
{
var second = ranked[1];
var tied = best.IsAtStartPoint == second.IsAtStartPoint &&
best.TaskPriority == second.TaskPriority &&
best.DistanceToTarget == second.DistanceToTarget;
if (tied && best.TargetSiteId != second.TargetSiteId)
{
reason = $"ambiguous {best} vs {second}";
return false;
}
}
selected = best;
if (string.IsNullOrEmpty(reason) || reason == "no chain")
{
reason = $"selected {best}";
}
else
{
reason = $"{reason}; selected {best}";
}
return true;
}
private static bool RemainingContains(Fass2ChainMatch match, int siteId)
{
if (match.RemainingPath != null)
{
for (var i = 0; i < match.RemainingPath.Count; i++)
{
if (match.RemainingPath[i] == siteId)
{
return true;
}
}
}
return match.TargetSiteId == siteId;
}
}
}
@@ -0,0 +1,210 @@
using SimpleCore;
using SimpleCore.Compiler;
using SimpleCore.Library;
using SimpleCore.PropType;
using System;
using System.Collections.Generic;
namespace StandardScene.Magnetic.Tasking
{
/// <summary>
/// 从站点序列构建 <see cref="SegmentPlan"/>,供交管 Forecast 与任务下发共用同一路径。
/// </summary>
internal static class Fass2RouteHelper
{
public static List<int> GetSitesBetween(int startSiteId, int endSiteId, AbstractCar car)
{
if (startSiteId == endSiteId)
{
return new List<int> { startSiteId };
}
if (car != null)
{
var startSite = SimpleLib.GetSite(startSiteId);
var endSite = SimpleLib.GetSite(endSiteId);
if (startSite != null && endSite != null)
{
var plan = new SegmentPlan { usingCar = car };
plan.fields["allow_destination_on_route"] = "true";
plan.FindRoute(startSite, endSite);
var sites = ExtractSiteIds(plan);
if (sites.Count > 0)
{
return sites;
}
}
}
return Fass2PathFinder.GetSitesBetweenBfs(startSiteId, endSiteId);
}
public static void ForecastTrafficSequence(AbstractCar car, IReadOnlyList<int> siteIds, int fromIndex)
{
if (car == null || siteIds == null || siteIds.Count == 0)
{
throw new ArgumentException("traffic forecast requires car and siteIds");
}
fromIndex = Math.Max(0, Math.Min(fromIndex, siteIds.Count - 1));
if (fromIndex >= siteIds.Count - 1)
{
return;
}
var routeSiteIds = new List<int>();
for (var i = fromIndex; i < siteIds.Count; i++)
{
routeSiteIds.Add(siteIds[i]);
}
try
{
var plan = BuildSegmentPlan(car, routeSiteIds);
var program = plan.Compile("fass2-traffic", false);
program.Forecast();
}
catch (Exception ex) when (IsRecoverableForecastFailure(ex))
{
// 双车 Multi-car search Exhausted 时降级:只建 pending/seqScope,交由 TryLock 现场占点
ApplyManualTrafficSequence(car, routeSiteIds);
Diagnosis.Post(
$"Mag2Car forecast fallback manual sequence car={car.id}: {ex.Message}",
"Mag2Car",
true);
}
}
/// <summary>
/// 不走 SimpleCore 多车 Forecast,直接写入交管序列(持锁起点已由 TrafficReset 保证)。
/// </summary>
public static void ApplyManualTrafficSequence(AbstractCar car, IReadOnlyList<int> routeSiteIds)
{
if (car == null || routeSiteIds == null || routeSiteIds.Count == 0)
{
return;
}
var scope = new int[routeSiteIds.Count];
for (var i = 0; i < routeSiteIds.Count; i++)
{
scope[i] = routeSiteIds[i];
}
var pending = new int[Math.Max(0, scope.Length - 1)];
for (var i = 0; i < pending.Length; i++)
{
pending[i] = scope[i + 1];
}
car.status.seqScope = scope;
car.status.pendingLocks = pending;
car.status.seqPtr = scope.Length > 1 ? 1 : 0;
car.status.escape = Array.Empty<int>();
}
private static bool IsRecoverableForecastFailure(Exception ex)
{
for (var cur = ex; cur != null; cur = cur.InnerException)
{
var msg = cur.Message ?? string.Empty;
if (msg.IndexOf("Multi-car traffic search", StringComparison.OrdinalIgnoreCase) >= 0 ||
msg.IndexOf("Exhausted", StringComparison.OrdinalIgnoreCase) >= 0 ||
msg.IndexOf("BadForecast", StringComparison.OrdinalIgnoreCase) >= 0 ||
cur.GetType().Name.IndexOf("Forecast", StringComparison.OrdinalIgnoreCase) >= 0)
{
return true;
}
}
return false;
}
public static SegmentPlan BuildSegmentPlan(AbstractCar car, IReadOnlyList<int> siteIds)
{
if (car == null || siteIds == null || siteIds.Count == 0)
{
throw new ArgumentException("segment plan requires car and siteIds");
}
var plan = new SegmentPlan { usingCar = car };
plan.fields["allow_destination_on_route"] = "true";
var firstSite = SimpleLib.GetSite(siteIds[0]);
if (firstSite == null)
{
throw new InvalidOperationException($"site {siteIds[0]} not found");
}
plan.segments.Add(firstSite);
for (var i = 0; i < siteIds.Count - 1; i++)
{
var dstSite = SimpleLib.GetSite(siteIds[i + 1]);
if (dstSite == null)
{
throw new InvalidOperationException($"site {siteIds[i + 1]} not found");
}
var track = FindTrack(siteIds[i], siteIds[i + 1]);
if (track == null)
{
throw new InvalidOperationException($"no track from site {siteIds[i]} to {siteIds[i + 1]}");
}
plan.segments.Add(track);
plan.segments.Add(dstSite);
}
return plan;
}
public static List<int> ExtractSiteIds(SegmentPlan plan)
{
var sites = new List<int>();
if (plan?.segments == null)
{
return sites;
}
foreach (var segment in plan.segments)
{
if (segment is Site site)
{
sites.Add(site.id);
}
}
return sites;
}
public static Track FindTrack(int fromSiteId, int toSiteId)
{
foreach (var track in SimpleLib.GetAllTracks())
{
if (track.direction == 0)
{
if ((track.siteA == fromSiteId && track.siteB == toSiteId) ||
(track.siteB == fromSiteId && track.siteA == toSiteId))
{
return track;
}
continue;
}
if (track.direction == 1 && track.siteA == fromSiteId && track.siteB == toSiteId)
{
return track;
}
if (track.direction == 2 && track.siteB == fromSiteId && track.siteA == toSiteId)
{
return track;
}
}
return null;
}
}
}
@@ -0,0 +1,147 @@
using SimpleCore.PropType;
using System;
using System.Collections.Generic;
using System.Globalization;
namespace StandardScene.Magnetic.Tasking
{
internal static class Fass2SiteFieldReader
{
public static Fass2SiteActionData Read(Site site)
{
var data = new Fass2SiteActionData();
if (site?.fields == null)
{
return data;
}
data.StartStop = TryReadByte(site.fields, Fass2SiteFields.StartStop);
data.Byroad = TryReadByte(site.fields, Fass2SiteFields.Byroad);
data.Direction = TryReadByte(site.fields, Fass2SiteFields.Direction);
data.Orientation = TryReadByte(site.fields, Fass2SiteFields.Orientation);
data.Lift = TryReadByte(site.fields, Fass2SiteFields.Lift);
data.Roll = TryReadByte(site.fields, Fass2SiteFields.Roll);
data.Charge = TryReadByte(site.fields, Fass2SiteFields.Charge);
data.Obstacle = TryReadByte(site.fields, Fass2SiteFields.Obstacle);
data.Audio = TryReadByte(site.fields, Fass2SiteFields.Audio);
data.Light = TryReadByte(site.fields, Fass2SiteFields.Light);
data.Rest = TryReadByte(site.fields, Fass2SiteFields.Rest);
data.Clamp = TryReadByte(site.fields, Fass2SiteFields.Clamp);
data.Tray = TryReadByte(site.fields, Fass2SiteFields.Tray);
data.Shutdown = TryReadByte(site.fields, Fass2SiteFields.Shutdown);
data.PrecisionStop = TryReadBool(site.fields, Fass2SiteFields.PrecisionStop);
data.WaitMode = TryReadString(site.fields, Fass2SiteFields.WaitMode);
data.ControlArea = TryReadString(site.fields, Fass2SiteFields.ControlArea);
data.ControlCapacity = TryReadInt(site.fields, Fass2SiteFields.ControlCapacity);
data.ControlReleaseStartStop = TryReadByte(site.fields, Fass2SiteFields.ControlReleaseStartStop);
if (!data.ControlCapacity.HasValue)
{
TryFillRegionCapacityFallback(site.fields, data);
}
return data;
}
public static string BuildFieldsSignature(IReadOnlyList<int> siteIds, int fromIndex, Func<int, Site> getSite)
{
if (siteIds == null || siteIds.Count == 0)
{
return string.Empty;
}
var parts = new List<string>(siteIds.Count - fromIndex);
for (var i = Math.Max(0, fromIndex); i < siteIds.Count; i++)
{
var site = getSite(siteIds[i]);
if (site?.fields == null)
{
parts.Add($"{siteIds[i]}:");
continue;
}
var keys = new List<string>();
foreach (var pair in site.fields)
{
if (pair.Key.StartsWith("Fass2_", StringComparison.OrdinalIgnoreCase) ||
pair.Key.Equals(Fass2SiteFields.TagValue, StringComparison.OrdinalIgnoreCase))
{
keys.Add($"{pair.Key}={pair.Value}");
}
}
keys.Sort(StringComparer.OrdinalIgnoreCase);
parts.Add($"{siteIds[i]}:{string.Join("|", keys)}");
}
return string.Join(";", parts);
}
private static byte? TryReadByte(Dictionary<string, string> fields, string key)
{
if (!fields.TryGetValue(key, out var text) || string.IsNullOrWhiteSpace(text))
{
return null;
}
if (byte.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value))
{
return value;
}
return null;
}
private static bool TryReadBool(Dictionary<string, string> fields, string key)
{
if (!fields.TryGetValue(key, out var text) || string.IsNullOrWhiteSpace(text))
{
return false;
}
return bool.TryParse(text, out var value) && value
|| text == "1"
|| string.Equals(text, "true", StringComparison.OrdinalIgnoreCase);
}
private static string TryReadString(Dictionary<string, string> fields, string key)
{
return fields.TryGetValue(key, out var text) ? text : null;
}
private static int? TryReadInt(Dictionary<string, string> fields, string key)
{
if (!fields.TryGetValue(key, out var text) || string.IsNullOrWhiteSpace(text))
{
return null;
}
if (int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value))
{
return value;
}
return null;
}
private static void TryFillRegionCapacityFallback(Dictionary<string, string> fields, Fass2SiteActionData data)
{
foreach (var pair in fields)
{
if (string.IsNullOrEmpty(pair.Key) ||
!pair.Key.StartsWith("Region", StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (int.TryParse(pair.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var maxCount) &&
maxCount > 0)
{
data.ControlCapacity = maxCount;
}
return;
}
}
}
}
@@ -0,0 +1,56 @@
namespace StandardScene.Magnetic.Tasking
{
/// <summary>
/// FASS 2.0 站点 <c>fields</c> 键名常量,用于地图编辑器配置站点动作。
/// </summary>
public static class Fass2SiteFields
{
public const string TagValue = "TagValue";
public const string StartStop = "Fass2_StartStop";
public const string Byroad = "Fass2_Byroad";
public const string Direction = "Fass2_Direction";
public const string Orientation = "Fass2_Orientation";
public const string Lift = "Fass2_Lift";
public const string Roll = "Fass2_Roll";
public const string Charge = "Fass2_Charge";
public const string Obstacle = "Fass2_Obstacle";
public const string Audio = "Fass2_Audio";
public const string Light = "Fass2_Light";
public const string Rest = "Fass2_Rest";
public const string Clamp = "Fass2_Clamp";
public const string Tray = "Fass2_Tray";
public const string Shutdown = "Fass2_Shutdown";
public const string PrecisionStop = "Fass2_PrecisionStop";
public const string WaitMode = "Fass2_WaitMode";
public const string ControlArea = "Fass2_ControlArea";
public const string ControlCapacity = "Fass2_ControlCapacity";
public const string ControlReleaseStartStop = "Fass2_ControlReleaseStartStop";
}
/// <summary>
/// 从站点 fields 解析出的动作参数(映射到 25B 节点块)。
/// </summary>
public sealed class Fass2SiteActionData
{
public byte? StartStop { get; set; }
public byte? Byroad { get; set; }
public byte? Direction { get; set; }
public byte? Orientation { get; set; }
public byte? Lift { get; set; }
public byte? Roll { get; set; }
public byte? Charge { get; set; }
public byte? Obstacle { get; set; }
public byte? Audio { get; set; }
public byte? Light { get; set; }
public byte? Rest { get; set; }
public byte? Clamp { get; set; }
public byte? Tray { get; set; }
public byte? Shutdown { get; set; }
public bool PrecisionStop { get; set; }
public string WaitMode { get; set; }
public string ControlArea { get; set; }
public int? ControlCapacity { get; set; }
public byte? ControlReleaseStartStop { get; set; }
}
}
@@ -0,0 +1,213 @@
using SimpleCore;
using SimpleCore.PropType;
using StandardScene.Magnetic.Protocol;
using System;
using System.Collections.Generic;
namespace StandardScene.Magnetic.Tasking
{
/// <summary>
/// 将站点路径与站点/边 fields 合并为 FASS 2.0 <c>0xB1</c> 节点序列。
/// 对齐 backend <c>CarRequestService.GetSendNodes</c>:边属性挂在节点<b>出边</b>上。
/// </summary>
public static class Fass2TaskBuilder
{
public const byte StartStopPass = 1;
public const byte StartStopStop = 2;
public const byte StartStopControlStart = 11;
public const byte StartStopControlStop = 12;
public const byte StartStopPrecision = 22;
public static Fass2TaskPlan BuildPath(int startSiteId, int goalSiteId, Fass2TaskBuildOptions options,
Func<int, ushort> resolveNodeId, AbstractCar routingCar = null)
{
if (resolveNodeId == null)
{
throw new ArgumentNullException(nameof(resolveNodeId));
}
options ??= new Fass2TaskBuildOptions();
var siteIds = Fass2RouteHelper.GetSitesBetween(startSiteId, goalSiteId, routingCar);
if (siteIds.Count == 0)
{
throw new InvalidOperationException($"no path from site {startSiteId} to {goalSiteId}");
}
var nodes = BuildNodesForSites(siteIds, options, resolveNodeId);
return new Fass2TaskPlan
{
StartSiteId = startSiteId,
GoalSiteId = goalSiteId,
SiteIds = siteIds,
Nodes = nodes,
Batches = SplitBatches(nodes, options.MaxNodesPerFrame),
FieldsSignature = Fass2SiteFieldReader.BuildFieldsSignature(siteIds, 0, SimpleLib.GetSite)
};
}
public static Fass2NodeMessage[] BuildSegment(int srcSiteId, int dstSiteId, Fass2TaskBuildOptions options,
Func<int, ushort> resolveNodeId, AbstractCar routingCar = null)
{
var plan = BuildPath(srcSiteId, dstSiteId, options, resolveNodeId, routingCar);
var nodes = plan.Nodes;
var array = new Fass2NodeMessage[nodes.Count];
for (var i = 0; i < nodes.Count; i++)
{
array[i] = nodes[i];
}
return array;
}
public static IReadOnlyList<Fass2NodeMessage[]> SplitBatches(IReadOnlyList<Fass2NodeMessage> nodes, int maxPerFrame = 10)
{
if (nodes == null || nodes.Count == 0)
{
return Array.Empty<Fass2NodeMessage[]>();
}
if (maxPerFrame <= 0 || maxPerFrame > 10)
{
maxPerFrame = 10;
}
var batches = new List<Fass2NodeMessage[]>();
for (var offset = 0; offset < nodes.Count; offset += maxPerFrame)
{
var count = Math.Min(maxPerFrame, nodes.Count - offset);
var batch = new Fass2NodeMessage[count];
for (var i = 0; i < count; i++)
{
batch[i] = nodes[offset + i];
}
batches.Add(batch);
}
return batches;
}
private static List<Fass2NodeMessage> BuildNodesForSites(IReadOnlyList<int> siteIds, Fass2TaskBuildOptions options,
Func<int, ushort> resolveNodeId)
{
var nodes = new List<Fass2NodeMessage>(siteIds.Count);
for (var i = 0; i < siteIds.Count; i++)
{
var siteId = siteIds[i];
var site = SimpleLib.GetSite(siteId);
var siteAction = Fass2SiteFieldReader.Read(site);
var isLast = i == siteIds.Count - 1;
var nextSiteId = isLast ? -1 : siteIds[i + 1];
Fass2TrackMotionData trackMotion = null;
Site currentSite = site;
Site nextSite = null;
if (!isLast)
{
nextSite = SimpleLib.GetSite(nextSiteId);
var track = Fass2RouteHelper.FindTrack(siteId, nextSiteId);
trackMotion = Fass2TrackFieldReader.Read(track);
}
var node = new Fass2NodeMessage
{
Node = resolveNodeId(siteId),
StartStop = ResolveStartStop(siteAction, isLast),
Distance = !isLast ? ComputeEdgeDistance(currentSite, nextSite) : (ushort)0,
Speed = !isLast
? SpeedToProtocol(trackMotion?.Speed ?? options.DefaultSpeed)
: (ushort)0,
Orientation = !isLast ? ResolveOrientation(currentSite, nextSite, trackMotion) : (byte)0,
Byroad = siteAction.Byroad ?? trackMotion?.Byroad ?? 0,
Direction = siteAction.Direction ?? trackMotion?.Direction ?? 0,
Lift = siteAction.Lift ?? 0,
Roll = siteAction.Roll ?? 0,
Charge = siteAction.Charge ?? 0,
Obstacle = siteAction.Obstacle ?? 0,
Audio = siteAction.Audio ?? 0,
Light = siteAction.Light ?? 0,
Rest = siteAction.Rest ?? 0,
Clamp = siteAction.Clamp ?? 0,
Tray = siteAction.Tray ?? 0,
Shutdown = siteAction.Shutdown ?? 0
};
if (siteAction.Orientation.HasValue)
{
node.Orientation = siteAction.Orientation.Value;
}
nodes.Add(node);
}
return nodes;
}
private static byte ResolveStartStop(Fass2SiteActionData siteAction, bool isLast)
{
if (siteAction.StartStop.HasValue)
{
return siteAction.StartStop.Value;
}
if (isLast)
{
return siteAction.PrecisionStop ? StartStopPrecision : StartStopStop;
}
return StartStopPass;
}
private static byte ResolveOrientation(Site src, Site dst, Fass2TrackMotionData trackMotion)
{
if (trackMotion?.Orientation != null)
{
return trackMotion.Orientation.Value;
}
if (src == null || dst == null)
{
return 0;
}
var angle = trackMotion != null && trackMotion.EnableCarAbsoluteDirection
? trackMotion.CarAbsoluteDirection
: Math.Atan2(dst.y - src.y, dst.x - src.x) / Math.PI * 180.0;
if (trackMotion?.Reverse == true)
{
angle += 180;
}
angle += trackMotion?.CarDirectionBias ?? 0;
angle %= 360;
if (angle < 0)
{
angle += 360;
}
return (byte)((int)Math.Round(angle / 360.0 * 256) % 256);
}
private static ushort ComputeEdgeDistance(Site src, Site dst)
{
var dx = dst.x - src.x;
var dy = dst.y - src.y;
var length = Math.Sqrt(dx * dx + dy * dy);
return (ushort)Math.Min(ushort.MaxValue, Math.Max(1, Math.Round(length)));
}
/// <summary>
/// 协议速度单位 0.1 m/min;地图 Speed 默认 m/min,≤1 视为旧版 m/s 兼容。
/// </summary>
public static ushort SpeedToProtocol(double speed)
{
if (speed > 0 && speed <= 1)
{
speed *= 60;
}
return (ushort)Math.Max(1, Math.Min(10000, Math.Round(speed * 10)));
}
}
}
@@ -0,0 +1,191 @@
using SimpleCore.PropType;
using StandardScene.Magnetic.Protocol;
using System;
using System.Collections.Generic;
namespace StandardScene.Magnetic.Tasking
{
public enum Fass2TaskPhase
{
Idle,
Planning,
Dispatching,
Moving,
AtStation,
SegmentDone,
Complete,
Fault,
Cancelled
}
/// <summary>
/// 一次 FASS 2.0 任务执行上下文,供状态机推进与重启恢复使用。
/// </summary>
public sealed class Fass2TaskContext
{
public Fass2TaskPhase Phase { get; set; } = Fass2TaskPhase.Idle;
public int StartSiteId { get; set; }
public int GoalSiteId { get; set; }
public int CurrentIndex { get; set; }
public ulong TaskId { get; set; }
public Fass2TaskPlan Plan { get; set; }
public string FieldsSignature { get; set; } = string.Empty;
public double DefaultSpeed { get; set; } = -1;
public string FaultReason { get; set; }
public DateTime StartedAt { get; set; }
public DateTime LastDispatchAt { get; set; } = DateTime.MinValue;
public ulong LastActionId { get; set; }
public DateTime LastActionSentAt { get; set; } = DateTime.MinValue;
/// <summary>交管窗口未齐、正在等锁;此期间不消耗移动超时预算。</summary>
public bool WaitingForTraffic { get; set; }
/// <summary>停在管控停止点等待按车放行;此期间不消耗移动超时预算。</summary>
public bool WaitingForRelease { get; set; }
/// <summary>本任务已对哪个路径下标发过放行报文;-1 表示尚未放行。</summary>
public int ControlReleasedIndex { get; set; } = -1;
/// <summary>最近一次规划的站点链路,供断线重连判断是否仍在原路径上。</summary>
public int[] RouteSiteIds { get; set; } = Array.Empty<int>();
}
public static class Fass2TaskPersistence
{
public const string TagPhase = "Fass2Task_Phase";
public const string TagStartSite = "Fass2Task_StartSite";
public const string TagGoalSite = "Fass2Task_GoalSite";
public const string TagCurrentIndex = "Fass2Task_CurrentIndex";
public const string TagTaskId = "Fass2Task_TaskId";
public const string TagFieldsSignature = "Fass2Task_FieldsSig";
public const string TagDefaultSpeed = "Fass2Task_DefaultSpeed";
public const string TagRouteSites = "Fass2Task_RouteSites";
public static void Save(TagSet tags, Fass2TaskContext context)
{
if (tags == null || context == null)
{
return;
}
SetTag(tags, TagPhase, context.Phase.ToString());
SetTag(tags, TagStartSite, context.StartSiteId.ToString());
SetTag(tags, TagGoalSite, context.GoalSiteId.ToString());
SetTag(tags, TagCurrentIndex, context.CurrentIndex.ToString());
SetTag(tags, TagTaskId, context.TaskId.ToString());
SetTag(tags, TagFieldsSignature, context.FieldsSignature ?? string.Empty);
SetTag(tags, TagDefaultSpeed, context.DefaultSpeed.ToString(System.Globalization.CultureInfo.InvariantCulture));
SetTag(tags, TagRouteSites, FormatIntList(context.RouteSiteIds));
}
public static bool TryLoad(TagSet tags, out Fass2TaskContext context)
{
context = null;
if (tags == null || !tags.TryGetValue(TagPhase, out var phaseText) ||
!Enum.TryParse(phaseText, out Fass2TaskPhase phase))
{
return false;
}
if (phase is Fass2TaskPhase.Idle or Fass2TaskPhase.Complete or Fass2TaskPhase.Cancelled)
{
return false;
}
if (!TryReadInt(tags, TagStartSite, out var startSite) ||
!TryReadInt(tags, TagGoalSite, out var goalSite) ||
!TryReadInt(tags, TagCurrentIndex, out var currentIndex))
{
return false;
}
tags.TryGetValue(TagFieldsSignature, out var fieldsSignature);
ulong.TryParse(tags.TryGetValue(TagTaskId, out var taskIdText) ? taskIdText : "0", out var taskId);
double.TryParse(
tags.TryGetValue(TagDefaultSpeed, out var speedText) ? speedText : "-1",
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture,
out var defaultSpeed);
tags.TryGetValue(TagRouteSites, out var routeSitesText);
context = new Fass2TaskContext
{
Phase = phase,
StartSiteId = startSite,
GoalSiteId = goalSite,
CurrentIndex = currentIndex,
TaskId = taskId,
FieldsSignature = fieldsSignature ?? string.Empty,
DefaultSpeed = defaultSpeed,
RouteSiteIds = ParseIntList(routeSitesText)
};
return true;
}
public static void Clear(TagSet tags)
{
if (tags == null)
{
return;
}
RemoveTag(tags, TagPhase);
RemoveTag(tags, TagStartSite);
RemoveTag(tags, TagGoalSite);
RemoveTag(tags, TagCurrentIndex);
RemoveTag(tags, TagTaskId);
RemoveTag(tags, TagFieldsSignature);
RemoveTag(tags, TagDefaultSpeed);
RemoveTag(tags, TagRouteSites);
}
private static void SetTag(TagSet tags, string key, string value)
{
if (tags.Contains(key))
{
tags.Remove(key);
}
tags.Add(key, value);
}
private static void RemoveTag(TagSet tags, string key)
{
if (tags.Contains(key))
{
tags.Remove(key);
}
}
private static bool TryReadInt(TagSet tags, string key, out int value)
{
value = 0;
return tags.TryGetValue(key, out var text) && int.TryParse(text, out value);
}
private static string FormatIntList(int[] values)
{
return values == null || values.Length == 0 ? string.Empty : string.Join(",", values);
}
private static int[] ParseIntList(string text)
{
if (string.IsNullOrWhiteSpace(text))
{
return Array.Empty<int>();
}
var parts = text.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
var ids = new List<int>(parts.Length);
foreach (var part in parts)
{
if (int.TryParse(part.Trim(), out var id) && id > 0)
{
ids.Add(id);
}
}
return ids.ToArray();
}
}
}
@@ -0,0 +1,26 @@
using StandardScene.Magnetic.Protocol;
using System.Collections.Generic;
namespace StandardScene.Magnetic.Tasking
{
public sealed class Fass2TaskBuildOptions
{
public bool UseTagValueAsNode { get; set; }
public double DefaultSpeed { get; set; } = 12;
public double CarSpeed { get; set; } = 1;
public int MaxNodesPerFrame { get; set; } = 10;
}
/// <summary>
/// 一次 FASS 2.0 路径任务计划:站点序列、节点报文、分批结果。
/// </summary>
public sealed class Fass2TaskPlan
{
public int StartSiteId { get; set; }
public int GoalSiteId { get; set; }
public IReadOnlyList<int> SiteIds { get; set; } = new List<int>();
public IReadOnlyList<Fass2NodeMessage> Nodes { get; set; } = new List<Fass2NodeMessage>();
public IReadOnlyList<Fass2NodeMessage[]> Batches { get; set; } = new List<Fass2NodeMessage[]>();
public string FieldsSignature { get; set; } = string.Empty;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,84 @@
using SimpleCore.PropType;
using System;
using System.Collections.Generic;
using System.Globalization;
namespace StandardScene.Magnetic.Tasking
{
internal sealed class Fass2TrackMotionData
{
public float Speed { get; set; } = 0.2f;
public bool Reverse { get; set; }
public float CarDirectionBias { get; set; }
public bool EnableCarAbsoluteDirection { get; set; }
public float CarAbsoluteDirection { get; set; }
public byte? Byroad { get; set; }
public byte? Direction { get; set; }
public byte? Orientation { get; set; }
}
internal static class Fass2TrackFieldReader
{
public static Fass2TrackMotionData Read(Track track)
{
var data = new Fass2TrackMotionData();
if (track?.fields == null)
{
return data;
}
if (track.fields.TryGetValue("Speed", out var speedText) &&
float.TryParse(speedText, NumberStyles.Float, CultureInfo.InvariantCulture, out var speed))
{
data.Speed = speed;
}
if (track.fields.TryGetValue("Reverse", out var reverseText) &&
bool.TryParse(reverseText, out var reverse))
{
data.Reverse = reverse;
}
if (track.fields.TryGetValue("CarDirectionBias", out var biasText) &&
float.TryParse(biasText, NumberStyles.Float, CultureInfo.InvariantCulture, out var bias))
{
data.CarDirectionBias = bias;
}
if (track.fields.TryGetValue("EnableCarAbsoluteDirection", out var enableAbsText) &&
bool.TryParse(enableAbsText, out var enableAbs))
{
data.EnableCarAbsoluteDirection = enableAbs;
}
if (track.fields.TryGetValue("CarAbsoluteDirection", out var absText) &&
float.TryParse(absText, NumberStyles.Float, CultureInfo.InvariantCulture, out var absDir))
{
data.CarAbsoluteDirection = absDir;
}
data.Byroad = TryReadByte(track.fields, "Fass2_Byroad");
if (data.Byroad == null && track.fields.TryGetValue("magSelect", out var magSelect) &&
byte.TryParse(magSelect, NumberStyles.Integer, CultureInfo.InvariantCulture, out var magValue))
{
data.Byroad = magValue;
}
data.Direction = TryReadByte(track.fields, "Fass2_Direction");
data.Orientation = TryReadByte(track.fields, "Fass2_Orientation");
return data;
}
private static byte? TryReadByte(Dictionary<string, string> fields, string key)
{
if (!fields.TryGetValue(key, out var text) || string.IsNullOrWhiteSpace(text))
{
return null;
}
return byte.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)
? value
: (byte?)null;
}
}
}
@@ -0,0 +1,303 @@
using SimpleCore;
using SimpleCore.Compiler;
using SimpleCore.PropType;
using SimpleCore.Traffic;
using System;
using System.Collections.Generic;
using System.Linq;
namespace StandardScene.Magnetic.Tasking
{
/// <summary>
/// 方案 B′:用任务下发的 siteIds 经 SegmentPlan.Forecast 建立 seqScope/pendingLocks,再段级 TryLock/Leave。
/// </summary>
internal sealed class Fass2TrafficLocker
{
private readonly AbstractCar _car;
private readonly Action<string> _log;
public Fass2TrafficLocker(AbstractCar car, Action<string> log)
{
_car = car ?? throw new ArgumentNullException(nameof(car));
_log = log ?? (_ => { });
}
public void PrepareSequence(IReadOnlyList<int> siteIds, int fromIndex)
{
if (siteIds == null || siteIds.Count == 0)
{
throw new InvalidOperationException("traffic plan siteIds is empty");
}
fromIndex = Math.Max(0, Math.Min(fromIndex, siteIds.Count - 1));
var startSiteId = siteIds[fromIndex];
var startSite = SimpleLib.GetSite(startSiteId);
if (startSite == null)
{
throw new InvalidOperationException($"traffic start site invalid: {startSiteId}");
}
// TrafficReset 会 programs.Clear() 把 now 置空;TryLock 在真正占点前要求 programs.now != null
// 否则抛 Program obsoleted。环线拦截路径仍处在 move 脚本的 actualSendScript 中,需保留/重建锚点。
var keepProgram = _car.status.programs.now;
_log($"prepare reset start={startSiteId}, goal={siteIds[siteIds.Count - 1]}, fromIndex={fromIndex}, route=[{string.Join(",", siteIds)}]");
try
{
_car.TrafficReset(startSite, makeAvailable: true, strict: false);
}
catch (Exception ex) when (IsStartOccupiedByOtherCar(ex))
{
// 起点被他车占用时不能 Reset;若本车已持有该站则跳过 Reset 继续建序,否则交给上层等待。
if (Array.IndexOf(_car.status.holdingLocks, startSiteId) < 0)
{
throw new InvalidOperationException(
$"traffic start site {startSiteId} occupied by another car", ex);
}
_log($"prepare reset skipped, already holding start={startSiteId}, ex={ex.Message}");
}
EnsureProgramAnchor(keepProgram);
if (fromIndex >= siteIds.Count - 1)
{
LogTrafficState("prepare single-site");
return;
}
Fass2RouteHelper.ForecastTrafficSequence(_car, siteIds, fromIndex);
LogTrafficState("prepare forecast ok");
}
public void RebaseFrom(IReadOnlyList<int> siteIds, int fromIndex, int windowSize)
{
PrepareSequence(siteIds, fromIndex);
EnsureWindowLocked(siteIds, fromIndex, Math.Max(1, windowSize));
}
private static bool IsStartOccupiedByOtherCar(Exception ex)
{
for (var cur = ex; cur != null; cur = cur.InnerException)
{
var msg = cur.Message ?? string.Empty;
if (msg.IndexOf("already locks it", StringComparison.OrdinalIgnoreCase) >= 0 ||
msg.IndexOf("unavailable site", StringComparison.OrdinalIgnoreCase) >= 0)
{
return true;
}
}
return false;
}
public bool TryLockNext(int siteId)
{
if (!_car.status.usage.Get().scheduling)
{
throw new InvalidOperationException("abandoned");
}
if (Array.IndexOf(_car.status.holdingLocks, siteId) >= 0)
{
return true;
}
if (_car.status.pendingLocks.Length > 0 && _car.status.pendingLocks[0] != siteId)
{
// 窗口重入时可能再次请求已锁过的更早站点;交给 EnsureWindowLocked 跳过,避免 UDP 线程抛异常。
_log($"lock skip site={siteId}, expect pending0={_car.status.pendingLocks[0]}, holding=[{FormatInts(_car.status.holdingLocks)}]");
return false;
}
EnsureProgramAnchor(keepProgram: null);
if (TrafficControl.TryLock(_car, siteId))
{
_log($"lock ok site={siteId}, holding=[{FormatInts(_car.status.holdingLocks)}]");
return true;
}
_log($"lock wait site={siteId}, blockedBy={FormatBlocked()}, TCStat={FormatTcStat()}");
return false;
}
public void LeavePassed(int siteId)
{
if (_car.status.holdingLocks.Length <= 1)
{
_log($"leave skip site={siteId}, holding count={_car.status.holdingLocks.Length}");
return;
}
if (Array.IndexOf(_car.status.holdingLocks, siteId) < 0)
{
_log($"leave skip site={siteId}, not in holding");
return;
}
EnsureProgramAnchor(keepProgram: null);
TrafficControl.Leave(_car, siteId);
_log($"leave site={siteId}, holding=[{FormatInts(_car.status.holdingLocks)}]");
}
/// <summary>
/// 释放路径上严格位于 keepFromIndex 之前的持锁站点(当前站及前方窗口保留)。
/// </summary>
public void ReleaseLocksBehind(IReadOnlyList<int> siteIds, int keepFromIndex)
{
if (siteIds == null || siteIds.Count == 0 || _car.status.holdingLocks.Length <= 1)
{
return;
}
keepFromIndex = Math.Max(0, Math.Min(keepFromIndex, siteIds.Count - 1));
var holdingSnapshot = _car.status.holdingLocks.ToArray();
foreach (var siteId in holdingSnapshot)
{
if (_car.status.holdingLocks.Length <= 1)
{
break;
}
var index = IndexOfSite(siteIds, siteId);
if (index < 0 || index >= keepFromIndex)
{
continue;
}
LeavePassed(siteId);
}
}
private static int IndexOfSite(IReadOnlyList<int> siteIds, int siteId)
{
for (var i = 0; i < siteIds.Count; i++)
{
if (siteIds[i] == siteId)
{
return i;
}
}
return -1;
}
public int EnsureWindowLocked(IReadOnlyList<int> siteIds, int fromIndex, int windowSize)
{
if (siteIds == null || siteIds.Count == 0)
{
return 0;
}
fromIndex = Math.Max(0, Math.Min(fromIndex, siteIds.Count - 1));
windowSize = Math.Max(1, Math.Min(windowSize, 10));
var maxIndex = Math.Min(siteIds.Count - 1, fromIndex + windowSize - 1);
// 返回值必须是从 fromIndex 起的连续已占/新锁站数,供 0xB1 窗口切片使用。
// UDP Tick 会反复进入:已在 holding 的站直接计数,禁止再次对已消费的 pending 站 TryLock。
var lockedCount = 0;
for (var i = fromIndex; i <= maxIndex; i++)
{
var siteId = siteIds[i];
if (Array.IndexOf(_car.status.holdingLocks, siteId) >= 0)
{
lockedCount++;
continue;
}
if (_car.status.pendingLocks.Length == 0 || _car.status.pendingLocks[0] != siteId)
{
_log(
$"window gap site={siteId}, pending0={(_car.status.pendingLocks.Length > 0 ? _car.status.pendingLocks[0].ToString() : "-")}, holding=[{FormatInts(_car.status.holdingLocks)}]");
break;
}
if (!TryLockNext(siteId))
{
break;
}
lockedCount++;
}
if (lockedCount == 0)
{
lockedCount = 1;
_log($"window warn: no holding overlap index={fromIndex}, site={siteIds[fromIndex]}, holding=[{FormatInts(_car.status.holdingLocks)}]");
}
_log($"window locked={lockedCount}/{windowSize}, fromIndex={fromIndex}, holding=[{FormatInts(_car.status.holdingLocks)}], pending=[{FormatInts(_car.status.pendingLocks)}]");
return lockedCount;
}
public void FinalizeAtSite(int siteId)
{
_log($"finalize site={siteId}, holding=[{FormatInts(_car.status.holdingLocks)}], pending=[{FormatInts(_car.status.pendingLocks)}]");
}
/// <summary>
/// SimpleCore.TryLock 在写锁前检查 programs.now;为空则抛 Program obsoleted。
/// </summary>
private void EnsureProgramAnchor(CarProgram keepProgram)
{
if (_car.status.programs.now != null)
{
return;
}
if (keepProgram != null)
{
_car.status.programs.now = keepProgram;
_log($"restore programs.now after TrafficReset, name={keepProgram.name}, state={keepProgram.status.state}");
return;
}
var dummy = new CarProgram
{
name = $"fass2-traffic-anchor:{_car.id}",
plans = new[]
{
new SegmentPlan { usingCar = _car }
}
};
dummy.status.state = CarProgram.StatusEnum.Dummy;
_car.status.programs.now = dummy;
_log($"install dummy programs.now for TryLock, name={dummy.name}");
}
private void LogTrafficState(string prefix)
{
_log(
$"{prefix}, holding=[{FormatInts(_car.status.holdingLocks)}], pending=[{FormatInts(_car.status.pendingLocks)}], seqScope=[{FormatInts(_car.status.seqScope)}], seqPtr={_car.status.seqPtr}");
}
private string FormatBlocked()
{
if (_car.status.blockedBy == null || _car.status.blockedBy.Length == 0)
{
return "-";
}
return string.Join(",", _car.status.blockedBy.Select(p => $"{p.Item1}:{p.Item2}"));
}
private string FormatTcStat()
{
try
{
var tc = _car.status.TCStat;
return string.IsNullOrWhiteSpace(tc) ? "-" : tc;
}
catch
{
return "-";
}
}
private static string FormatInts(int[] values)
{
return values == null || values.Length == 0 ? string.Empty : string.Join(",", values);
}
}
}
@@ -0,0 +1,26 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StandardScene.Magnetic.Tasking
{
/// <summary>
/// FASS 2.0 车型与 LoopMission 衔接接口。
/// LoopMission 仍通过 <c>goalSite</c> 标签分配目标;车型侧负责一次全程任务执行。
/// </summary>
public interface IFass2LoopCar
{
bool IsTaskIdle { get; }
bool HasGoalSite { get; }
int? GoalSiteId { get; }
/// <summary>执行 <c>goalSite</c>(或指定目标)对应的全程 FASS 2.0 任务。</summary>
Task ExecuteGoalTaskAsync(int? goalSiteId = null, double defaultSpeed = -1,
CancellationToken cancellationToken = default);
/// <summary>任务到达终点后触发,参数为到达站 ID。</summary>
event Action<int> TaskCompleted;
}
}
@@ -0,0 +1,61 @@
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Text;
namespace StandardScene.Magnetic.Tasking
{
/// <summary>
/// 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 = "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 GetCarLogDirectory(ushort vehicleCode)
{
return Path.Combine(ResolveBaseDirectory(), $"car{vehicleCode}");
}
public static string GetCurrentLogFilePath(ushort vehicleCode)
{
var fileName = $"mag2_{DateTime.Now:yyyyMMdd}.log";
return Path.Combine(GetCarLogDirectory(vehicleCode), fileName);
}
public static void Write(ushort vehicleCode, string message)
{
if (!_enabled || vehicleCode == 0 || string.IsNullOrWhiteSpace(message))
{
return;
}
var carLock = CarLocks.GetOrAdd(vehicleCode, _ => new object());
lock (carLock)
{
var directory = GetCarLogDirectory(vehicleCode);
Directory.CreateDirectory(directory);
var path = GetCurrentLogFilePath(vehicleCode);
File.AppendAllText(
path,
$"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] {message}{Environment.NewLine}",
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
}
}
private static string ResolveBaseDirectory()
{
return Path.IsPathRooted(_baseDirectory)
? _baseDirectory
: Path.Combine(AppContext.BaseDirectory, _baseDirectory);
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.
+209
View File
@@ -0,0 +1,209 @@
# Export Mag2Car guide Markdown to PDF (requires Chrome or Edge)
param(
[string]$InputMd = "$PSScriptRoot\Mag2Car使用指南.md",
[string]$OutputPdf = "$PSScriptRoot\Mag2Car使用指南.pdf"
)
$ErrorActionPreference = 'Stop'
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
function Escape-Html([string]$text) {
return [System.Net.WebUtility]::HtmlEncode($text)
}
function Convert-MarkdownToHtml([string]$markdown) {
$lines = $markdown -split "`r?`n"
$sb = New-Object System.Text.StringBuilder
$inCode = $false
$inTable = $false
$tableRows = New-Object System.Collections.Generic.List[string[]]
$inList = $false
$listOrdered = $false
function Flush-Table {
param($builder, [ref]$inTableRef, $rows)
if (-not $inTableRef.Value) { return }
[void]$builder.AppendLine('<table>')
for ($i = 0; $i -lt $rows.Count; $i++) {
$cells = $rows[$i]
$tag = if ($i -eq 0) { 'th' } else { 'td' }
if ($i -eq 0) { [void]$builder.AppendLine('<thead><tr>') } elseif ($i -eq 1) { [void]$builder.AppendLine('<tbody><tr>') } else { [void]$builder.AppendLine('<tr>') }
foreach ($cell in $cells) {
$cellHtml = Escape-Html($cell.Trim())
$cellHtml = $cellHtml -replace '`([^`]+)`', '<code>$1</code>'
[void]$builder.AppendLine("<$tag>$cellHtml</$tag>")
}
[void]$builder.AppendLine('</tr>')
if ($i -eq 0) { [void]$builder.AppendLine('</thead>') }
}
if ($rows.Count -gt 1) { [void]$builder.AppendLine('</tbody>') }
[void]$builder.AppendLine('</table>')
$rows.Clear()
$inTableRef.Value = $false
}
function Close-List {
param($builder, [ref]$inListRef, [ref]$orderedRef)
if (-not $inListRef.Value) { return }
if ($orderedRef.Value) { [void]$builder.AppendLine('</ol>') } else { [void]$builder.AppendLine('</ul>') }
$inListRef.Value = $false
$orderedRef.Value = $false
}
foreach ($line in $lines) {
if ($line -match '^\s*```') {
if ($inCode) {
[void]$sb.AppendLine('</code></pre>')
$inCode = $false
} else {
Flush-Table $sb ([ref]$inTable) $tableRows
Close-List $sb ([ref]$inList) ([ref]$listOrdered)
[void]$sb.AppendLine('<pre><code>')
$inCode = $true
}
continue
}
if ($inCode) {
[void]$sb.AppendLine((Escape-Html $line))
continue
}
if ($line -match '^\s*\|(.+)\|\s*$') {
Close-List $sb ([ref]$inList) ([ref]$listOrdered)
if ($line -match '^\s*\|[-:\s|]+\|\s*$') { continue }
$cells = ($line.Trim('|') -split '\|') | ForEach-Object { $_.Trim() }
$tableRows.Add($cells)
$inTable = $true
continue
} else {
Flush-Table $sb ([ref]$inTable) $tableRows
}
if ($line -match '^(#{1,6})\s+(.+)$') {
Close-List $sb ([ref]$inList) ([ref]$listOrdered)
$level = $Matches[1].Length
$text = $Matches[2]
[void]$sb.AppendLine("<h$level>$(Escape-Html $text)</h$level>")
continue
}
if ($line -match '^>\s*(.+)$') {
Close-List $sb ([ref]$inList) ([ref]$listOrdered)
$text = $Matches[1]
[void]$sb.AppendLine("<blockquote><p>$(Escape-Html $text)</p></blockquote>")
continue
}
if ($line -match '^---\s*$') {
Close-List $sb ([ref]$inList) ([ref]$listOrdered)
[void]$sb.AppendLine('<hr/>')
continue
}
if ($line -match '^\d+\.\s+(.+)$') {
if (-not $inList) { [void]$sb.AppendLine('<ol>'); $inList = $true; $listOrdered = $true }
$text = Escape-Html $Matches[1]
$text = $text -replace '`([^`]+)`', '<code>$1</code>'
[void]$sb.AppendLine("<li>$text</li>")
continue
}
if ($line -match '^-\s+(.+)$') {
if (-not $inList) { [void]$sb.AppendLine('<ul>'); $inList = $true; $listOrdered = $false }
$text = Escape-Html $Matches[1]
$text = $text -replace '`([^`]+)`', '<code>$1</code>'
[void]$sb.AppendLine("<li>$text</li>")
continue
}
if ([string]::IsNullOrWhiteSpace($line)) {
Close-List $sb ([ref]$inList) ([ref]$listOrdered)
continue
}
Close-List $sb ([ref]$inList) ([ref]$listOrdered)
$p = Escape-Html $line
$p = $p -replace '`([^`]+)`', '<code>$1</code>'
[void]$sb.AppendLine("<p>$p</p>")
}
Flush-Table $sb ([ref]$inTable) $tableRows
Close-List $sb ([ref]$inList) ([ref]$listOrdered)
if ($inCode) { [void]$sb.AppendLine('</code></pre>') }
return $sb.ToString()
}
if (-not (Test-Path $InputMd)) {
throw "Markdown file not found: $InputMd"
}
$md = Get-Content -Path $InputMd -Raw -Encoding UTF8
$body = Convert-MarkdownToHtml $md
$htmlPath = [System.IO.Path]::ChangeExtension($OutputPdf, '.html')
$html = @"
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>Mag2Car Guide</title>
<style>
@page { size: A4; margin: 18mm 16mm; }
body { font-family: "Microsoft YaHei", "Segoe UI", sans-serif; color: #222; line-height: 1.55; font-size: 11pt; }
h1 { font-size: 22pt; border-bottom: 2px solid #2563eb; padding-bottom: 8px; margin-top: 0; }
h2 { font-size: 16pt; color: #1e40af; margin-top: 22px; page-break-after: avoid; }
h3 { font-size: 13pt; color: #1d4ed8; margin-top: 16px; page-break-after: avoid; }
h4, h5 { font-size: 11.5pt; margin-top: 12px; page-break-after: avoid; }
p, li { orphans: 3; widows: 3; }
code, pre { font-family: Consolas, "Courier New", monospace; }
code { background: #f3f4f6; padding: 1px 4px; border-radius: 3px; font-size: 10pt; }
pre { background: #f8fafc; border: 1px solid #e5e7eb; padding: 10px; overflow-x: auto; font-size: 9.5pt; }
pre code { background: transparent; padding: 0; }
table { border-collapse: collapse; width: 100%; margin: 10px 0 14px; font-size: 10pt; page-break-inside: avoid; }
th, td { border: 1px solid #d1d5db; padding: 6px 8px; vertical-align: top; }
th { background: #eff6ff; text-align: left; }
tr:nth-child(even) td { background: #fafafa; }
blockquote { border-left: 4px solid #93c5fd; margin: 10px 0; padding: 6px 12px; color: #374151; background: #f8fafc; }
hr { border: none; border-top: 1px solid #e5e7eb; margin: 18px 0; }
ul, ol { margin: 6px 0 10px 20px; }
</style>
</head>
<body>
$body
</body>
</html>
"@
[System.IO.File]::WriteAllText($htmlPath, $html, [System.Text.UTF8Encoding]::new($false))
$browserCandidates = @(
"$env:ProgramFiles\Google\Chrome\Application\chrome.exe",
"${env:ProgramFiles(x86)}\Google\Chrome\Application\chrome.exe",
"${env:ProgramFiles(x86)}\Microsoft\Edge\Application\msedge.exe",
"$env:ProgramFiles\Microsoft\Edge\Application\msedge.exe"
)
$browser = $browserCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $browser) {
throw "Chrome/Edge not found. HTML generated at: $htmlPath"
}
if (Test-Path $OutputPdf) { Remove-Item $OutputPdf -Force }
$resolvedHtml = (Resolve-Path -LiteralPath $htmlPath).Path
$fileUri = [System.Uri]::new($resolvedHtml).AbsoluteUri
$args = @(
"--headless=new",
"--disable-gpu",
"--no-pdf-header-footer",
"--print-to-pdf=$OutputPdf",
$fileUri
)
$proc = Start-Process -FilePath $browser -ArgumentList $args -Wait -PassThru
if ($proc.ExitCode -ne 0 -or -not (Test-Path $OutputPdf)) {
throw "PDF export failed with exit code $($proc.ExitCode). HTML: $htmlPath"
}
Write-Output "PDF: $OutputPdf"
Write-Output "HTML: $htmlPath"
@@ -29,31 +29,8 @@
</ItemGroup>
<!-- CycleGUITextViewer 等 UI 已从 WinForms 迁移到 CycleGUI(与 Core 一致,Private=false 由宿主加载) -->
<ItemGroup>
<Reference Include="CycleGUI">
<HintPath>$(CGUILibDir)\CycleGUI.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
<!-- 程序集引用:与 Core 保持一致的本地契约/工具 dll -->
<ItemGroup>
<Reference Include="SimpleLite">
<HintPath>E:\Work\Core\Simple-FR\Simple\SimpleLite\bin\Debug\SimpleLite.dll</HintPath>
</Reference>
<Reference Include="SimpleCore">
<HintPath>E:\Work\Core\Simple-FR\Simple\SimpleCore\bin\Debug\netstandard2.0\SimpleCore.dll</HintPath>
</Reference>
<Reference Include="CommonUsage">
<HintPath>D:\MDCS\Dependencies\Commons\CommonUsage.dll</HintPath>
</Reference>
<Reference Include="Topaz">
<HintPath>E:\Work\Core\Simple-FR\Simple\tools\Topaz.dll</HintPath>
</Reference>
<Reference Include="LessokajiWeaverUtilities">
<HintPath>E:\Work\Core\Simple-FR\Simple\SimpleLite\bin\Debug\LessokajiWeaverUtilities.dll</HintPath>
</Reference>
</ItemGroup>
<!-- NuGetVDA5050/MQTT 协议栈所需 -->
<ItemGroup>
@@ -63,5 +40,28 @@
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="Jint" Version="4.6.3" />
</ItemGroup>
<ItemGroup>
<Reference Include="CommonUsage">
<HintPath>$(StandRefDir)CommonUsage.dll</HintPath>
</Reference>
<Reference Include="CycleGUI">
<HintPath>$(StandRefDir)CycleGUI.dll</HintPath>
</Reference>
<Reference Include="LessokajiWeaverUtilities">
<HintPath>$(StandRefDir)LessokajiWeaverUtilities.dll</HintPath>
</Reference>
<Reference Include="MDCSToolBox">
<HintPath>$(StandRefDir)MDCSToolBox.dll</HintPath>
</Reference>
<Reference Include="SimpleCore">
<HintPath>$(StandRefDir)SimpleCore.dll</HintPath>
</Reference>
<Reference Include="SimpleLite">
<HintPath>$(StandSimpleLiteDir)SimpleLite.dll</HintPath>
</Reference>
<Reference Include="Topaz">
<HintPath>$(StandRefDir)Topaz.dll</HintPath>
</Reference>
</ItemGroup>
</Project>
@@ -24,9 +24,9 @@ namespace StandardScene.CarTypes
version = _protocolVersion,
manufacturer = _manufacturer,
serialNumber = _serialNumber,
actions = new List<actionState>
actions = new List<action>
{
new actionState
new action
{
actionId = actionId,
actionType = actionType,
@@ -30,23 +30,23 @@
</ItemGroup>
<ItemGroup>
<Reference Include="SimpleLite">
<HintPath>E:\Work\Core\Simple-FR\Simple\SimpleLite\bin\Debug\SimpleLite.dll</HintPath>
</Reference>
<Reference Include="SimpleCore">
<HintPath>E:\Work\Core\Simple-FR\Simple\SimpleCore\bin\Debug\netstandard2.0\SimpleCore.dll</HintPath>
</Reference>
<Reference Include="CommonUsage">
<HintPath>D:\MDCS\Dependencies\Commons\CommonUsage.dll</HintPath>
</Reference>
<Reference Include="MDCSToolBox">
<HintPath>D:\MDCS\Dependencies\Commons\MDCSToolBox.dll</HintPath>
</Reference>
<Reference Include="Topaz">
<HintPath>E:\Work\Core\Simple-FR\Simple\tools\Topaz.dll</HintPath>
<HintPath>$(StandRefDir)CommonUsage.dll</HintPath>
</Reference>
<Reference Include="LessokajiWeaverUtilities">
<HintPath>E:\Work\Core\Simple-FR\Simple\SimpleLite\bin\Debug\LessokajiWeaverUtilities.dll</HintPath>
<HintPath>$(StandRefDir)LessokajiWeaverUtilities.dll</HintPath>
</Reference>
<Reference Include="MDCSToolBox">
<HintPath>$(StandRefDir)MDCSToolBox.dll</HintPath>
</Reference>
<Reference Include="SimpleCore">
<HintPath>$(StandRefDir)SimpleCore.dll</HintPath>
</Reference>
<Reference Include="SimpleLite">
<HintPath>$(StandSimpleLiteDir)SimpleLite.dll</HintPath>
</Reference>
<Reference Include="Topaz">
<HintPath>$(StandRefDir)Topaz.dll</HintPath>
</Reference>
</ItemGroup>
@@ -0,0 +1,47 @@
[
{
"Sit": 98,
"SignalType": "JGControl",
"JgName": "上线机构1",
"DockId": "dock1",
"Action": "RequestIn"
},
{
"Sit": 100,
"SignalType": "JGControl",
"JgName": "上线机构1",
"DockId": "dock1",
"Action": "Arrived"
},
{
"Sit": 108,
"SignalType": "JGControl",
"JgName": "上线机构1",
"DockId": "dock2",
"Action": "RequestIn"
},
{
"Sit": 199,
"SignalType": "JGControl",
"JgName": "下线机构1",
"DockId": "dock1",
"Action": "RequestIn"
},
{
"Sit": 201,
"SignalType": "PointControl",
"JgName": "下线机构1",
"DockId": "dock1",
"Action": "UP1"
},
{
"Sit": 50,
"SignalType": "TimedCharging",
"ExtraParams": "300;95;20"
},
{
"Sit": 60,
"SignalType": "RateFlowControl",
"ExtraParams": "缓存区;A;2;60,61,62"
}
]
@@ -0,0 +1,134 @@
[
{
"TriggerSit": 4,
"AreaName": "路口中",
"CompatGroup": "NS-W",
"Priority": 10,
"WaitSeconds": 12,
"ControlArea": "5,104,100,103,6",
"ControlCapacity": 1,
"ControlReleaseStartStop": 11,
"IsUse": true
},
{
"TriggerSit": 26,
"AreaName": "路口中",
"CompatGroup": "NS-E",
"Priority": 10,
"WaitSeconds": 12,
"ControlArea": "6,103,100,104,5",
"ControlCapacity": 1,
"ControlReleaseStartStop": 11,
"IsUse": true
},
{
"TriggerSit": 38,
"AreaName": "路口中",
"CompatGroup": "EW-N",
"Priority": 10,
"WaitSeconds": 12,
"ControlArea": "27,101,100,102,28",
"ControlCapacity": 1,
"ControlReleaseStartStop": 11,
"IsUse": true
},
{
"TriggerSit": 45,
"AreaName": "路口中",
"CompatGroup": "EW-S",
"Priority": 10,
"WaitSeconds": 12,
"ControlArea": "28,102,100,101,27",
"ControlCapacity": 1,
"ControlReleaseStartStop": 11,
"IsUse": true
},
{
"TriggerSit": 1,
"AreaName": "西南角",
"CompatGroup": "SW-W",
"Priority": 10,
"WaitSeconds": 12,
"ControlArea": "2",
"ControlCapacity": 1,
"ControlReleaseStartStop": 11,
"IsUse": true
},
{
"TriggerSit": 47,
"AreaName": "西南角",
"CompatGroup": "SW-S",
"Priority": 10,
"WaitSeconds": 12,
"ControlArea": "2",
"ControlCapacity": 1,
"ControlReleaseStartStop": 11,
"IsUse": true
},
{
"TriggerSit": 8,
"AreaName": "西北角",
"CompatGroup": "NW-W",
"Priority": 10,
"WaitSeconds": 12,
"ControlArea": "9",
"ControlCapacity": 1,
"ControlReleaseStartStop": 11,
"IsUse": true
},
{
"TriggerSit": 36,
"AreaName": "西北角",
"CompatGroup": "NW-N",
"Priority": 10,
"WaitSeconds": 12,
"ControlArea": "9",
"ControlCapacity": 1,
"ControlReleaseStartStop": 11,
"IsUse": true
},
{
"TriggerSit": 16,
"AreaName": "东南角",
"CompatGroup": "SE-E",
"Priority": 10,
"WaitSeconds": 12,
"ControlArea": "17",
"ControlCapacity": 1,
"ControlReleaseStartStop": 11,
"IsUse": true
},
{
"TriggerSit": 43,
"AreaName": "东南角",
"CompatGroup": "SE-S",
"Priority": 10,
"WaitSeconds": 12,
"ControlArea": "17",
"ControlCapacity": 1,
"ControlReleaseStartStop": 11,
"IsUse": true
},
{
"TriggerSit": 23,
"AreaName": "东北角",
"CompatGroup": "NE-E",
"Priority": 10,
"WaitSeconds": 12,
"ControlArea": "24",
"ControlCapacity": 1,
"ControlReleaseStartStop": 11,
"IsUse": true
},
{
"TriggerSit": 30,
"AreaName": "东北角",
"CompatGroup": "NE-N",
"Priority": 10,
"WaitSeconds": 12,
"ControlArea": "24",
"ControlCapacity": 1,
"ControlReleaseStartStop": 11,
"IsUse": true
}
]
@@ -0,0 +1,134 @@
[
{
"TriggerSit": 4,
"AreaName": "路口中",
"CompatGroup": "NS-W",
"Priority": 10,
"WaitSeconds": 12,
"ControlArea": "5,104,100,103,6",
"ControlCapacity": 1,
"ControlReleaseStartStop": 11,
"IsUse": true
},
{
"TriggerSit": 26,
"AreaName": "路口中",
"CompatGroup": "NS-E",
"Priority": 10,
"WaitSeconds": 12,
"ControlArea": "6,103,100,104,5",
"ControlCapacity": 1,
"ControlReleaseStartStop": 11,
"IsUse": true
},
{
"TriggerSit": 38,
"AreaName": "路口中",
"CompatGroup": "EW-N",
"Priority": 10,
"WaitSeconds": 12,
"ControlArea": "27,101,100,102,28",
"ControlCapacity": 1,
"ControlReleaseStartStop": 11,
"IsUse": true
},
{
"TriggerSit": 45,
"AreaName": "路口中",
"CompatGroup": "EW-S",
"Priority": 10,
"WaitSeconds": 12,
"ControlArea": "28,102,100,101,27",
"ControlCapacity": 1,
"ControlReleaseStartStop": 11,
"IsUse": true
},
{
"TriggerSit": 1,
"AreaName": "西南角",
"CompatGroup": "SW-W",
"Priority": 10,
"WaitSeconds": 12,
"ControlArea": "2",
"ControlCapacity": 1,
"ControlReleaseStartStop": 11,
"IsUse": true
},
{
"TriggerSit": 47,
"AreaName": "西南角",
"CompatGroup": "SW-S",
"Priority": 10,
"WaitSeconds": 12,
"ControlArea": "2",
"ControlCapacity": 1,
"ControlReleaseStartStop": 11,
"IsUse": true
},
{
"TriggerSit": 8,
"AreaName": "西北角",
"CompatGroup": "NW-W",
"Priority": 10,
"WaitSeconds": 12,
"ControlArea": "9",
"ControlCapacity": 1,
"ControlReleaseStartStop": 11,
"IsUse": true
},
{
"TriggerSit": 36,
"AreaName": "西北角",
"CompatGroup": "NW-N",
"Priority": 10,
"WaitSeconds": 12,
"ControlArea": "9",
"ControlCapacity": 1,
"ControlReleaseStartStop": 11,
"IsUse": true
},
{
"TriggerSit": 16,
"AreaName": "东南角",
"CompatGroup": "SE-E",
"Priority": 10,
"WaitSeconds": 12,
"ControlArea": "17",
"ControlCapacity": 1,
"ControlReleaseStartStop": 11,
"IsUse": true
},
{
"TriggerSit": 43,
"AreaName": "东南角",
"CompatGroup": "SE-S",
"Priority": 10,
"WaitSeconds": 12,
"ControlArea": "17",
"ControlCapacity": 1,
"ControlReleaseStartStop": 11,
"IsUse": true
},
{
"TriggerSit": 23,
"AreaName": "东北角",
"CompatGroup": "NE-E",
"Priority": 10,
"WaitSeconds": 12,
"ControlArea": "24",
"ControlCapacity": 1,
"ControlReleaseStartStop": 11,
"IsUse": true
},
{
"TriggerSit": 30,
"AreaName": "东北角",
"CompatGroup": "NE-N",
"Priority": 10,
"WaitSeconds": 12,
"ControlArea": "24",
"ControlCapacity": 1,
"ControlReleaseStartStop": 11,
"IsUse": true
}
]

Some files were not shown because too many files have changed in this diff Show More