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 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 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 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 SelectPreviousRoundAsync(CancellationToken cancellationToken = default) { var current = GetState(); var next = Math.Max(current.CurrentRoundIndex - 1, 0); return SelectRoundAsync(next, cancellationToken); } public async Task DownloadCurrentRoundAsync(CancellationToken cancellationToken = default) { CompetitionSessionState state; IReadOnlyList 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 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 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 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 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 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(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 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 NormalizeRoundNames(IReadOnlyList? 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 RoundParticipantCounts { get; init; } = []; public List RoundNames { get; init; } = []; public List Participants { get; init; } = []; public static MutableCompetitionSession Empty() => new() { Loaded = false, UpdatedAt = DateTimeOffset.UtcNow }; public IEnumerable 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 }; } } }