using System.IO.Ports; using System.Net.Http; using System.Text; using System.Text.Json; using Microsoft.Extensions.Options; using YD_TrainingCompetition_Api.Options; namespace YD_TrainingCompetition_Api.Serial; // SerialGateway 封装了 2.4G 网关串口的最底层读写能力。 // 这个类只负责“打开串口 / 关闭串口 / 收字节 / 发字节”, // 不关心具体协议语义,协议解析由上层 worker 处理。 public sealed class SerialGateway : ISerialGateway { private static readonly HttpClient DebugClient = new(); private readonly object _serialLock = new(); private readonly SerialPort _serialPort; // 构造时按配置初始化串口参数,并注册 DataReceived 事件。 // 事件里只读取原始字节并向上抛出,不在这里做业务判断。 public SerialGateway(IOptions 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 { // 一次事件可能读到半帧、整帧或多帧拼在一起的数据, // 所以这里直接把当前缓冲区可读字节全部取出,交给上层做组帧。 byte[]? buf = null; var len = 0; lock (_serialLock) { len = _serialPort.BytesToRead; if (len <= 0) { return; } buf = new byte[len]; _serialPort.Read(buf, 0, len); } #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 DataReceived?.Invoke(this, buf); } catch { } }; } public event EventHandler? DataReceived; public bool IsOpen => _serialPort.IsOpen; public string? CurrentPort => _serialPort.IsOpen ? _serialPort.PortName : null; // 返回系统当前可见的串口列表,按名字排序后给前端展示。 public string[] GetAvailablePorts() => SerialPort.GetPortNames().OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToArray(); public Task OpenAsync(string portName, CancellationToken cancellationToken = default) { if (_serialPort.IsOpen) { // 切换串口前先关闭旧连接,避免 SerialPort 对象状态残留。 _serialPort.Close(); } _serialPort.PortName = portName; _serialPort.Open(); // 对齐旧版 Form1:尽量按整帧触发接收,减少高频碎片事件对控制命令时序的干扰。 _serialPort.ReceivedBytesThreshold = 12; #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 return Task.CompletedTask; } public Task CloseAsync(CancellationToken cancellationToken = default) { if (_serialPort.IsOpen) { _serialPort.Close(); } return Task.CompletedTask; } // 下载本场前会清空输入缓存,避免把旧应答或残留半帧误当成当前操作结果。 public void ClearInputBuffer() { lock (_serialLock) { if (_serialPort.IsOpen) { _serialPort.DiscardInBuffer(); } } } public Task SendAsync(byte[] data, CancellationToken cancellationToken = default) { if (!_serialPort.IsOpen) { throw new InvalidOperationException("Serial port is not open"); } // 这里发送的是已经由上层构造好的完整协议帧。 #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 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); } return Task.CompletedTask; } // 调试阶段把关键串口事件上报到独立调试服务器,便于对照旧版行为。 // 如果调试服务器不存在,这里会静默失败,不影响正常业务收发。 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)) { 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)) { 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 { } } }