2026-06-10 11:16:46 +08:00
|
|
|
|
using System.IO.Ports;
|
|
|
|
|
|
using System.Net.Http;
|
|
|
|
|
|
using System.Text;
|
|
|
|
|
|
using System.Text.Json;
|
2026-06-02 10:30:05 +08:00
|
|
|
|
using Microsoft.Extensions.Options;
|
|
|
|
|
|
using YD_TrainingCompetition_Api.Options;
|
|
|
|
|
|
|
|
|
|
|
|
namespace YD_TrainingCompetition_Api.Serial;
|
|
|
|
|
|
|
2026-06-10 15:54:30 +08:00
|
|
|
|
// SerialGateway 封装了 2.4G 网关串口的最底层读写能力。
|
|
|
|
|
|
// 这个类只负责“打开串口 / 关闭串口 / 收字节 / 发字节”,
|
|
|
|
|
|
// 不关心具体协议语义,协议解析由上层 worker 处理。
|
2026-06-02 10:30:05 +08:00
|
|
|
|
public sealed class SerialGateway : ISerialGateway
|
|
|
|
|
|
{
|
2026-06-10 11:16:46 +08:00
|
|
|
|
private static readonly HttpClient DebugClient = new();
|
2026-06-10 15:54:30 +08:00
|
|
|
|
private readonly object _serialLock = new();
|
2026-06-02 10:30:05 +08:00
|
|
|
|
private readonly SerialPort _serialPort;
|
|
|
|
|
|
|
2026-06-10 15:54:30 +08:00
|
|
|
|
// 构造时按配置初始化串口参数,并注册 DataReceived 事件。
|
|
|
|
|
|
// 事件里只读取原始字节并向上抛出,不在这里做业务判断。
|
2026-06-02 10:30:05 +08:00
|
|
|
|
public SerialGateway(IOptions<Serial24GOptions> options)
|
|
|
|
|
|
{
|
|
|
|
|
|
var cfg = options.Value;
|
|
|
|
|
|
_serialPort = new SerialPort
|
|
|
|
|
|
{
|
|
|
|
|
|
PortName = cfg.PortName,
|
|
|
|
|
|
BaudRate = cfg.BaudRate,
|
|
|
|
|
|
DataBits = cfg.DataBits,
|
|
|
|
|
|
StopBits = StopBits.One,
|
|
|
|
|
|
Parity = Parity.None,
|
|
|
|
|
|
ReadBufferSize = cfg.ReadBufferSize,
|
|
|
|
|
|
WriteBufferSize = cfg.WriteBufferSize
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
_serialPort.DataReceived += (_, _) =>
|
|
|
|
|
|
{
|
|
|
|
|
|
try
|
|
|
|
|
|
{
|
2026-06-10 15:54:30 +08:00
|
|
|
|
// 一次事件可能读到半帧、整帧或多帧拼在一起的数据,
|
|
|
|
|
|
// 所以这里直接把当前缓冲区可读字节全部取出,交给上层做组帧。
|
|
|
|
|
|
byte[]? buf = null;
|
|
|
|
|
|
var len = 0;
|
|
|
|
|
|
lock (_serialLock)
|
2026-06-02 10:30:05 +08:00
|
|
|
|
{
|
2026-06-10 15:54:30 +08:00
|
|
|
|
len = _serialPort.BytesToRead;
|
|
|
|
|
|
if (len <= 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-06-02 10:30:05 +08:00
|
|
|
|
|
2026-06-10 15:54:30 +08:00
|
|
|
|
buf = new byte[len];
|
|
|
|
|
|
_serialPort.Read(buf, 0, len);
|
|
|
|
|
|
}
|
2026-06-10 11:16:46 +08:00
|
|
|
|
#region debug-point A:serial-receive
|
|
|
|
|
|
_ = DebugReportAsync("A", "SerialGateway.DataReceived", "[DEBUG] serial bytes received", new
|
|
|
|
|
|
{
|
|
|
|
|
|
port = _serialPort.PortName,
|
|
|
|
|
|
length = len,
|
|
|
|
|
|
hex = Convert.ToHexString(buf)
|
|
|
|
|
|
});
|
|
|
|
|
|
#endregion
|
2026-06-02 10:30:05 +08:00
|
|
|
|
DataReceived?.Invoke(this, buf);
|
|
|
|
|
|
}
|
|
|
|
|
|
catch
|
|
|
|
|
|
{
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public event EventHandler<byte[]>? DataReceived;
|
|
|
|
|
|
public bool IsOpen => _serialPort.IsOpen;
|
|
|
|
|
|
public string? CurrentPort => _serialPort.IsOpen ? _serialPort.PortName : null;
|
|
|
|
|
|
|
2026-06-10 15:54:30 +08:00
|
|
|
|
// 返回系统当前可见的串口列表,按名字排序后给前端展示。
|
2026-06-02 10:30:05 +08:00
|
|
|
|
public string[] GetAvailablePorts() => SerialPort.GetPortNames().OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToArray();
|
|
|
|
|
|
|
|
|
|
|
|
public Task OpenAsync(string portName, CancellationToken cancellationToken = default)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (_serialPort.IsOpen)
|
|
|
|
|
|
{
|
2026-06-10 15:54:30 +08:00
|
|
|
|
// 切换串口前先关闭旧连接,避免 SerialPort 对象状态残留。
|
2026-06-02 10:30:05 +08:00
|
|
|
|
_serialPort.Close();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_serialPort.PortName = portName;
|
|
|
|
|
|
_serialPort.Open();
|
2026-06-10 15:54:30 +08:00
|
|
|
|
// 对齐旧版 Form1:尽量按整帧触发接收,减少高频碎片事件对控制命令时序的干扰。
|
|
|
|
|
|
_serialPort.ReceivedBytesThreshold = 12;
|
2026-06-10 11:16:46 +08:00
|
|
|
|
#region debug-point A:serial-open
|
|
|
|
|
|
_ = DebugReportAsync("A", "SerialGateway.OpenAsync", "[DEBUG] serial opened", new
|
|
|
|
|
|
{
|
|
|
|
|
|
port = _serialPort.PortName,
|
|
|
|
|
|
baudRate = _serialPort.BaudRate,
|
|
|
|
|
|
dataBits = _serialPort.DataBits,
|
|
|
|
|
|
readBufferSize = _serialPort.ReadBufferSize,
|
|
|
|
|
|
writeBufferSize = _serialPort.WriteBufferSize
|
|
|
|
|
|
});
|
|
|
|
|
|
#endregion
|
2026-06-02 10:30:05 +08:00
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public Task CloseAsync(CancellationToken cancellationToken = default)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (_serialPort.IsOpen)
|
|
|
|
|
|
{
|
|
|
|
|
|
_serialPort.Close();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-10 15:54:30 +08:00
|
|
|
|
// 下载本场前会清空输入缓存,避免把旧应答或残留半帧误当成当前操作结果。
|
2026-06-10 11:16:46 +08:00
|
|
|
|
public void ClearInputBuffer()
|
|
|
|
|
|
{
|
2026-06-10 15:54:30 +08:00
|
|
|
|
lock (_serialLock)
|
2026-06-10 11:16:46 +08:00
|
|
|
|
{
|
2026-06-10 15:54:30 +08:00
|
|
|
|
if (_serialPort.IsOpen)
|
|
|
|
|
|
{
|
|
|
|
|
|
_serialPort.DiscardInBuffer();
|
|
|
|
|
|
}
|
2026-06-10 11:16:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-02 10:30:05 +08:00
|
|
|
|
public Task SendAsync(byte[] data, CancellationToken cancellationToken = default)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!_serialPort.IsOpen)
|
|
|
|
|
|
{
|
|
|
|
|
|
throw new InvalidOperationException("Serial port is not open");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-10 15:54:30 +08:00
|
|
|
|
// 这里发送的是已经由上层构造好的完整协议帧。
|
2026-06-10 11:16:46 +08:00
|
|
|
|
#region debug-point B:serial-send
|
|
|
|
|
|
_ = DebugReportAsync("B", "SerialGateway.SendAsync", "[DEBUG] serial bytes sent", new
|
|
|
|
|
|
{
|
|
|
|
|
|
port = _serialPort.PortName,
|
|
|
|
|
|
length = data.Length,
|
|
|
|
|
|
hex = Convert.ToHexString(data)
|
|
|
|
|
|
});
|
|
|
|
|
|
#endregion
|
2026-06-10 15:54:30 +08:00
|
|
|
|
if (data.Length >= 2 && data[1] == 0x30)
|
|
|
|
|
|
{
|
|
|
|
|
|
#region debug-point A:stop-frame-sent
|
|
|
|
|
|
_ = DebugTimeupProjectionReportAsync("A", "SerialGateway.SendAsync", "[DEBUG] stop frame sent to serial", new
|
|
|
|
|
|
{
|
|
|
|
|
|
port = _serialPort.PortName,
|
|
|
|
|
|
length = data.Length,
|
|
|
|
|
|
hex = Convert.ToHexString(data)
|
|
|
|
|
|
});
|
|
|
|
|
|
#endregion
|
|
|
|
|
|
}
|
|
|
|
|
|
if (data.Length >= 2 && (data[1] == 0x06 || data[1] == 0x10 || data[1] == 0x20 || data[1] == 0x30 || data[1] == 0x0A))
|
|
|
|
|
|
{
|
|
|
|
|
|
#region debug-point E:countdown-control-frame
|
|
|
|
|
|
_ = DebugCountdownStopCounterReportAsync("E", "SerialGateway.SendAsync", "[DEBUG] countdown control frame sent", new
|
|
|
|
|
|
{
|
|
|
|
|
|
port = _serialPort.PortName,
|
|
|
|
|
|
command = data[1],
|
|
|
|
|
|
subCommand = data.Length >= 3 ? data[2] : (byte)0,
|
|
|
|
|
|
data3 = data.Length >= 4 ? data[3] : (byte)0,
|
|
|
|
|
|
data4 = data.Length >= 5 ? data[4] : (byte)0,
|
|
|
|
|
|
length = data.Length,
|
|
|
|
|
|
hex = Convert.ToHexString(data)
|
|
|
|
|
|
});
|
|
|
|
|
|
#endregion
|
|
|
|
|
|
|
|
|
|
|
|
#region debug-point S4:stop-reset-control-frame
|
|
|
|
|
|
_ = DebugStopResetControlReportAsync("C", "SerialGateway.SendAsync", "[DEBUG] stop/reset control frame sent", new
|
|
|
|
|
|
{
|
|
|
|
|
|
port = _serialPort.PortName,
|
|
|
|
|
|
command = data[1],
|
|
|
|
|
|
subCommand = data.Length >= 3 ? data[2] : (byte)0,
|
|
|
|
|
|
data3 = data.Length >= 4 ? data[3] : (byte)0,
|
|
|
|
|
|
data4 = data.Length >= 5 ? data[4] : (byte)0,
|
|
|
|
|
|
length = data.Length,
|
|
|
|
|
|
hex = Convert.ToHexString(data)
|
|
|
|
|
|
});
|
|
|
|
|
|
#endregion
|
|
|
|
|
|
}
|
|
|
|
|
|
lock (_serialLock)
|
|
|
|
|
|
{
|
|
|
|
|
|
_serialPort.Write(data, 0, data.Length);
|
|
|
|
|
|
}
|
2026-06-02 10:30:05 +08:00
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
|
}
|
2026-06-10 11:16:46 +08:00
|
|
|
|
|
2026-06-10 15:54:30 +08:00
|
|
|
|
// 调试阶段把关键串口事件上报到独立调试服务器,便于对照旧版行为。
|
|
|
|
|
|
// 如果调试服务器不存在,这里会静默失败,不影响正常业务收发。
|
2026-06-10 11:16:46 +08:00
|
|
|
|
private static async Task DebugReportAsync(string hypothesisId, string location, string msg, object data)
|
|
|
|
|
|
{
|
|
|
|
|
|
try
|
|
|
|
|
|
{
|
|
|
|
|
|
var url = "http://127.0.0.1:7777/event";
|
|
|
|
|
|
var sessionId = "serial-gateway-download";
|
|
|
|
|
|
var envPath = Path.Combine(Directory.GetCurrentDirectory(), ".dbg", "serial-gateway-download.env");
|
|
|
|
|
|
if (File.Exists(envPath))
|
|
|
|
|
|
{
|
|
|
|
|
|
foreach (var line in await File.ReadAllLinesAsync(envPath))
|
2026-06-10 15:54:30 +08:00
|
|
|
|
{
|
|
|
|
|
|
if (line.StartsWith("DEBUG_SERVER_URL=", StringComparison.Ordinal))
|
|
|
|
|
|
{
|
|
|
|
|
|
url = line["DEBUG_SERVER_URL=".Length..].Trim();
|
|
|
|
|
|
}
|
|
|
|
|
|
else if (line.StartsWith("DEBUG_SESSION_ID=", StringComparison.Ordinal))
|
|
|
|
|
|
{
|
|
|
|
|
|
sessionId = line["DEBUG_SESSION_ID=".Length..].Trim();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var payload = JsonSerializer.Serialize(new
|
|
|
|
|
|
{
|
|
|
|
|
|
sessionId,
|
|
|
|
|
|
runId = "pre-fix",
|
|
|
|
|
|
hypothesisId,
|
|
|
|
|
|
location,
|
|
|
|
|
|
msg,
|
|
|
|
|
|
data,
|
|
|
|
|
|
ts = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
|
|
|
|
|
|
});
|
|
|
|
|
|
using var content = new StringContent(payload, Encoding.UTF8, "application/json");
|
|
|
|
|
|
await DebugClient.PostAsync(url, content);
|
|
|
|
|
|
}
|
|
|
|
|
|
catch
|
|
|
|
|
|
{
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static async Task DebugTimeupProjectionReportAsync(string hypothesisId, string location, string msg, object data)
|
|
|
|
|
|
{
|
|
|
|
|
|
try
|
|
|
|
|
|
{
|
|
|
|
|
|
var url = "http://127.0.0.1:7777/event";
|
|
|
|
|
|
var sessionId = "timeup-stop-projection";
|
|
|
|
|
|
var envPath = Path.Combine(Directory.GetCurrentDirectory(), ".dbg", "timeup-stop-projection.env");
|
|
|
|
|
|
if (File.Exists(envPath))
|
|
|
|
|
|
{
|
|
|
|
|
|
foreach (var line in await File.ReadAllLinesAsync(envPath))
|
|
|
|
|
|
{
|
|
|
|
|
|
if (line.StartsWith("DEBUG_SERVER_URL=", StringComparison.Ordinal))
|
|
|
|
|
|
{
|
|
|
|
|
|
url = line["DEBUG_SERVER_URL=".Length..].Trim();
|
|
|
|
|
|
}
|
|
|
|
|
|
else if (line.StartsWith("DEBUG_SESSION_ID=", StringComparison.Ordinal))
|
|
|
|
|
|
{
|
|
|
|
|
|
sessionId = line["DEBUG_SESSION_ID=".Length..].Trim();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var payload = JsonSerializer.Serialize(new
|
|
|
|
|
|
{
|
|
|
|
|
|
sessionId,
|
|
|
|
|
|
runId = "pre-fix",
|
|
|
|
|
|
hypothesisId,
|
|
|
|
|
|
location,
|
|
|
|
|
|
msg,
|
|
|
|
|
|
data,
|
|
|
|
|
|
ts = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
|
|
|
|
|
|
});
|
|
|
|
|
|
using var content = new StringContent(payload, Encoding.UTF8, "application/json");
|
|
|
|
|
|
await DebugClient.PostAsync(url, content);
|
|
|
|
|
|
}
|
|
|
|
|
|
catch
|
|
|
|
|
|
{
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static async Task DebugCountdownStopCounterReportAsync(string hypothesisId, string location, string msg, object data)
|
|
|
|
|
|
{
|
|
|
|
|
|
try
|
|
|
|
|
|
{
|
|
|
|
|
|
var url = "http://127.0.0.1:7777/event";
|
|
|
|
|
|
var sessionId = "countdown-stop-counter";
|
|
|
|
|
|
var envPath = Path.Combine(Directory.GetCurrentDirectory(), ".dbg", "countdown-stop-counter.env");
|
|
|
|
|
|
if (File.Exists(envPath))
|
|
|
|
|
|
{
|
|
|
|
|
|
foreach (var line in await File.ReadAllLinesAsync(envPath))
|
|
|
|
|
|
{
|
|
|
|
|
|
if (line.StartsWith("DEBUG_SERVER_URL=", StringComparison.Ordinal))
|
|
|
|
|
|
{
|
|
|
|
|
|
url = line["DEBUG_SERVER_URL=".Length..].Trim();
|
|
|
|
|
|
}
|
|
|
|
|
|
else if (line.StartsWith("DEBUG_SESSION_ID=", StringComparison.Ordinal))
|
|
|
|
|
|
{
|
|
|
|
|
|
sessionId = line["DEBUG_SESSION_ID=".Length..].Trim();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var payload = JsonSerializer.Serialize(new
|
|
|
|
|
|
{
|
|
|
|
|
|
sessionId,
|
|
|
|
|
|
runId = "pre-fix",
|
|
|
|
|
|
hypothesisId,
|
|
|
|
|
|
location,
|
|
|
|
|
|
msg,
|
|
|
|
|
|
data,
|
|
|
|
|
|
ts = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
|
|
|
|
|
|
});
|
|
|
|
|
|
using var content = new StringContent(payload, Encoding.UTF8, "application/json");
|
|
|
|
|
|
await DebugClient.PostAsync(url, content);
|
|
|
|
|
|
}
|
|
|
|
|
|
catch
|
|
|
|
|
|
{
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private static async Task DebugStopResetControlReportAsync(string hypothesisId, string location, string msg, object data)
|
|
|
|
|
|
{
|
|
|
|
|
|
try
|
|
|
|
|
|
{
|
|
|
|
|
|
var url = "http://127.0.0.1:7777/event";
|
|
|
|
|
|
var sessionId = "stop-reset-control";
|
|
|
|
|
|
var envPath = Path.Combine(Directory.GetCurrentDirectory(), ".dbg", "stop-reset-control.env");
|
|
|
|
|
|
if (File.Exists(envPath))
|
|
|
|
|
|
{
|
|
|
|
|
|
foreach (var line in await File.ReadAllLinesAsync(envPath))
|
2026-06-10 11:16:46 +08:00
|
|
|
|
{
|
|
|
|
|
|
if (line.StartsWith("DEBUG_SERVER_URL=", StringComparison.Ordinal))
|
|
|
|
|
|
{
|
|
|
|
|
|
url = line["DEBUG_SERVER_URL=".Length..].Trim();
|
|
|
|
|
|
}
|
|
|
|
|
|
else if (line.StartsWith("DEBUG_SESSION_ID=", StringComparison.Ordinal))
|
|
|
|
|
|
{
|
|
|
|
|
|
sessionId = line["DEBUG_SESSION_ID=".Length..].Trim();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var payload = JsonSerializer.Serialize(new
|
|
|
|
|
|
{
|
|
|
|
|
|
sessionId,
|
|
|
|
|
|
runId = "pre-fix",
|
|
|
|
|
|
hypothesisId,
|
|
|
|
|
|
location,
|
|
|
|
|
|
msg,
|
|
|
|
|
|
data,
|
|
|
|
|
|
ts = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
|
|
|
|
|
|
});
|
|
|
|
|
|
using var content = new StringContent(payload, Encoding.UTF8, "application/json");
|
|
|
|
|
|
await DebugClient.PostAsync(url, content);
|
|
|
|
|
|
}
|
|
|
|
|
|
catch
|
|
|
|
|
|
{
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-02 10:30:05 +08:00
|
|
|
|
}
|