655 lines
23 KiB
C#
655 lines
23 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;
|
|
|
|
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<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.UpdatedAt = DateTimeOffset.UtcNow;
|
|
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
|
|
|
|
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<CompetitionSessionState> 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<CompetitionSessionState> 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<CompetitionSessionState> 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<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;
|
|
}
|
|
|
|
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<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 = 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<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 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<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,
|
|
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
|
|
};
|
|
}
|
|
}
|
|
}
|