Files
Nonemm/src/Nonemm.Session/OperatingPosition.cs
T
erikbrockoandClaude Sonnet 5.5 1e0fb3eb08 Mark the CQ frequency on the bandmap and leave Run when the radio moves off it
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>
2026-10-24 12:12:35 +00:00

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;
}
}