Starting Run stores the frequency in the position. The bandmap draws a purple, bold "CQ Frequency" pseudospot there, which is not added to the shared bandmap, and a click on it tunes back and sets Run. A move of more than 3 kHz (a setting on the telnet Filters tab) while running switches to search and pounce. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
676 lines
23 KiB
C#
676 lines
23 KiB
C#
using Nonemm.Contests;
|
|
using Nonemm.Core;
|
|
using Nonemm.Core.Calls;
|
|
using Nonemm.Core.Country;
|
|
using Nonemm.Storage;
|
|
|
|
namespace Nonemm.Session;
|
|
|
|
/// One radio and what the operator is typing on it. A single-radio station has
|
|
/// one of these; an SO2R station has two, sharing one `ContestSession` and so
|
|
/// one log, one score and one run of serial numbers.
|
|
public sealed class OperatingPosition
|
|
{
|
|
public OperatingPosition(ContestSession session, int radioNumber = 1)
|
|
{
|
|
Session = session;
|
|
RadioNumber = radioNumber;
|
|
Entry = new EntryFields(session.Contest.ExchangeFieldsFor(session.Me));
|
|
FillSentExchange();
|
|
// N1MM fills the sent serial in again on every entry window when a
|
|
// contact is logged, so the other radio is not left showing the number
|
|
// that has just gone
|
|
Session.Logged += (_, _) => FillSentExchange();
|
|
Session.NumbersRestarted += (_, _) => LetTheNumberGo();
|
|
Entry.CallStarted += (_, _) => ReserveNumber();
|
|
}
|
|
|
|
public ContestSession Session { get; }
|
|
|
|
/// 1 or 2. Contacts record which radio made them.
|
|
public int RadioNumber { get; }
|
|
|
|
public EntryFields Entry { get; }
|
|
|
|
/// With no radio connected this stays where it was last typed.
|
|
public Frequency Frequency { get; private set; } = Bands.Band20M.Low;
|
|
|
|
/// The band this position is on. A contest that numbers per band takes the
|
|
/// serial number against it.
|
|
public Band? Band => Bands.ForFrequency(Frequency);
|
|
|
|
/// Where the radio transmits when working split, and zero when it is not.
|
|
public Frequency TransmitFrequency { get; private set; } = Frequency.Zero;
|
|
|
|
public Mode Mode { get; private set; } = Modes.Cw;
|
|
|
|
/// True while the operator is calling CQ on this radio rather than
|
|
/// searching. Each radio runs or searches on its own.
|
|
public bool IsRunning
|
|
{
|
|
get => running;
|
|
set
|
|
{
|
|
if (value && !running)
|
|
{
|
|
CqFrequency = Frequency;
|
|
}
|
|
running = value;
|
|
}
|
|
}
|
|
|
|
private bool running;
|
|
|
|
/// Where this radio last started running or sent a CQ. It stays after the
|
|
/// radio goes back to searching, so the operator can return to it.
|
|
public Frequency? CqFrequency { get; set; }
|
|
|
|
/// How far, in hertz, the radio can move off the CQ frequency before the
|
|
/// position goes back to searching.
|
|
public long CqLeaveHertz { get; set; } = 3_000;
|
|
|
|
/// The other operator's name and a note about the contact. Neither is part
|
|
/// of any exchange: the name is what a contact being quick edited already
|
|
/// held, and the note is what Ctrl+N asks for. Neither counts towards a
|
|
/// complete exchange.
|
|
public string OtherName { get; set; } = "";
|
|
|
|
public string Comment { get; set; } = "";
|
|
|
|
/// The time this station sent, `HHmm`, for a contest that exchanges it.
|
|
/// Empty until the exchange has gone out.
|
|
public string SentTime { get; private set; } = "";
|
|
|
|
public Contest Contest => Session.Contest;
|
|
|
|
public ContestInstance Instance => Session.Instance;
|
|
|
|
public StationInfo Me => Session.Me;
|
|
|
|
public ContestLog Log => Session.Log;
|
|
|
|
public QsoEditor Editor => Session.Editor;
|
|
|
|
/// The serial number this position sends. It is taken from the pool at the
|
|
/// first character of the callsign and held until the contact is logged;
|
|
/// before that it is what the next contact on this band would carry.
|
|
public int SentNumber => reserved ?? Session.NextNumberOn(Band);
|
|
|
|
private int? reserved;
|
|
|
|
/// Takes this position's number, unless it has one already. N1MM takes it
|
|
/// at the first character of the callsign rather than at logging, so the
|
|
/// number is on the network while the contact is still being worked and
|
|
/// the other operator's next number goes above it.
|
|
private void ReserveNumber()
|
|
{
|
|
if (reserved is not null || !Contest.HasSerialNumbers)
|
|
{
|
|
return;
|
|
}
|
|
reserved = Session.ReserveNumberOn(Band);
|
|
FillSentExchange();
|
|
Session.NotifyChanged();
|
|
}
|
|
|
|
/// Gives up the number this position holds, so the next contact takes a
|
|
/// fresh one. A wipe keeps it, as N1MM does: the contact is still to come.
|
|
private void LetTheNumberGo()
|
|
{
|
|
reserved = null;
|
|
FillSentExchange();
|
|
}
|
|
|
|
public void Tune(Frequency frequency, Mode? mode = null, Frequency? transmitFrequency = null)
|
|
{
|
|
bool modeChanged = mode is not null && mode.Category != Mode.Category;
|
|
Frequency = frequency;
|
|
Mode = mode ?? Mode;
|
|
if (modeChanged)
|
|
{
|
|
FillSentExchange();
|
|
}
|
|
TransmitFrequency = transmitFrequency ?? TransmitFrequency;
|
|
if (running && CqFrequency is { } cq && Math.Abs(frequency.Hertz - cq.Hertz) > CqLeaveHertz)
|
|
{
|
|
running = false;
|
|
}
|
|
Session.NotifyChanged();
|
|
}
|
|
|
|
/// What the log says about what is typed now. Null when there is no call to
|
|
/// judge yet.
|
|
public Verdict? Verdict()
|
|
{
|
|
string call = Entry.Call.Trim();
|
|
return call.Length == 0 ? null : Log.Judge(BuildQso(call));
|
|
}
|
|
|
|
/// The same on another band and mode, which is what the band panel asks for
|
|
/// each of its cells.
|
|
public Verdict? Verdict(Frequency frequency, Mode mode)
|
|
{
|
|
string call = Entry.Call.Trim();
|
|
return call.Length == 0
|
|
? null
|
|
: Log.Judge(BuildQso(call) with { Frequency = frequency, Mode = mode });
|
|
}
|
|
|
|
public CountryLookup? Country() =>
|
|
Entry.Call.Trim().Length == 0 ? null : Session.Countries?.Find(Entry.Call.Trim());
|
|
|
|
/// Every earlier contact with the call being typed, newest first.
|
|
public IReadOnlyList<Qso> WorkedBefore() =>
|
|
Entry.Call.Trim().Length == 0 ? [] : Log.WorkedBefore(Entry.Call.Trim());
|
|
|
|
/// The frequency typed into the callsign box, if that is what it holds.
|
|
public Frequency? PendingQsy() => FrequencyEntry.Parse(Entry.Call);
|
|
|
|
/// The mode typed into the callsign box, if that is what it holds.
|
|
public Mode? PendingMode() => ModeEntry.Parse(Entry.Call, Frequency);
|
|
|
|
/// What working this call on the band and mode we are on would bring,
|
|
/// judged from the call alone. A call that has not been worked has sent no
|
|
/// exchange, so a contest scored on what the other station sends can be
|
|
/// judged no further than this.
|
|
public Verdict? JudgeCall(string call)
|
|
{
|
|
string wanted = call.Trim();
|
|
return wanted.Length == 0
|
|
? null
|
|
: Log.Judge(new Qso
|
|
{
|
|
Id = "",
|
|
TimestampUtc = DateTime.UtcNow,
|
|
Call = Callsign.Parse(wanted),
|
|
Frequency = Frequency,
|
|
Mode = Mode,
|
|
ContestName = Contest.Name,
|
|
});
|
|
}
|
|
|
|
/// The calls answering our CQ that have not been worked yet.
|
|
public CallStack Stack { get; } = new();
|
|
|
|
/// N1MM's {STACKANOTHER} and Ctrl+Alt+G: the call being typed goes on the
|
|
/// stack and the entry is cleared, so the next caller can be copied. Only
|
|
/// while running, and a dupe is not stacked. False when nothing was
|
|
/// stacked.
|
|
public bool StackAnother()
|
|
{
|
|
if (!IsRunning || !IsWorthStacking(Entry.Call.Trim()))
|
|
{
|
|
return false;
|
|
}
|
|
Verdict? worth = Verdict();
|
|
if (worth is null || worth.IsDupe)
|
|
{
|
|
return false;
|
|
}
|
|
Stack.Add(Entry.Call.Trim(), worth.IsNewMultiplier);
|
|
ClearEntry();
|
|
return true;
|
|
}
|
|
|
|
/// N1MM's Alt+G: the call on top of the stack replaces what is typed.
|
|
/// False when the stack is empty, and then the entry is left alone.
|
|
public bool GrabStacked()
|
|
{
|
|
if (Stack.IsEmpty)
|
|
{
|
|
return false;
|
|
}
|
|
string call = Stack.Next();
|
|
ClearEntry();
|
|
Entry.Call = call;
|
|
Entry.FocusOn(0);
|
|
Session.NotifyChanged();
|
|
return true;
|
|
}
|
|
|
|
/// N1MM's {SOCALLSTACK}: one key both stacks and unstacks. With a call
|
|
/// typed and the stack holding one, the two are swapped — the stacked call
|
|
/// comes off first, so the typed call cannot come straight back. With the
|
|
/// box empty it takes the next call off; with the stack empty it puts the
|
|
/// typed call on. Only while running, and a dupe or a frequency is left
|
|
/// alone. False when there was nothing to do, which is what N1MM reports
|
|
/// back to ESM.
|
|
public bool SwapWithStack()
|
|
{
|
|
if (!IsRunning)
|
|
{
|
|
return false;
|
|
}
|
|
string call = Entry.Call.Trim();
|
|
if (call.Length == 0)
|
|
{
|
|
return GrabStacked();
|
|
}
|
|
if (!IsWorthStacking(call) || Verdict() is not { IsDupe: false } worth)
|
|
{
|
|
return false;
|
|
}
|
|
bool isMultiplier = worth.IsNewMultiplier;
|
|
string next = Stack.Next();
|
|
Stack.Add(call, isMultiplier);
|
|
ClearEntry();
|
|
Entry.Call = next;
|
|
Entry.FocusOn(0);
|
|
Session.NotifyChanged();
|
|
return next.Length > 0;
|
|
}
|
|
|
|
/// A frequency typed into the callsign box is a band change, not a call,
|
|
/// and our own callsign is never stacked. N1MM refuses its own call only
|
|
/// while the stack is empty.
|
|
private bool IsWorthStacking(string call) =>
|
|
call.Length >= CallStack.ShortestCall
|
|
&& !call.All(c => char.IsAsciiDigit(c) || c == '.')
|
|
&& !string.Equals(call, Me.Callsign, StringComparison.OrdinalIgnoreCase);
|
|
|
|
/// The first entry box holding something that cannot be logged, and why.
|
|
/// Null when everything typed can go in the log. The log window checks the
|
|
/// same way, so a value refused here is refused there.
|
|
public EntryProblem? EntryProblem() => ExchangeCheck.Problem(Entry, Mode);
|
|
|
|
/// Logs what is typed and clears the entry. Throws when the entry is not
|
|
/// complete or holds a value the contest will not take, so a caller that
|
|
/// has not checked cannot lose an exchange or log nonsense.
|
|
public Qso LogContact()
|
|
{
|
|
if (!IsEntryComplete)
|
|
{
|
|
throw new InvalidOperationException($"the exchange for {Entry.Call} is not complete");
|
|
}
|
|
if (EntryProblem() is { } problem)
|
|
{
|
|
throw new InvalidOperationException(problem.Message);
|
|
}
|
|
return Store();
|
|
}
|
|
|
|
/// Logs what is typed although the exchange is short or a box holds
|
|
/// something the contest will not take, which is N1MM's `{FORCELOG}`. A
|
|
/// contact in the log with a hole in it can be filled in afterwards; one
|
|
/// that was never logged cannot. The callsign is still required, because a
|
|
/// row with no callsign in it is not a contact.
|
|
public Qso LogContactAnyway()
|
|
{
|
|
if (Entry.Call.Trim().Length == 0)
|
|
{
|
|
throw new InvalidOperationException("there is no callsign to log");
|
|
}
|
|
return Store();
|
|
}
|
|
|
|
private Qso Store()
|
|
{
|
|
StampSentTime();
|
|
Qso stored = Session.Add(Noted(BuildQso(Entry.Call.Trim())));
|
|
// the number went with the contact, so the next one starts again from
|
|
// the pool
|
|
reserved = null;
|
|
Entry.Clear();
|
|
FillSentExchange();
|
|
OtherName = "";
|
|
Comment = "";
|
|
SentTime = "";
|
|
return stored;
|
|
}
|
|
|
|
/// Notes the time the exchange went out, which the BARTG RTTY contest
|
|
/// sends and logs. The first message of a contact sets it, so the time in
|
|
/// the log is the time the other station copied, not the time the contact
|
|
/// was logged. Does nothing for the contests that do not send a time.
|
|
public void StampSentTime()
|
|
{
|
|
if (Contest.StampsSentTime && SentTime.Length == 0 && Entry.Call.Trim().Length > 2)
|
|
{
|
|
SentTime = DateTime.UtcNow.ToString("HHmm");
|
|
Session.NotifyChanged();
|
|
}
|
|
}
|
|
|
|
/// Puts what this station sends into the boxes that show it: the report
|
|
/// and the serial number. N1MM keeps both filled in and steps over them,
|
|
/// so the number going out is on the screen without being typed.
|
|
public void FillSentExchange()
|
|
{
|
|
foreach (ExchangeField field in Entry.Exchange.Where(f => f.IsSent))
|
|
{
|
|
Entry.Set(
|
|
field.Slot,
|
|
field.Slot == ExchangeSlot.SentReport ? DefaultReport() : SentNumber.ToString());
|
|
}
|
|
}
|
|
|
|
/// Puts the report the operator would send anyway into the report boxes
|
|
/// that are empty: 599 on CW and digital modes, 59 on phone. N1MM does the
|
|
/// same when the callsign box is left, which is why neither program stops
|
|
/// on a report box.
|
|
public bool FillReports()
|
|
{
|
|
bool filled = false;
|
|
for (int at = 0; at < Entry.Exchange.Count; at++)
|
|
{
|
|
if (Entry.Exchange[at].Kind == ExchangeFieldKind.Report && Entry[at + 1].Trim().Length == 0)
|
|
{
|
|
Entry[at + 1] = DefaultReport();
|
|
filled = true;
|
|
}
|
|
}
|
|
if (filled)
|
|
{
|
|
Session.NotifyChanged();
|
|
}
|
|
return filled;
|
|
}
|
|
|
|
/// Fills what is already known about the station into the exchange boxes
|
|
/// that are still empty. What the other station actually sends wins, so a
|
|
/// box with something in it is left alone. A zone box takes the call
|
|
/// history file first, then the zone logged on the last contact with the
|
|
/// call, then the zone the country file gives for the callsign, which is
|
|
/// what N1MM does. Returns true when anything was filled.
|
|
public bool FillFromHistory()
|
|
{
|
|
CallHistoryEntry? known = Session.History.Find(Entry.Call);
|
|
bool filled = false;
|
|
for (int at = 0; at < Entry.Exchange.Count; at++)
|
|
{
|
|
ExchangeField field = Entry.Exchange[at];
|
|
string value = known is null ? "" : ExchangeSlots.ValueFrom(known, field);
|
|
if (value.Length == 0)
|
|
{
|
|
value = KnownZone(field);
|
|
}
|
|
if (value.Length == 0 || Entry[at + 1].Trim().Length > 0)
|
|
{
|
|
continue;
|
|
}
|
|
Entry[at + 1] = value;
|
|
filled = true;
|
|
}
|
|
if (filled)
|
|
{
|
|
Session.NotifyChanged();
|
|
}
|
|
return filled;
|
|
}
|
|
|
|
/// The zone to suggest for a zone box: the one on the newest earlier
|
|
/// contact with this call that has one, else the country file's zone.
|
|
/// Empty for any other box.
|
|
private string KnownZone(ExchangeField field)
|
|
{
|
|
if (field.Kind is not (ExchangeFieldKind.CqZone or ExchangeFieldKind.ItuZone))
|
|
{
|
|
return "";
|
|
}
|
|
string logged = WorkedBefore()
|
|
.Select(qso => ExchangeSlots.ValueOf(qso, field.Slot))
|
|
.FirstOrDefault(value => value.Length > 0) ?? "";
|
|
if (logged.Length > 0)
|
|
{
|
|
return logged;
|
|
}
|
|
CountryLookup? their = Country();
|
|
int zone = their is null ? 0 : field.Kind == ExchangeFieldKind.CqZone ? their.CqZone : their.ItuZone;
|
|
return zone > 0 ? zone.ToString() : "";
|
|
}
|
|
|
|
/// Moves to the next box the operator types in. On top of the report boxes
|
|
/// the entry itself steps over, a contest can say a box is not for this
|
|
/// station: CQ WW RTTY asks only the US and Canada for a state.
|
|
/// True when everything this contest asks for this station has been typed.
|
|
/// A box the walk steps over is not asked for, so it does not hold the
|
|
/// contact up: `MoveFocus` and this read the same rule.
|
|
public bool IsEntryComplete => Entry.IsComplete(Applies);
|
|
|
|
private bool Applies(ExchangeField field) => !Contest.SkipsField(field, Country());
|
|
|
|
public void MoveFocus(bool forward)
|
|
{
|
|
CountryLookup? their = Country();
|
|
for (int step = 0; step < Entry.FieldCount; step++)
|
|
{
|
|
if (forward)
|
|
{
|
|
Entry.Advance();
|
|
}
|
|
else
|
|
{
|
|
Entry.Retreat();
|
|
}
|
|
if (Entry.Focus == 0 || !Contest.SkipsField(Entry.Exchange[Entry.Focus - 1], their))
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The logged contact the boxes are re-editing, or null while a new one is
|
|
/// being typed. N1MM calls this quick edit: Ctrl+Q steps back through the
|
|
/// log, Ctrl+A forward, Enter writes the changes back and Esc leaves.
|
|
public Qso? Editing { get; private set; }
|
|
|
|
private TypedEntry? held;
|
|
private TypedEntry? wiped;
|
|
|
|
/// Steps one contact back or forward and loads it into the boxes. False
|
|
/// when the log has nothing that way; stepping forward past the newest
|
|
/// contact leaves quick edit instead.
|
|
public bool QuickEdit(bool forward)
|
|
{
|
|
IReadOnlyList<Qso> qsos = Log.Qsos;
|
|
if (qsos.Count == 0)
|
|
{
|
|
return false;
|
|
}
|
|
int at = Editing is null ? qsos.Count : IndexOf(Editing.Id);
|
|
int wanted = forward ? at + 1 : at - 1;
|
|
if (wanted < 0 || at < 0)
|
|
{
|
|
return false;
|
|
}
|
|
if (wanted >= qsos.Count)
|
|
{
|
|
LeaveQuickEdit();
|
|
return true;
|
|
}
|
|
held ??= Typed();
|
|
Editing = qsos[wanted];
|
|
LoadFrom(Editing);
|
|
Session.NotifyChanged();
|
|
return true;
|
|
}
|
|
|
|
/// Puts the boxes back to the contact that was being typed.
|
|
public void LeaveQuickEdit()
|
|
{
|
|
if (Editing is null)
|
|
{
|
|
return;
|
|
}
|
|
Editing = null;
|
|
if (held is { } typed)
|
|
{
|
|
PutBack(typed);
|
|
held = null;
|
|
}
|
|
Session.NotifyChanged();
|
|
}
|
|
|
|
/// Writes the boxes back to the contact being quick edited and leaves quick
|
|
/// edit. Null when no contact is being edited. Throws on a value the
|
|
/// contest will not take, the same as logging a new contact does.
|
|
public Qso? SaveQuickEdit()
|
|
{
|
|
if (Editing is null)
|
|
{
|
|
return null;
|
|
}
|
|
if (EntryProblem() is { } problem)
|
|
{
|
|
throw new InvalidOperationException(problem.Message);
|
|
}
|
|
Qso saved = Noted(ApplyExchange(CountryFields.Apply(
|
|
Editing with { Call = Callsign.Parse(Entry.Call.Trim()) }, Session.Countries)));
|
|
Session.Update(saved);
|
|
LeaveQuickEdit();
|
|
return saved;
|
|
}
|
|
|
|
/// The contact as it would be logged now, for the dialog that edits the one
|
|
/// in progress.
|
|
public Qso InProgress() => Noted(BuildQso(Entry.Call.Trim()));
|
|
|
|
/// Fills the boxes from a contact, as quick edit does.
|
|
public void LoadFrom(Qso qso)
|
|
{
|
|
Entry.Call = qso.Call.Text;
|
|
for (int at = 0; at < Entry.Exchange.Count; at++)
|
|
{
|
|
Entry[at + 1] = QsoExchange.ValueOf(qso, Entry.Exchange[at].Slot);
|
|
}
|
|
OtherName = Entry.Exchange.Any(f => f.Slot == ExchangeSlot.Name) ? "" : qso.Name;
|
|
Comment = qso.Comment;
|
|
}
|
|
|
|
private int IndexOf(string id)
|
|
{
|
|
for (int at = 0; at < Log.Qsos.Count; at++)
|
|
{
|
|
if (Log.Qsos[at].Id == id)
|
|
{
|
|
return at;
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
/// Clears the boxes, keeping what was in them for `RestoreWiped`. An entry
|
|
/// with nothing typed in it is not worth keeping, so a second wipe does not
|
|
/// throw the first one away.
|
|
public void Wipe()
|
|
{
|
|
if (!Entry.IsEmpty)
|
|
{
|
|
wiped = Typed();
|
|
}
|
|
ClearEntry();
|
|
}
|
|
|
|
/// N1MM's Alt+W and its Wipe button: one key both ways. With something in
|
|
/// the boxes it clears them; with the boxes empty it puts back what the
|
|
/// last wipe cleared.
|
|
public void WipeOrRestore()
|
|
{
|
|
if (!RestoreWiped())
|
|
{
|
|
Wipe();
|
|
}
|
|
}
|
|
|
|
/// Puts back what the last wipe cleared. False when no wipe
|
|
/// has anything to put back, or when the boxes have something in them
|
|
/// again — N1MM restores into an empty entry only, so nothing typed since
|
|
/// the wipe is lost.
|
|
public bool RestoreWiped()
|
|
{
|
|
if (wiped is not { } typed || !Entry.IsEmpty)
|
|
{
|
|
return false;
|
|
}
|
|
PutBack(typed);
|
|
Entry.FocusOn(0);
|
|
Session.NotifyChanged();
|
|
return true;
|
|
}
|
|
|
|
/// Wipes without keeping anything: the call has gone somewhere else, such
|
|
/// as onto the call stack, so there is nothing to put back.
|
|
private void ClearEntry()
|
|
{
|
|
Editing = null;
|
|
held = null;
|
|
Entry.Clear();
|
|
FillSentExchange();
|
|
OtherName = "";
|
|
Comment = "";
|
|
SentTime = "";
|
|
Session.NotifyChanged();
|
|
}
|
|
|
|
private TypedEntry Typed() => new(
|
|
Entry.Call,
|
|
[.. Enumerable.Range(1, Entry.FieldCount - 1).Select(f => Entry[f])],
|
|
OtherName,
|
|
Comment,
|
|
SentTime);
|
|
|
|
private void PutBack(TypedEntry typed)
|
|
{
|
|
Entry.Call = typed.Call;
|
|
// the contest can have changed since, and with it the boxes there are
|
|
for (int field = 1; field < Entry.FieldCount && field <= typed.Values.Count; field++)
|
|
{
|
|
Entry[field] = typed.Values[field - 1];
|
|
}
|
|
OtherName = typed.Name;
|
|
Comment = typed.Comment;
|
|
SentTime = typed.SentTime;
|
|
}
|
|
|
|
/// The name and comment boxes fill the log columns of the same names. A
|
|
/// contest that exchanges a name has already filled that column, and what
|
|
/// was exchanged wins.
|
|
private Qso Noted(Qso qso) => qso with
|
|
{
|
|
Name = qso.Name.Length > 0 ? qso.Name : OtherName.Trim(),
|
|
Comment = qso.Comment.Length > 0 ? qso.Comment : Comment.Trim(),
|
|
MiscText = qso.MiscText.Length > 0 ? qso.MiscText : SentTime,
|
|
};
|
|
|
|
/// The report an operator sends without thinking about it: 599 on CW and
|
|
/// digital modes, 59 on phone.
|
|
public string DefaultReport() => Mode.Category switch
|
|
{
|
|
ModeCategory.Cw => "599",
|
|
ModeCategory.Phone => "59",
|
|
_ => "599",
|
|
};
|
|
|
|
private Qso BuildQso(string call)
|
|
{
|
|
Qso qso = new()
|
|
{
|
|
Id = Qso.NewId(),
|
|
TimestampUtc = DateTime.UtcNow.AddTicks(-(DateTime.UtcNow.Ticks % TimeSpan.TicksPerSecond)),
|
|
Call = Callsign.Parse(call),
|
|
Frequency = Frequency,
|
|
QsxFrequency = TransmitFrequency,
|
|
Mode = Mode,
|
|
RadioNumber = RadioNumber,
|
|
ContestName = Contest.Name,
|
|
ContestNumber = Instance.ContestNumber,
|
|
SentReport = DefaultReport(),
|
|
SentNumber = SentNumber,
|
|
Operator = Session.Operator.Length > 0 ? Session.Operator : Me.Callsign,
|
|
IsRunQso = IsRunning,
|
|
};
|
|
return ApplyExchange(CountryFields.Apply(qso, Session.Countries));
|
|
}
|
|
|
|
private Qso ApplyExchange(Qso qso)
|
|
{
|
|
foreach (ExchangeField field in Entry.Exchange)
|
|
{
|
|
qso = ExchangeSlots.Apply(qso, field.Slot, Entry.ValueOf(field.Slot).Trim());
|
|
}
|
|
return Entry.Exchange.Any(f => f.Slot == ExchangeSlot.Exchange1)
|
|
? Contest.FromExchange1(qso, Entry.ValueOf(ExchangeSlot.Exchange1).Trim())
|
|
: qso;
|
|
}
|
|
}
|