YD_TrainingCompetition_Api/Competition/CompetitionSessionService.cs

1138 lines
43 KiB
C#
Raw Normal View History

2026-06-10 11:16:46 +08:00
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;
2026-06-10 15:54:30 +08:00
// CompetitionSessionService 管理“当前正在进行的比赛会话”。
// 它既保存内存中的场次/选手状态,也负责把关键操作转换成串口命令下发到设备。
2026-06-10 11:16:46 +08:00
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)
{
2026-06-10 15:54:30 +08:00
// 返回不可变快照,避免外部直接改动内部状态。
SyncCurrentRoundRemainingUnsafe();
2026-06-10 11:16:46 +08:00
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)
{
2026-06-10 15:54:30 +08:00
// 新建会话时会完全替换旧会话,并同步刷新当前场次的人员目录。
2026-06-10 11:16:46 +08:00
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;
2026-06-10 15:54:30 +08:00
_session.CountdownStartedAt = null;
_session.CountdownStartRemainingSeconds = null;
_session.RemainingSeconds = _session.TestDurationSeconds;
_session.CountdownEndsAt = null;
_session.PendingAutoStopCommand = false;
2026-06-10 11:16:46 +08:00
_session.UpdatedAt = DateTimeOffset.UtcNow;
2026-06-10 15:54:30 +08:00
// 切场后,串口实时上报的设备号需要重新映射到当前场次名单。
SyncCurrentRoundRemainingUnsafe();
2026-06-10 11:16:46 +08:00
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
2026-06-10 15:54:30 +08:00
// 下载顺序按旧版 Jump_window/Form1 对齐:
// 1. 下发考试时长
// 2. 下发下载准备帧
// 3. 下发本场人数
// 4. 逐个下载姓名和通信地址
// 5. 发送停止和清零,确保设备显示回到初始状态
2026-06-10 11:16:46 +08:00
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))
{
2026-06-10 15:54:30 +08:00
// 旧协议通过“场次基址 + 槽位”定位当前设备的下载地址。
2026-06-10 11:16:46 +08:00
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))
{
2026-06-10 15:54:30 +08:00
// 下载完成后本地显示也清成待机状态,和设备保持一致。
2026-06-10 11:16:46 +08:00
participant.Count = 0;
participant.Mode = 0;
participant.RemainingSeconds = _session.TestDurationSeconds;
}
_session.IsTesting = false;
2026-06-10 15:54:30 +08:00
_session.CountdownStartedAt = null;
_session.CountdownStartRemainingSeconds = null;
_session.RemainingSeconds = _session.TestDurationSeconds;
_session.CountdownEndsAt = null;
_session.PendingAutoStopCommand = false;
2026-06-10 11:16:46 +08:00
_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();
2026-06-10 15:54:30 +08:00
SyncCurrentRoundRemainingUnsafe();
2026-06-10 11:16:46 +08:00
_session.IsTesting = true;
2026-06-10 15:54:30 +08:00
_session.CountdownStartedAt = DateTimeOffset.UtcNow;
_session.CountdownStartRemainingSeconds = _session.RemainingSeconds ?? _session.TestDurationSeconds ?? 0;
_session.CountdownEndsAt = _session.CountdownStartedAt.Value.AddSeconds(_session.CountdownStartRemainingSeconds.Value);
_session.PendingAutoStopCommand = false;
2026-06-10 11:16:46 +08:00
_session.UpdatedAt = DateTimeOffset.UtcNow;
state = _session.ToState();
}
2026-06-10 15:54:30 +08:00
#region debug-point A:start-state
_ = DebugCountdownStopCounterReportAsync("A", "CompetitionSessionService.StartAsync", "[DEBUG] start state created", new
{
state
});
#endregion
2026-06-10 11:16:46 +08:00
await _notifier.BroadcastCompetitionStateAsync(state, cancellationToken);
return state;
}
public async Task<CompetitionSessionState> StopAsync(CancellationToken cancellationToken = default)
{
2026-06-10 15:54:30 +08:00
#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
2026-06-10 11:16:46 +08:00
CompetitionSessionState state;
lock (_gate)
{
EnsureLoadedUnsafe();
2026-06-10 15:54:30 +08:00
SyncCurrentRoundRemainingUnsafe();
foreach (var participant in _session.GetRoundParticipants(_session.CurrentRoundIndex))
{
// 对齐旧版:停止按钮一按下,当前场次先立即退出运行态显示。
participant.Mode = 0;
participant.RemainingSeconds = _session.RemainingSeconds;
}
2026-06-10 11:16:46 +08:00
_session.IsTesting = false;
2026-06-10 15:54:30 +08:00
_session.CountdownStartedAt = null;
_session.CountdownStartRemainingSeconds = null;
_session.CountdownEndsAt = null;
_session.PendingAutoStopCommand = false;
2026-06-10 11:16:46 +08:00
_session.UpdatedAt = DateTimeOffset.UtcNow;
state = _session.ToState();
}
2026-06-10 15:54:30 +08:00
await SendFreezeStopCommandAsync(cancellationToken);
#region debug-point S2:stop-state-after
_ = DebugStopResetControlReportAsync("A", "CompetitionSessionService.StopAsync", "[DEBUG] stop state updated", new
{
state
});
#endregion
2026-06-10 11:16:46 +08:00
await _notifier.BroadcastCompetitionStateAsync(state, cancellationToken);
return state;
}
public async Task<CompetitionSessionState> ResetCurrentRoundAsync(CancellationToken cancellationToken = default)
{
2026-06-10 15:54:30 +08:00
#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);
2026-06-10 11:16:46 +08:00
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;
2026-06-10 15:54:30 +08:00
_session.CountdownStartedAt = null;
_session.CountdownStartRemainingSeconds = null;
_session.RemainingSeconds = _session.TestDurationSeconds;
_session.CountdownEndsAt = null;
_session.PendingAutoStopCommand = false;
2026-06-10 11:16:46 +08:00
_session.UpdatedAt = DateTimeOffset.UtcNow;
state = _session.ToState();
}
2026-06-10 15:54:30 +08:00
#region debug-point R2:reset-state-after
_ = DebugStopResetControlReportAsync("B", "CompetitionSessionService.ResetCurrentRoundAsync", "[DEBUG] reset state updated", new
{
state
});
#endregion
2026-06-10 11:16:46 +08:00
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;
}
2026-06-10 15:54:30 +08:00
// 先按当前时刻刷新一次倒计时,避免“刚好到时但后台 Tick 还没跑到”时继续写分。
SyncCurrentRoundRemainingUnsafe();
// 只按当前场次内的 DeviceId 匹配成绩,避免上一场的数据写到本场。
2026-06-10 11:16:46 +08:00
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;
2026-06-10 15:54:30 +08:00
// 对齐比赛控制语义:只有测试进行中才允许设备上报改写成绩。
// 这样在到时或手动停止后,迟到的串口帧只更新在线状态,不再冲掉最终成绩。
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();
2026-06-10 11:16:46 +08:00
_session.UpdatedAt = DateTimeOffset.UtcNow;
state = _session.ToState();
}
2026-06-10 15:54:30 +08:00
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);
}
2026-06-10 11:16:46 +08:00
}
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()
{
2026-06-10 15:54:30 +08:00
// ParticipantDirectory 是“设备号 -> 人员信息”的快速索引,
// 串口实时上报进来后会先查这里,再决定成绩属于谁。
2026-06-10 11:16:46 +08:00
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(),
2026-06-10 15:54:30 +08:00
// Rfid 字段在这个项目里实际承载秩序册中的“通信地址/手柄地址”。
2026-06-10 11:16:46 +08:00
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,
2026-06-10 15:54:30 +08:00
RemainingSeconds = request.TestDurationSeconds,
CountdownEndsAt = null,
PendingAutoStopCommand = false,
2026-06-10 11:16:46 +08:00
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);
}
2026-06-10 15:54:30 +08:00
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);
}
2026-06-10 11:16:46 +08:00
private async Task SendFrameWithLegacyDelayAsync(byte[] frame, int delayMilliseconds, CancellationToken cancellationToken)
{
2026-06-10 15:54:30 +08:00
// 旧版每帧发送前都会清串口输入缓存,避免旧应答影响新命令。
2026-06-10 11:16:46 +08:00
_serial.ClearInputBuffer();
await SendFrameAsync(frame, cancellationToken);
if (delayMilliseconds > 0)
{
2026-06-10 15:54:30 +08:00
// 帧间延时是设备侧稳定接收的重要条件,不能省略。
2026-06-10 11:16:46 +08:00
await Task.Delay(delayMilliseconds, cancellationToken);
}
}
private static byte[] BuildNameFrame(int address, string name)
{
2026-06-10 15:54:30 +08:00
// 姓名下载帧最多写入 7 个字节,保持与旧协议长度一致。
2026-06-10 11:16:46 +08:00
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)
{
2026-06-10 15:54:30 +08:00
// 通信地址格式为 001.002.003.033.074,拆成 5 个字节后写入协议帧。
2026-06-10 11:16:46 +08:00
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)
{
2026-06-10 15:54:30 +08:00
// 下行帧统一使用 12 字节格式:55 + 命令区 + 数据区 + AA。
2026-06-10 11:16:46 +08:00
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()
{
2026-06-10 15:54:30 +08:00
// 这是对齐旧版下载流程补上的专用准备帧。
2026-06-10 11:16:46 +08:00
var frame = BuildFrame(0xFF, 0x13, 0x04, 0x00);
frame[5] = 0x03;
return frame;
}
private static CompetitionParticipantState CloneParticipant(MutableCompetitionParticipant participant) => participant.ToState();
2026-06-10 15:54:30 +08:00
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);
}
// 调试服务器上报逻辑只在排查串口/下载问题时使用。
2026-06-10 11:16:46 +08:00
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
{
}
}
2026-06-10 15:54:30 +08:00
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 内使用,向外统一转换成只读状态对象。
2026-06-10 11:16:46 +08:00
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; }
2026-06-10 15:54:30 +08:00
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; }
2026-06-10 11:16:46 +08:00
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,
2026-06-10 15:54:30 +08:00
RemainingSeconds = RemainingSeconds,
CountdownEndsAt = CountdownEndsAt,
2026-06-10 11:16:46 +08:00
UpdatedAt = UpdatedAt,
RoundParticipantCounts = RoundParticipantCounts.ToArray(),
RoundNames = RoundNames.ToArray(),
Participants = Participants.Select(x => x.ToState()).ToArray()
};
}
}
2026-06-10 15:54:30 +08:00
// 选手的运行态信息(成绩、电量、在线状态)都维护在这里。
2026-06-10 11:16:46 +08:00
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
};
}
}
}