213 lines
8.2 KiB
C#
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
|
|
{
|
|
}
|
|
}
|
|
}
|