YD_TrainingCompetition_Api/Services/SerialIngestWorker.cs
2026-06-10 15:54:30 +08:00

213 lines
8.2 KiB
C#

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<SerialIngestWorker> _logger;
private readonly Serial24GOptions _options;
private readonly List<byte> _buffer = [];
private readonly Channel<byte[]> _chunks = Channel.CreateUnbounded<byte[]>();
public SerialIngestWorker(
Serial.ISerialGateway serialGateway,
IJumpProtocolParser parser,
IDeviceStateStore store,
IRealtimeNotifier notifier,
ICompetitionSessionService competition,
IParticipantDirectory directory,
IOptions<Serial24GOptions> options,
ILogger<SerialIngestWorker> 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
{
}
}
}