1138 lines
43 KiB
C#
1138 lines
43 KiB
C#
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
|
||
};
|
||
}
|
||
}
|
||
}
|