Two entry windows and an SO2R box

LoggingSession mixed two things: the contest, which one station has one of,
and what the operator is typing, which belongs to a radio. It is now
ContestSession and RadioPosition. Two positions share one log, one score and
one run of serial numbers, so a station worked on radio 1 is a dupe on radio 2.
Frequency, mode, split, what is typed and whether you are running belong to
each radio on its own.

A second radio opens a second entry window. It has no menu of its own: it is
another view of the same contest, not another program. Only the window for
radio 1 opens the database and the connections.

Ctrl+Tab moves the operator to the other radio and the keyboard follows.
Ctrl+Shift+Tab puts both radios in the headphones.

OtrspBox speaks OTRSP to an SO2R box over a serial port: TX1/TX2 for the key,
RX1/RX2 and RX1S for the headphones, AUXnnn for an output line. It follows the
active radio, and every message points the box at this radio before keying, so
a message cannot go out of the radio the operator has just left. A command that
would change nothing is not sent, because the box works relays; N1MM does the
same. The box takes a Stream, so what it sends is tested without a serial port.

Still missing: alternating CQ, and voice keying on the second radio.

Looked at under Xvfb with two fake radios, one of them split: both windows up,
radio 1 showing 14008.00 into 14020.0, radio 2 on 14030.00, Ctrl+Tab moving the
keyboard between them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 16:49:24 +00:00
parent 43be3816c3
commit 1185faed78
22 changed files with 714 additions and 208 deletions

View File

@@ -0,0 +1,153 @@
using Nonemm.Contests;
using Nonemm.Core;
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 RadioPosition
{
public RadioPosition(ContestSession session, int radioNumber = 1)
{
Session = session;
RadioNumber = radioNumber;
Entry = new EntryFields(session.Contest.ExchangeFieldsFor(session.Me));
}
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;
/// 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; 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;
public int SentNumber => Session.SentNumber;
public void Tune(Frequency frequency, Mode? mode = null, Frequency? transmitFrequency = null)
{
Frequency = frequency;
Mode = mode ?? Mode;
TransmitFrequency = transmitFrequency ?? TransmitFrequency;
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));
}
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);
/// Logs what is typed and clears the entry. Throws when the entry is not
/// complete, so a caller that has not checked cannot lose an exchange.
public Qso LogContact()
{
if (!Entry.IsComplete)
{
throw new InvalidOperationException($"the exchange for {Entry.Call} is not complete");
}
Qso stored = Session.Add(BuildQso(Entry.Call.Trim()));
Entry.Clear();
return stored;
}
public void Wipe()
{
Entry.Clear();
Session.NotifyChanged();
}
/// 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)
{
string value = Entry.ValueOf(field.Slot).Trim();
qso = field.Slot switch
{
ExchangeSlot.ReceivedReport => qso with { ReceivedReport = value },
ExchangeSlot.SerialNumber => qso with { ReceivedNumber = Number(value) },
ExchangeSlot.Zone => qso with { Zone = Number(value) },
ExchangeSlot.Section => qso with { Section = value.ToUpperInvariant() },
ExchangeSlot.Check => qso with { Check = Number(value) },
ExchangeSlot.Precedence => qso with { Precedence = value.ToUpperInvariant() },
ExchangeSlot.Exchange1 => qso with { Exchange1 = value.ToUpperInvariant() },
ExchangeSlot.MiscText => qso with { MiscText = value.ToUpperInvariant() },
ExchangeSlot.Name => qso with { Name = value },
ExchangeSlot.Qth => qso with { Qth = value },
ExchangeSlot.GridSquare => qso with { GridSquare = value.ToUpperInvariant() },
ExchangeSlot.Power => qso with { Power = value },
ExchangeSlot.Comment => qso with { Comment = value },
_ => qso,
};
}
return qso;
}
private static int Number(string value) => int.TryParse(value, out int parsed) ? parsed : 0;
}