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; 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) { 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.UpdatedAt = DateTimeOffset.UtcNow; 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 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.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(); _session.IsTesting = true; _session.UpdatedAt = DateTimeOffset.UtcNow; state = _session.ToState(); } await _notifier.BroadcastCompetitionStateAsync(state, cancellationToken); return state; } public async Task StopAsync(CancellationToken cancellationToken = default) { await SendFrameAsync(BuildFrame(0x30), cancellationToken); CompetitionSessionState state; lock (_gate) { EnsureLoadedUnsafe(); _session.IsTesting = false; _session.UpdatedAt = DateTimeOffset.UtcNow; state = _session.ToState(); } await _notifier.BroadcastCompetitionStateAsync(state, cancellationToken); return state; } public async Task ResetCurrentRoundAsync(CancellationToken cancellationToken = default) { await SendFrameAsync(BuildFrame(0x0A, 0x0A), 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.UpdatedAt = DateTimeOffset.UtcNow; state = _session.ToState(); } 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; } var participant = _session.GetRoundParticipants(_session.CurrentRoundIndex) .FirstOrDefault(x => string.Equals(x.DeviceId, snapshot.Telemetry.DeviceId, StringComparison.OrdinalIgnoreCase)); if (participant is null) { return; } participant.Count = Math.Max(snapshot.Telemetry.Counter, 0); participant.Battery = snapshot.Telemetry.Battery; participant.Mode = snapshot.Telemetry.Mode; participant.RemainingSeconds = snapshot.Telemetry.RemainingSeconds; participant.Online = snapshot.Online; participant.LastSeen = snapshot.LastSeen; _session.UpdatedAt = DateTimeOffset.UtcNow; state = _session.ToState(); } 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() { 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 = 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, 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 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) { 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) { 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) { 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 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 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 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, 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 }; } } }