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:
153
src/Nonemm.Session/RadioPosition.cs
Normal file
153
src/Nonemm.Session/RadioPosition.cs
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user