YD_TrainingCompetition_Api/Competition/CompetitionSessionService.cs
2026-06-10 15:54:30 +08:00

1138 lines
43 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using YD_TrainingCompetition_Api.Models;
using YD_TrainingCompetition_Api.Serial;
using YD_TrainingCompetition_Api.Services;
using System.Net.Http;
using System.Text;
using System.Text.Json;
namespace YD_TrainingCompetition_Api.Competition;
// CompetitionSessionService 管理“当前正在进行的比赛会话”。
// 它既保存内存中的场次/选手状态,也负责把关键操作转换成串口命令下发到设备。
public sealed class CompetitionSessionService : ICompetitionSessionService
{
private static readonly HttpClient DebugClient = new();
private readonly object _gate = new();
private readonly IParticipantDirectory _directory;
private readonly ISerialGateway _serial;
private readonly IRealtimeNotifier _notifier;
private MutableCompetitionSession _session = MutableCompetitionSession.Empty();
public CompetitionSessionService(
IParticipantDirectory directory,
ISerialGateway serial,
IRealtimeNotifier notifier)
{
_directory = directory;
_serial = serial;
_notifier = notifier;
}
public CompetitionSessionState GetState()
{
lock (_gate)
{
// 返回不可变快照,避免外部直接改动内部状态。
SyncCurrentRoundRemainingUnsafe();
return _session.ToState();
}
}
public async Task<CompetitionSessionState> CreateAsync(CreateCompetitionSessionRequest request, CancellationToken cancellationToken = default)
{
if (request.Participants is null || request.Participants.Count == 0)
{
throw new InvalidOperationException("participants is required");
}
MutableCompetitionSession next;
lock (_gate)
{
// 新建会话时会完全替换旧会话,并同步刷新当前场次的人员目录。
next = BuildSession(request);
_session = next;
ApplyCurrentRoundDirectoryUnsafe();
}
var state = next.ToState();
await _notifier.BroadcastCompetitionStateAsync(state, cancellationToken);
return state;
}
public async Task<CompetitionSessionState> SelectRoundAsync(int roundIndex, CancellationToken cancellationToken = default)
{
CompetitionSessionState state;
lock (_gate)
{
EnsureLoadedUnsafe();
if (roundIndex < 0 || roundIndex >= _session.RoundParticipantCounts.Count)
{
throw new ArgumentOutOfRangeException(nameof(roundIndex));
}
_session.CurrentRoundIndex = roundIndex;
_session.IsTesting = false;
_session.CountdownStartedAt = null;
_session.CountdownStartRemainingSeconds = null;
_session.RemainingSeconds = _session.TestDurationSeconds;
_session.CountdownEndsAt = null;
_session.PendingAutoStopCommand = false;
_session.UpdatedAt = DateTimeOffset.UtcNow;
// 切场后,串口实时上报的设备号需要重新映射到当前场次名单。
SyncCurrentRoundRemainingUnsafe();
ApplyCurrentRoundDirectoryUnsafe();
state = _session.ToState();
}
await SendSelectRoundCommandAsync(roundIndex, cancellationToken);
await _notifier.BroadcastCompetitionStateAsync(state, cancellationToken);
return state;
}
public Task<CompetitionSessionState> SelectNextRoundAsync(CancellationToken cancellationToken = default)
{
var current = GetState();
var next = Math.Min(current.CurrentRoundIndex + 1, Math.Max(current.TotalRounds - 1, 0));
return SelectRoundAsync(next, cancellationToken);
}
public Task<CompetitionSessionState> SelectPreviousRoundAsync(CancellationToken cancellationToken = default)
{
var current = GetState();
var next = Math.Max(current.CurrentRoundIndex - 1, 0);
return SelectRoundAsync(next, cancellationToken);
}
public async Task<CompetitionSessionState> DownloadCurrentRoundAsync(CancellationToken cancellationToken = default)
{
CompetitionSessionState state;
IReadOnlyList<CompetitionParticipantState> roundParticipants;
int durationSeconds;
int roundIndex;
lock (_gate)
{
EnsureLoadedUnsafe();
roundIndex = _session.CurrentRoundIndex;
roundParticipants = _session.GetRoundParticipants(roundIndex).Select(CloneParticipant).ToArray();
durationSeconds = Math.Max(_session.TestDurationSeconds ?? 0, 0);
state = _session.ToState();
}
#region debug-point D:download-round
_ = DebugReportAsync("D", "CompetitionSessionService.DownloadCurrentRoundAsync", "[DEBUG] download current round requested", new
{
roundIndex,
durationSeconds,
participantCount = roundParticipants.Count,
participants = roundParticipants.Select(x => new
{
x.Id,
x.Name,
x.DeviceId,
x.Rfid,
x.RoundIndex,
x.SlotIndex
}).ToArray()
});
#endregion
// 下载顺序按旧版 Jump_window/Form1 对齐:
// 1. 下发考试时长
// 2. 下发下载准备帧
// 3. 下发本场人数
// 4. 逐个下载姓名和通信地址
// 5. 发送停止和清零,确保设备显示回到初始状态
await SendFrameWithLegacyDelayAsync(
BuildFrame(0x06, 0x06, (byte)(durationSeconds / 256), (byte)(durationSeconds % 256)),
100,
cancellationToken);
await SendFrameWithLegacyDelayAsync(BuildLegacyDownloadPrepareFrame(), 100, cancellationToken);
await SendFrameWithLegacyDelayAsync(BuildFrame(0x0D, 0x0D, 0x00, (byte)roundParticipants.Count), 100, cancellationToken);
var addressBase = (roundIndex % 10) * 20;
foreach (var participant in roundParticipants.OrderBy(x => x.SlotIndex))
{
// 旧协议通过“场次基址 + 槽位”定位当前设备的下载地址。
var slot = addressBase + participant.SlotIndex;
#region debug-point D:participant-download
_ = DebugReportAsync("D", "CompetitionSessionService.DownloadCurrentRoundAsync", "[DEBUG] participant download prepared", new
{
roundIndex,
addressBase,
slot,
participant.Id,
participant.Name,
participant.DeviceId,
participant.Rfid,
participant.SlotIndex,
nameHex = Convert.ToHexString(BuildNameFrame(slot, participant.Name)),
rfidHex = Convert.ToHexString(BuildRfidFrame(slot, participant.Rfid))
});
#endregion
await SendFrameWithLegacyDelayAsync(BuildNameFrame(slot, participant.Name), 50, cancellationToken);
await SendFrameWithLegacyDelayAsync(BuildRfidFrame(slot, participant.Rfid), 50, cancellationToken);
}
await SendFrameWithLegacyDelayAsync(BuildFrame(0x30), 100, cancellationToken);
await SendFrameWithLegacyDelayAsync(BuildFrame(0x0A, 0x0A), 100, cancellationToken);
lock (_gate)
{
foreach (var participant in _session.GetRoundParticipants(_session.CurrentRoundIndex))
{
// 下载完成后本地显示也清成待机状态,和设备保持一致。
participant.Count = 0;
participant.Mode = 0;
participant.RemainingSeconds = _session.TestDurationSeconds;
}
_session.IsTesting = false;
_session.CountdownStartedAt = null;
_session.CountdownStartRemainingSeconds = null;
_session.RemainingSeconds = _session.TestDurationSeconds;
_session.CountdownEndsAt = null;
_session.PendingAutoStopCommand = false;
_session.UpdatedAt = DateTimeOffset.UtcNow;
state = _session.ToState();
}
await _notifier.BroadcastCompetitionStateAsync(state, cancellationToken);
return state;
}
public async Task<CompetitionSessionState> StartAsync(CancellationToken cancellationToken = default)
{
await SendFrameAsync(BuildFrame(0x20), cancellationToken);
CompetitionSessionState state;
lock (_gate)
{
EnsureLoadedUnsafe();
SyncCurrentRoundRemainingUnsafe();
_session.IsTesting = true;
_session.CountdownStartedAt = DateTimeOffset.UtcNow;
_session.CountdownStartRemainingSeconds = _session.RemainingSeconds ?? _session.TestDurationSeconds ?? 0;
_session.CountdownEndsAt = _session.CountdownStartedAt.Value.AddSeconds(_session.CountdownStartRemainingSeconds.Value);
_session.PendingAutoStopCommand = false;
_session.UpdatedAt = DateTimeOffset.UtcNow;
state = _session.ToState();
}
#region debug-point A:start-state
_ = DebugCountdownStopCounterReportAsync("A", "CompetitionSessionService.StartAsync", "[DEBUG] start state created", new
{
state
});
#endregion
await _notifier.BroadcastCompetitionStateAsync(state, cancellationToken);
return state;
}
public async Task<CompetitionSessionState> StopAsync(CancellationToken cancellationToken = default)
{
#region debug-point S1:stop-entry
_ = DebugStopResetControlReportAsync("A", "CompetitionSessionService.StopAsync", "[DEBUG] stop button entered", new
{
currentState = GetState()
});
#endregion
#region debug-point A:manual-stop-command
_ = DebugTimeupProjectionReportAsync("A", "CompetitionSessionService.StopAsync", "[DEBUG] manual stop command requested", new
{
currentState = GetState()
});
#endregion
CompetitionSessionState state;
lock (_gate)
{
EnsureLoadedUnsafe();
SyncCurrentRoundRemainingUnsafe();
foreach (var participant in _session.GetRoundParticipants(_session.CurrentRoundIndex))
{
// 对齐旧版:停止按钮一按下,当前场次先立即退出运行态显示。
participant.Mode = 0;
participant.RemainingSeconds = _session.RemainingSeconds;
}
_session.IsTesting = false;
_session.CountdownStartedAt = null;
_session.CountdownStartRemainingSeconds = null;
_session.CountdownEndsAt = null;
_session.PendingAutoStopCommand = false;
_session.UpdatedAt = DateTimeOffset.UtcNow;
state = _session.ToState();
}
await SendFreezeStopCommandAsync(cancellationToken);
#region debug-point S2:stop-state-after
_ = DebugStopResetControlReportAsync("A", "CompetitionSessionService.StopAsync", "[DEBUG] stop state updated", new
{
state
});
#endregion
await _notifier.BroadcastCompetitionStateAsync(state, cancellationToken);
return state;
}
public async Task<CompetitionSessionState> ResetCurrentRoundAsync(CancellationToken cancellationToken = default)
{
#region debug-point R1:reset-entry
_ = DebugStopResetControlReportAsync("B", "CompetitionSessionService.ResetCurrentRoundAsync", "[DEBUG] reset button entered", new
{
currentState = GetState()
});
#endregion
// 现场日志表明:设备在运行态时第一下清零容易落空,
// 先补一次停止,再按旧协议发送清零,等价于用户手工点两下。
await SendStopCountingCommandAsync(cancellationToken);
await SendFrameWithLegacyDelayAsync(BuildFrame(0x0A, 0x0A), 100, cancellationToken);
CompetitionSessionState state;
lock (_gate)
{
EnsureLoadedUnsafe();
foreach (var participant in _session.GetRoundParticipants(_session.CurrentRoundIndex))
{
participant.Count = 0;
participant.Mode = 0;
participant.RemainingSeconds = _session.TestDurationSeconds;
}
_session.IsTesting = false;
_session.CountdownStartedAt = null;
_session.CountdownStartRemainingSeconds = null;
_session.RemainingSeconds = _session.TestDurationSeconds;
_session.CountdownEndsAt = null;
_session.PendingAutoStopCommand = false;
_session.UpdatedAt = DateTimeOffset.UtcNow;
state = _session.ToState();
}
#region debug-point R2:reset-state-after
_ = DebugStopResetControlReportAsync("B", "CompetitionSessionService.ResetCurrentRoundAsync", "[DEBUG] reset state updated", new
{
state
});
#endregion
await _notifier.BroadcastCompetitionStateAsync(state, cancellationToken);
return state;
}
public async Task<CompetitionSessionState> SetParticipantCountAsync(int participantId, int count, CancellationToken cancellationToken = default)
{
CompetitionSessionState state;
lock (_gate)
{
var participant = FindParticipantUnsafe(participantId);
participant.Count = Math.Max(count, 0);
_session.UpdatedAt = DateTimeOffset.UtcNow;
state = _session.ToState();
}
await _notifier.BroadcastCompetitionStateAsync(state, cancellationToken);
return state;
}
public Task<CompetitionSessionState> ChangeParticipantCountAsync(int participantId, int delta, CancellationToken cancellationToken = default)
{
var state = GetState();
var participant = state.Participants.FirstOrDefault(x => x.Id == participantId)
?? throw new KeyNotFoundException($"participant {participantId} not found");
return SetParticipantCountAsync(participantId, participant.Count + delta, cancellationToken);
}
public async Task ApplyDeviceSnapshotAsync(DeviceSnapshot snapshot, CancellationToken cancellationToken = default)
{
CompetitionSessionState? state = null;
lock (_gate)
{
if (!_session.Loaded)
{
return;
}
// 先按当前时刻刷新一次倒计时,避免“刚好到时但后台 Tick 还没跑到”时继续写分。
SyncCurrentRoundRemainingUnsafe();
// 只按当前场次内的 DeviceId 匹配成绩,避免上一场的数据写到本场。
var participant = _session.GetRoundParticipants(_session.CurrentRoundIndex)
.FirstOrDefault(x => string.Equals(x.DeviceId, snapshot.Telemetry.DeviceId, StringComparison.OrdinalIgnoreCase));
if (participant is null)
{
return;
}
participant.Battery = snapshot.Telemetry.Battery;
participant.Online = snapshot.Online;
participant.LastSeen = snapshot.LastSeen;
// 对齐比赛控制语义:只有测试进行中才允许设备上报改写成绩。
// 这样在到时或手动停止后,迟到的串口帧只更新在线状态,不再冲掉最终成绩。
if (_session.IsTesting)
{
participant.Count = Math.Max(snapshot.Telemetry.Counter, 0);
participant.Mode = snapshot.Telemetry.Mode;
participant.RemainingSeconds = GetCurrentRoundRemainingSecondsUnsafe();
// 运行时证据表明:当前设备持续上报的 remainingSeconds 一直为 0,
// 如果拿它来驱动会话倒计时,会把 CountdownStartedAt 每次都重置,
// 导致页面永远停在 60 秒不动。
// 因此倒计时回到后端本地时钟,设备结束仅保留 Mode=0x0A 这条已知可靠信号。
if (snapshot.Telemetry.Mode == 0x0A)
{
_session.IsTesting = false;
_session.RemainingSeconds = 0;
_session.CountdownStartedAt = null;
_session.CountdownStartRemainingSeconds = null;
_session.CountdownEndsAt = null;
_session.PendingAutoStopCommand = false;
foreach (var roundParticipant in _session.GetRoundParticipants(_session.CurrentRoundIndex))
{
roundParticipant.RemainingSeconds = 0;
}
}
}
#region debug-point S3:device-snapshot-for-stop-reset
_ = DebugStopResetControlReportAsync("C", "CompetitionSessionService.ApplyDeviceSnapshotAsync", "[DEBUG] device snapshot observed", new
{
snapshot = new
{
snapshot.Telemetry.DeviceId,
snapshot.Telemetry.Counter,
snapshot.Telemetry.Mode,
snapshot.Telemetry.RemainingSeconds
},
session = new
{
_session.IsTesting,
_session.RemainingSeconds,
_session.CountdownEndsAt
},
participant = new
{
participant.Id,
participant.DeviceId,
participant.Count,
participant.Mode,
participant.RemainingSeconds,
participant.Online,
participant.LastSeen
}
});
#endregion
#region debug-point C:device-snapshot-applied
_ = DebugCountdownStopCounterReportAsync("C", "CompetitionSessionService.ApplyDeviceSnapshotAsync", "[DEBUG] device snapshot applied", new
{
snapshot = new
{
snapshot.Telemetry.DeviceId,
snapshot.Telemetry.Counter,
snapshot.Telemetry.Mode,
snapshot.Telemetry.RemainingSeconds
},
session = new
{
_session.IsTesting,
_session.RemainingSeconds,
_session.CountdownStartedAt,
_session.CountdownStartRemainingSeconds,
_session.CountdownEndsAt
},
participant = new
{
participant.Id,
participant.DeviceId,
participant.Count,
participant.Mode,
participant.RemainingSeconds
}
});
#endregion
SyncCurrentRoundRemainingUnsafe();
_session.UpdatedAt = DateTimeOffset.UtcNow;
state = _session.ToState();
}
if (state is not null)
{
await _notifier.BroadcastCompetitionStateAsync(state, cancellationToken);
}
}
public async Task TickAsync(CancellationToken cancellationToken = default)
{
CompetitionSessionState? state = null;
var shouldStopDevices = false;
lock (_gate)
{
if (!_session.Loaded)
{
return;
}
if (!_session.IsTesting)
{
shouldStopDevices = ConsumePendingAutoStopCommandUnsafe();
if (!shouldStopDevices)
{
return;
}
_session.UpdatedAt = DateTimeOffset.UtcNow;
state = _session.ToState();
goto ExitLock;
}
var before = _session.RemainingSeconds;
SyncCurrentRoundRemainingUnsafe();
shouldStopDevices = ConsumePendingAutoStopCommandUnsafe();
if (!shouldStopDevices && before == _session.RemainingSeconds && _session.RemainingSeconds is not 0)
{
return;
}
_session.UpdatedAt = DateTimeOffset.UtcNow;
state = _session.ToState();
ExitLock:;
}
if (shouldStopDevices)
{
await SendFreezeStopCommandAsync(cancellationToken);
}
#region debug-point B:tick-state
_ = DebugCountdownStopCounterReportAsync("B", "CompetitionSessionService.TickAsync", "[DEBUG] tick state broadcast", new
{
state
});
#endregion
if (state is not null)
{
await _notifier.BroadcastCompetitionStateAsync(state, cancellationToken);
}
}
private void EnsureLoadedUnsafe()
{
if (!_session.Loaded)
{
throw new InvalidOperationException("competition session is not loaded");
}
}
private MutableCompetitionParticipant FindParticipantUnsafe(int participantId)
{
EnsureLoadedUnsafe();
return _session.Participants.FirstOrDefault(x => x.Id == participantId)
?? throw new KeyNotFoundException($"participant {participantId} not found");
}
private void ApplyCurrentRoundDirectoryUnsafe()
{
// ParticipantDirectory 是“设备号 -> 人员信息”的快速索引,
// 串口实时上报进来后会先查这里,再决定成绩属于谁。
var currentRound = _session.GetRoundParticipants(_session.CurrentRoundIndex)
.Select(x => new RosterEntry
{
DeviceId = x.DeviceId,
Name = x.Name,
School = x.School,
GroupName = _session.RoundNames.ElementAtOrDefault(x.RoundIndex)
})
.ToArray();
_directory.ApplyRoster(currentRound);
}
private MutableCompetitionSession BuildSession(CreateCompetitionSessionRequest request)
{
var requests = request.Participants!
.Where(x => !string.IsNullOrWhiteSpace(x.Name))
.ToArray();
if (requests.Length == 0)
{
throw new InvalidOperationException("participants is empty");
}
var counts = NormalizeRoundCounts(request, requests.Length);
var names = NormalizeRoundNames(request.RoundNames, counts.Count);
var participants = new List<MutableCompetitionParticipant>(requests.Length);
var roundIndex = 0;
var slotIndex = 0;
var remainingInRound = counts[0];
for (var i = 0; i < requests.Length; i++)
{
while (remainingInRound == 0 && roundIndex < counts.Count - 1)
{
roundIndex++;
slotIndex = 0;
remainingInRound = counts[roundIndex];
}
var item = requests[i];
var name = item.Name!.Trim();
participants.Add(new MutableCompetitionParticipant
{
Id = item.Id ?? (i + 1),
Name = name,
ProjectName = string.IsNullOrWhiteSpace(item.ProjectName) ? null : item.ProjectName.Trim(),
School = string.IsNullOrWhiteSpace(item.School) ? null : item.School.Trim(),
// Jump_window uses ReceivedByte[2] as a zero-based slot index in the current round.
DeviceId = string.IsNullOrWhiteSpace(item.DeviceId) ? slotIndex.ToString() : item.DeviceId.Trim(),
// Rfid 字段在这个项目里实际承载秩序册中的“通信地址/手柄地址”。
Rfid = string.IsNullOrWhiteSpace(item.Rfid) ? null : item.Rfid.Trim(),
RoundIndex = item.RoundIndex ?? roundIndex,
SlotIndex = item.SlotIndex ?? slotIndex,
Count = Math.Max(item.Count ?? 0, 0),
RemainingSeconds = request.TestDurationSeconds
});
slotIndex++;
if (remainingInRound > 0)
{
remainingInRound--;
}
}
return new MutableCompetitionSession
{
Loaded = true,
EventId = string.IsNullOrWhiteSpace(request.EventId) ? Guid.NewGuid().ToString("N") : request.EventId.Trim(),
EventName = string.IsNullOrWhiteSpace(request.EventName) ? "跳绳比赛" : request.EventName.Trim(),
RosterPath = request.RosterPath,
TotalRounds = counts.Count,
CurrentRoundIndex = 0,
IsTesting = false,
TestDurationSeconds = request.TestDurationSeconds,
RemainingSeconds = request.TestDurationSeconds,
CountdownEndsAt = null,
PendingAutoStopCommand = false,
UpdatedAt = DateTimeOffset.UtcNow,
RoundParticipantCounts = counts,
RoundNames = names,
Participants = participants
};
}
private static List<int> NormalizeRoundCounts(CreateCompetitionSessionRequest request, int participantCount)
{
var counts = request.RoundParticipantCounts?
.Where(x => x > 0)
.ToList()
?? [];
if (counts.Count == 0)
{
if (request.TotalRounds is > 1)
{
var perRound = (int)Math.Ceiling(participantCount / (double)request.TotalRounds.Value);
counts = Enumerable.Range(0, request.TotalRounds.Value)
.Select(_ => perRound)
.ToList();
}
else
{
counts = [participantCount];
}
}
var total = counts.Sum();
if (total < participantCount)
{
counts[^1] += participantCount - total;
}
else if (total > participantCount)
{
var overflow = total - participantCount;
for (var i = counts.Count - 1; i >= 0 && overflow > 0; i--)
{
var cut = Math.Min(counts[i] - 1, overflow);
counts[i] -= cut;
overflow -= cut;
}
counts = counts.Where(x => x > 0).ToList();
}
if (counts.Count == 0)
{
counts = [participantCount];
}
return counts;
}
private static List<string> NormalizeRoundNames(IReadOnlyList<string>? requested, int roundCount)
{
var result = requested?
.Where(x => !string.IsNullOrWhiteSpace(x))
.Select(x => x.Trim())
.ToList()
?? [];
while (result.Count < roundCount)
{
result.Add($"第 {result.Count + 1} 场");
}
if (result.Count > roundCount)
{
result = result.Take(roundCount).ToList();
}
return result;
}
private async Task SendSelectRoundCommandAsync(int roundIndex, CancellationToken cancellationToken)
{
await SendFrameAsync(BuildFrame(0x0C, 0x0C, 0x00, (byte)(roundIndex % 10)), cancellationToken);
}
private Task SendFrameAsync(byte[] frame, CancellationToken cancellationToken)
{
return _serial.SendAsync(frame, cancellationToken);
}
private async Task SendStopCountingCommandAsync(CancellationToken cancellationToken)
{
// 现场日志表明设备在收到 0x30 后仍可能继续维持 Mode=2。
// 这里在停止后补一帧 0x10,把设备显式拉回“准备/待机”态,
// 不清零当前成绩,但尽量让终端退出继续计数状态。
await SendFrameWithLegacyDelayAsync(BuildFrame(0x30), 100, cancellationToken);
await SendFrameWithLegacyDelayAsync(BuildFrame(0x10), 100, cancellationToken);
await Task.Delay(100, cancellationToken);
}
private async Task SendFreezeStopCommandAsync(CancellationToken cancellationToken)
{
// 手动停止/到时停止要求“保留当前成绩,只停止继续计数”,
// 因此这里只发送停止帧 0x30,不再补会把设备拉回 0 分待机态的 0x10。
await SendFrameAsync(BuildFrame(0x30), cancellationToken);
await Task.Delay(150, cancellationToken);
await SendFrameAsync(BuildFrame(0x30), cancellationToken);
await Task.Delay(150, cancellationToken);
}
private async Task SendFrameWithLegacyDelayAsync(byte[] frame, int delayMilliseconds, CancellationToken cancellationToken)
{
// 旧版每帧发送前都会清串口输入缓存,避免旧应答影响新命令。
_serial.ClearInputBuffer();
await SendFrameAsync(frame, cancellationToken);
if (delayMilliseconds > 0)
{
// 帧间延时是设备侧稳定接收的重要条件,不能省略。
await Task.Delay(delayMilliseconds, cancellationToken);
}
}
private static byte[] BuildNameFrame(int address, string name)
{
// 姓名下载帧最多写入 7 个字节,保持与旧协议长度一致。
var frame = BuildFrame(0x01, (byte)address);
var bytes = System.Text.Encoding.Default.GetBytes(name ?? string.Empty);
var take = Math.Min(bytes.Length, 7);
Array.Copy(bytes, 0, frame, 3, take);
return frame;
}
private static byte[] BuildRfidFrame(int address, string? rfid)
{
// 通信地址格式为 001.002.003.033.074,拆成 5 个字节后写入协议帧。
var frame = BuildFrame(0x02, (byte)address);
var parts = (rfid ?? string.Empty)
.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Take(5)
.ToArray();
for (var i = 0; i < parts.Length; i++)
{
if (byte.TryParse(parts[i], out var value))
{
frame[3 + i] = value;
}
}
return frame;
}
private static byte[] BuildFrame(byte command, byte subCommand = 0x00, byte data3 = 0x00, byte data4 = 0x00)
{
// 下行帧统一使用 12 字节格式:55 + 命令区 + 数据区 + AA。
var frame = new byte[12];
frame[0] = 0x55;
frame[1] = command;
frame[2] = subCommand;
frame[3] = data3;
frame[4] = data4;
frame[11] = 0xAA;
return frame;
}
private static byte[] BuildLegacyDownloadPrepareFrame()
{
// 这是对齐旧版下载流程补上的专用准备帧。
var frame = BuildFrame(0xFF, 0x13, 0x04, 0x00);
frame[5] = 0x03;
return frame;
}
private static CompetitionParticipantState CloneParticipant(MutableCompetitionParticipant participant) => participant.ToState();
private void SyncCurrentRoundRemainingUnsafe()
{
if (!_session.Loaded)
{
return;
}
var remaining = GetCurrentRoundRemainingSecondsUnsafe();
_session.RemainingSeconds = remaining;
foreach (var participant in _session.GetRoundParticipants(_session.CurrentRoundIndex))
{
participant.RemainingSeconds = remaining;
}
if (_session.IsTesting && remaining is <= 0)
{
_session.PendingAutoStopCommand = true;
_session.IsTesting = false;
_session.CountdownStartedAt = null;
_session.CountdownStartRemainingSeconds = null;
_session.CountdownEndsAt = null;
}
}
private bool ConsumePendingAutoStopCommandUnsafe()
{
if (!_session.PendingAutoStopCommand)
{
return false;
}
_session.PendingAutoStopCommand = false;
return true;
}
private int? GetCurrentRoundRemainingSecondsUnsafe()
{
if (!_session.Loaded)
{
return null;
}
var baseRemaining = _session.RemainingSeconds ?? _session.TestDurationSeconds;
if (!_session.IsTesting || _session.CountdownStartedAt is null || _session.CountdownStartRemainingSeconds is null)
{
return baseRemaining;
}
var elapsed = (int)Math.Floor((DateTimeOffset.UtcNow - _session.CountdownStartedAt.Value).TotalSeconds);
return Math.Max(_session.CountdownStartRemainingSeconds.Value - elapsed, 0);
}
private int GetCurrentRoundElapsedSecondsUnsafe()
{
var remaining = GetCurrentRoundRemainingSecondsUnsafe() ?? 0;
var duration = Math.Max(_session.TestDurationSeconds ?? 0, 0);
return Math.Max(duration - remaining, 0);
}
private int? NormalizeRemainingSecondsUnsafe(int? deviceElapsedSeconds)
{
#region debug-point A:normalize-remaining
_ = DebugCountdownStopCounterReportAsync("A", "CompetitionSessionService.NormalizeRemainingSecondsUnsafe", "[DEBUG] normalize remaining seconds", new
{
deviceElapsedSeconds,
duration = _session.TestDurationSeconds,
isTesting = _session.IsTesting,
countdownStartedAt = _session.CountdownStartedAt,
countdownStartRemainingSeconds = _session.CountdownStartRemainingSeconds,
currentRemaining = _session.RemainingSeconds
});
#endregion
if (!deviceElapsedSeconds.HasValue)
{
return GetCurrentRoundRemainingSecondsUnsafe();
}
if (_session.TestDurationSeconds is not int duration || duration <= 0)
{
return Math.Max(deviceElapsedSeconds.Value, 0);
}
// 旧版 WinForms 把帧内 7/8 字节当“已用时间”,因此前端倒计时需要做一次反算。
return Math.Max(duration - Math.Max(deviceElapsedSeconds.Value, 0), 0);
}
// 调试服务器上报逻辑只在排查串口/下载问题时使用。
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
{
}
}
// 内部可变对象只在 service 内使用,向外统一转换成只读状态对象。
private sealed class MutableCompetitionSession
{
public bool Loaded { get; init; }
public string EventId { get; init; } = string.Empty;
public string EventName { get; init; } = string.Empty;
public string? RosterPath { get; init; }
public int TotalRounds { get; init; }
public int CurrentRoundIndex { get; set; }
public bool IsTesting { get; set; }
public int? TestDurationSeconds { get; init; }
public int? RemainingSeconds { get; set; }
public DateTimeOffset? CountdownStartedAt { get; set; }
public int? CountdownStartRemainingSeconds { get; set; }
public DateTimeOffset? CountdownEndsAt { get; set; }
public bool PendingAutoStopCommand { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public List<int> RoundParticipantCounts { get; init; } = [];
public List<string> RoundNames { get; init; } = [];
public List<MutableCompetitionParticipant> Participants { get; init; } = [];
public static MutableCompetitionSession Empty() => new()
{
Loaded = false,
UpdatedAt = DateTimeOffset.UtcNow
};
public IEnumerable<MutableCompetitionParticipant> GetRoundParticipants(int roundIndex)
{
return Participants.Where(x => x.RoundIndex == roundIndex).OrderBy(x => x.SlotIndex);
}
public CompetitionSessionState ToState()
{
return new CompetitionSessionState
{
Loaded = Loaded,
EventId = EventId,
EventName = EventName,
RosterPath = RosterPath,
TotalRounds = TotalRounds,
CurrentRoundIndex = CurrentRoundIndex,
IsTesting = IsTesting,
TestDurationSeconds = TestDurationSeconds,
RemainingSeconds = RemainingSeconds,
CountdownEndsAt = CountdownEndsAt,
UpdatedAt = UpdatedAt,
RoundParticipantCounts = RoundParticipantCounts.ToArray(),
RoundNames = RoundNames.ToArray(),
Participants = Participants.Select(x => x.ToState()).ToArray()
};
}
}
// 选手的运行态信息(成绩、电量、在线状态)都维护在这里。
private sealed class MutableCompetitionParticipant
{
public int Id { get; init; }
public string Name { get; set; } = string.Empty;
public string? ProjectName { get; init; }
public string? School { get; init; }
public string DeviceId { get; init; } = string.Empty;
public string? Rfid { get; init; }
public int RoundIndex { get; init; }
public int SlotIndex { get; init; }
public int Count { get; set; }
public int? Battery { get; set; }
public int? Mode { get; set; }
public int? RemainingSeconds { get; set; }
public bool Online { get; set; }
public DateTimeOffset? LastSeen { get; set; }
public CompetitionParticipantState ToState()
{
return new CompetitionParticipantState
{
Id = Id,
Name = Name,
ProjectName = ProjectName,
School = School,
DeviceId = DeviceId,
Rfid = Rfid,
RoundIndex = RoundIndex,
SlotIndex = SlotIndex,
Count = Count,
Battery = Battery,
Mode = Mode,
RemainingSeconds = RemainingSeconds,
Online = Online,
LastSeen = LastSeen
};
}
}
}