using System.Threading.Channels; using System.Net.Http; using System.Text; using System.Text.Json; using Microsoft.Extensions.Options; using YD_TrainingCompetition_Api.Competition; using YD_TrainingCompetition_Api.Options; using YD_TrainingCompetition_Api.Protocols; namespace YD_TrainingCompetition_Api.Services; // SerialIngestWorker 负责把串口原始字节流转换成系统可用的设备状态。 // 它位于“串口网关”和“比赛会话/前端推送”之间,是整个 2.4G 数据链路的中枢。 public sealed class SerialIngestWorker : BackgroundService { private static readonly HttpClient DebugClient = new(); private readonly Serial.ISerialGateway _serialGateway; private readonly IJumpProtocolParser _parser; private readonly IDeviceStateStore _store; private readonly IRealtimeNotifier _notifier; private readonly ICompetitionSessionService _competition; private readonly IParticipantDirectory _directory; private readonly ILogger _logger; private readonly Serial24GOptions _options; private readonly List _buffer = []; private readonly Channel _chunks = Channel.CreateUnbounded(); public SerialIngestWorker( Serial.ISerialGateway serialGateway, IJumpProtocolParser parser, IDeviceStateStore store, IRealtimeNotifier notifier, ICompetitionSessionService competition, IParticipantDirectory directory, IOptions options, ILogger logger) { _serialGateway = serialGateway; _parser = parser; _store = store; _notifier = notifier; _competition = competition; _directory = directory; _logger = logger; _options = options.Value; // 串口回调线程只做快速入队,避免在事件线程里进行复杂解析。 _serialGateway.DataReceived += (_, chunk) => _chunks.Writer.TryWrite(chunk); } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { try { // 服务启动时优先按配置尝试自动打开串口; // 即使失败,系统也仍可正常启动,之后通过 API 手工打开。 await _serialGateway.OpenAsync(_options.PortName, stoppingToken); _logger.LogInformation("2.4G serial opened on {Port}", _options.PortName); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to open serial port {Port}. You can open it later from API.", _options.PortName); } // 统一在后台循环中串行消费所有字节块,便于维护一个连续缓冲区。 await foreach (var chunk in _chunks.Reader.ReadAllAsync(stoppingToken)) { await ProcessChunkAsync(chunk, stoppingToken); } } private async Task ProcessChunkAsync(byte[] chunk, CancellationToken cancellationToken) { #region debug-point C:chunk-received _ = DebugReportAsync("C", "SerialIngestWorker.ProcessChunkAsync", "[DEBUG] ingest chunk received", new { chunkLength = chunk.Length, bufferLength = _buffer.Count, hex = Convert.ToHexString(chunk) }); #endregion _buffer.AddRange(chunk); // 一个串口事件可能只收到半帧,也可能收到多帧粘连的数据, // 因此要在共享缓冲区里持续找帧头并按固定长度截取。 while (_buffer.Count >= _options.FrameLength) { var start = _buffer.IndexOf(0xAA); if (start < 0) { // 没有找到帧头,说明缓冲区内容不可用,直接清空。 _buffer.Clear(); return; } if (_buffer.Count < start + _options.FrameLength) { if (start > 0) { // 丢掉帧头之前的噪声字节,只保留可能的半帧。 _buffer.RemoveRange(0, start); } return; } var frame = _buffer.Skip(start).Take(_options.FrameLength).ToArray(); _buffer.RemoveRange(0, start + _options.FrameLength); if (!_parser.TryParse(frame, out var parsed) || parsed is null) { #region debug-point C:parse-failed _ = DebugReportAsync("C", "SerialIngestWorker.ProcessChunkAsync", "[DEBUG] frame parse failed", new { frameLength = frame.Length, expectedLength = _options.FrameLength, hex = Convert.ToHexString(frame) }); #endregion continue; } #region debug-point C:parse-succeeded _ = DebugReportAsync("C", "SerialIngestWorker.ProcessChunkAsync", "[DEBUG] frame parse succeeded", new { frameLength = frame.Length, hex = Convert.ToHexString(frame), deviceNo = parsed.DeviceNo, counter = parsed.Counter, battery = parsed.Battery, mode = parsed.Mode, remainingSeconds = parsed.RemainingSeconds }); #endregion // 旧版桌面程序使用回传帧中的设备号作为当前场次内的槽位索引。 // 这里保持相同的编号语义,确保成绩能映射回正确选手。 var deviceId = parsed.DeviceNo.ToString(); var roster = _directory.FindByDeviceId(deviceId); var telemetry = new Models.DeviceTelemetry { DeviceId = deviceId, BsName = $"NO-{parsed.DeviceNo}", AthleteName = roster?.Name, School = roster?.School, Counter = parsed.Counter, ErrorCount = null, Battery = parsed.Battery, Mode = parsed.Mode, RemainingSeconds = parsed.RemainingSeconds, Timestamp = DateTimeOffset.UtcNow, IngestedAt = DateTimeOffset.UtcNow, GatewayId = "serial-2.4g" }; if (!_store.TryUpsert(telemetry, out _)) { // 没有状态变化时不再继续推送,减少无效广播。 continue; } var snapshot = _store.GetByDeviceId(deviceId); if (snapshot is not null) { await _notifier.BroadcastTelemetryAsync(snapshot, cancellationToken); await _competition.ApplyDeviceSnapshotAsync(snapshot, cancellationToken); } } } // 调试会话期间把关键证据上报到调试服务器。 // 失败时静默吞掉异常,避免影响正式收发流程。 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 { } } }