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:
@@ -8,11 +8,11 @@ namespace Nonemm.Session;
|
||||
/// Answers what the callsign being typed could be, a column per source.
|
||||
public sealed class CheckWindowSources
|
||||
{
|
||||
private readonly LoggingSession session;
|
||||
private readonly RadioPosition session;
|
||||
private readonly CallDatabase database;
|
||||
private readonly Bandmap bandmap;
|
||||
|
||||
public CheckWindowSources(LoggingSession session, CallDatabase database, Bandmap bandmap)
|
||||
public CheckWindowSources(RadioPosition session, CallDatabase database, Bandmap bandmap)
|
||||
{
|
||||
this.session = session;
|
||||
this.database = database;
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
using Nonemm.Contests;
|
||||
using Nonemm.Core;
|
||||
using Nonemm.Core.Country;
|
||||
using Nonemm.Storage;
|
||||
|
||||
namespace Nonemm.Session;
|
||||
|
||||
/// The running contest: the log, the score and what happens when a contact is
|
||||
/// logged. One of these per contest, however many radios the station has.
|
||||
/// What the operator is typing lives in `RadioPosition`, one per radio.
|
||||
///
|
||||
/// No UI framework is referenced here, so the behaviour an operator judges the
|
||||
/// logger by is tested with plain unit tests.
|
||||
public sealed class ContestSession
|
||||
{
|
||||
private readonly LogStore store;
|
||||
|
||||
public ContestSession(
|
||||
LogStore store,
|
||||
Contest contest,
|
||||
ContestInstance instance,
|
||||
StationInfo me,
|
||||
CountryFile? countries)
|
||||
{
|
||||
this.store = store;
|
||||
Countries = countries;
|
||||
Contest = contest;
|
||||
Instance = instance;
|
||||
Me = me;
|
||||
Log = new ContestLog(contest, me, countries);
|
||||
Log.Restore(store.Qsos(instance.ContestNumber));
|
||||
Editor = new QsoEditor(contest, me, countries);
|
||||
SentNumber = NextSentNumber();
|
||||
}
|
||||
|
||||
public Contest Contest { get; }
|
||||
|
||||
public ContestInstance Instance { get; }
|
||||
|
||||
public StationInfo Me { get; }
|
||||
|
||||
public ContestLog Log { get; }
|
||||
|
||||
public QsoEditor Editor { get; }
|
||||
|
||||
public CountryFile? Countries { get; }
|
||||
|
||||
/// Serial numbers count up across the station, not per radio.
|
||||
public int SentNumber { get; private set; }
|
||||
|
||||
public string Operator { get; set; } = "";
|
||||
|
||||
public event EventHandler? Changed;
|
||||
|
||||
public event EventHandler<Qso>? Logged;
|
||||
|
||||
/// A contact that was edited, with the call and time it had before. The
|
||||
/// other stations need the old pair to find their copy of it.
|
||||
public event EventHandler<QsoChange>? Edited;
|
||||
|
||||
public event EventHandler<Qso>? Deleted;
|
||||
|
||||
/// Scores the contact, writes it and hands back what was stored: the
|
||||
/// timestamp can move by a second when another contact with the same call
|
||||
/// already holds it.
|
||||
public Qso Add(Qso qso)
|
||||
{
|
||||
Qso scored = Log.Add(qso);
|
||||
Qso stored = store.Add(scored);
|
||||
if (stored.TimestampUtc != scored.TimestampUtc)
|
||||
{
|
||||
Log.Replace(stored);
|
||||
}
|
||||
SentNumber = NextSentNumber();
|
||||
Logged?.Invoke(this, stored);
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
return stored;
|
||||
}
|
||||
|
||||
public void Delete(string id)
|
||||
{
|
||||
Qso? gone = Log.Qsos.FirstOrDefault(q => q.Id == id);
|
||||
store.Delete(id);
|
||||
Log.Remove(id);
|
||||
if (gone is not null)
|
||||
{
|
||||
Deleted?.Invoke(this, gone);
|
||||
}
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
/// Changes one field of a logged contact. A refused edit leaves the log
|
||||
/// alone and the caller gets the reason back.
|
||||
public QsoEdit Edit(string id, QsoField field, string text) =>
|
||||
EditWith(id, before => Editor.Apply(before, field, text));
|
||||
|
||||
/// Replaces a logged contact with one the caller has already built and
|
||||
/// checked. The edit contact dialog changes many fields at once and comes
|
||||
/// in this way.
|
||||
public void Update(Qso qso) => EditWith(qso.Id, _ => QsoEdit.Accept(qso));
|
||||
|
||||
public void NotifyChanged() => Changed?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
private QsoEdit EditWith(string id, Func<Qso, QsoEdit> change)
|
||||
{
|
||||
Qso before = Log.Qsos.FirstOrDefault(q => q.Id == id)
|
||||
?? throw new InvalidOperationException($"no contact with id {id} in the log");
|
||||
QsoEdit edit = change(before);
|
||||
if (edit.Result is not null)
|
||||
{
|
||||
store.Update(edit.Result);
|
||||
Log.Replace(edit.Result);
|
||||
Edited?.Invoke(this, new QsoChange(edit.Result, before.Call.Text, before.TimestampUtc));
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
return edit;
|
||||
}
|
||||
|
||||
private int NextSentNumber() =>
|
||||
Log.Qsos.Count == 0 ? 1 : Log.Qsos.Max(q => q.SentNumber) + 1;
|
||||
}
|
||||
@@ -10,7 +10,7 @@ public static class MessageExpander
|
||||
/// they are shorter.
|
||||
private const string CutDigits = "T12345678N";
|
||||
|
||||
public static string Expand(string template, LoggingSession session)
|
||||
public static string Expand(string template, RadioPosition session)
|
||||
{
|
||||
StringBuilder text = new();
|
||||
int at = 0;
|
||||
@@ -41,7 +41,7 @@ public static class MessageExpander
|
||||
return text.ToString();
|
||||
}
|
||||
|
||||
private static string Macro(string name, LoggingSession session) => name.ToUpperInvariant() switch
|
||||
private static string Macro(string name, RadioPosition session) => name.ToUpperInvariant() switch
|
||||
{
|
||||
"MYCALL" => session.Me.Callsign,
|
||||
"CALL" => session.Entry.Call.Trim(),
|
||||
|
||||
@@ -5,46 +5,25 @@ using Nonemm.Storage;
|
||||
|
||||
namespace Nonemm.Session;
|
||||
|
||||
/// The running contest: what the operator is typing, what the log says about
|
||||
/// it, and what happens when the contact is logged. It holds no reference to a
|
||||
/// UI toolkit, so the behaviour an operator judges the logger by is tested with
|
||||
/// plain unit tests.
|
||||
public sealed class LoggingSession
|
||||
/// 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
|
||||
{
|
||||
private readonly LogStore store;
|
||||
private readonly CountryFile? countries;
|
||||
|
||||
public LoggingSession(
|
||||
LogStore store,
|
||||
Contest contest,
|
||||
ContestInstance instance,
|
||||
StationInfo me,
|
||||
CountryFile? countries)
|
||||
public RadioPosition(ContestSession session, int radioNumber = 1)
|
||||
{
|
||||
this.store = store;
|
||||
this.countries = countries;
|
||||
Contest = contest;
|
||||
Instance = instance;
|
||||
Me = me;
|
||||
Log = new ContestLog(contest, me, countries);
|
||||
Log.Restore(store.Qsos(instance.ContestNumber));
|
||||
Entry = new EntryFields(contest.ExchangeFieldsFor(me));
|
||||
Editor = new QsoEditor(contest, me, countries);
|
||||
SentNumber = NextSentNumber();
|
||||
Session = session;
|
||||
RadioNumber = radioNumber;
|
||||
Entry = new EntryFields(session.Contest.ExchangeFieldsFor(session.Me));
|
||||
}
|
||||
|
||||
public Contest Contest { get; }
|
||||
public ContestSession Session { get; }
|
||||
|
||||
public ContestInstance Instance { get; }
|
||||
|
||||
public StationInfo Me { get; }
|
||||
|
||||
public ContestLog Log { get; }
|
||||
/// 1 or 2. Contacts record which radio made them.
|
||||
public int RadioNumber { get; }
|
||||
|
||||
public EntryFields Entry { get; }
|
||||
|
||||
public QsoEditor Editor { get; }
|
||||
|
||||
/// With no radio connected this stays where it was last typed.
|
||||
public Frequency Frequency { get; private set; } = Bands.Band20M.Low;
|
||||
|
||||
@@ -53,32 +32,28 @@ public sealed class LoggingSession
|
||||
|
||||
public Mode Mode { get; private set; } = Modes.Cw;
|
||||
|
||||
/// Which radio of a two-radio station the operator is on.
|
||||
public int RadioNumber { get; set; } = 1;
|
||||
|
||||
public int SentNumber { get; private set; }
|
||||
|
||||
public string Operator { get; set; } = "";
|
||||
|
||||
/// True while the operator is calling CQ rather than searching.
|
||||
/// 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 event EventHandler? Changed;
|
||||
public Contest Contest => Session.Contest;
|
||||
|
||||
public event EventHandler<Qso>? Logged;
|
||||
public ContestInstance Instance => Session.Instance;
|
||||
|
||||
/// A contact that was edited, with the call and time it had before. The
|
||||
/// other stations need the old pair to find their copy of it.
|
||||
public event EventHandler<QsoChange>? Edited;
|
||||
public StationInfo Me => Session.Me;
|
||||
|
||||
public event EventHandler<Qso>? Deleted;
|
||||
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;
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
Session.NotifyChanged();
|
||||
}
|
||||
|
||||
/// What the log says about what is typed now. Null when there is no call to
|
||||
@@ -90,7 +65,7 @@ public sealed class LoggingSession
|
||||
}
|
||||
|
||||
public CountryLookup? Country() =>
|
||||
Entry.Call.Trim().Length == 0 ? null : countries?.Find(Entry.Call.Trim());
|
||||
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() =>
|
||||
@@ -107,73 +82,33 @@ public sealed class LoggingSession
|
||||
{
|
||||
throw new InvalidOperationException($"the exchange for {Entry.Call} is not complete");
|
||||
}
|
||||
Qso scored = Log.Add(BuildQso(Entry.Call.Trim()));
|
||||
Qso stored = store.Add(scored);
|
||||
if (stored.TimestampUtc != scored.TimestampUtc)
|
||||
{
|
||||
Log.Replace(stored);
|
||||
}
|
||||
Qso stored = Session.Add(BuildQso(Entry.Call.Trim()));
|
||||
Entry.Clear();
|
||||
SentNumber = NextSentNumber();
|
||||
Logged?.Invoke(this, stored);
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
return stored;
|
||||
}
|
||||
|
||||
public void Wipe()
|
||||
{
|
||||
Entry.Clear();
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
Session.NotifyChanged();
|
||||
}
|
||||
|
||||
public void Delete(string id)
|
||||
/// The report an operator sends without thinking about it: 599 on CW and
|
||||
/// digital modes, 59 on phone.
|
||||
public string DefaultReport() => Mode.Category switch
|
||||
{
|
||||
Qso? gone = Log.Qsos.FirstOrDefault(q => q.Id == id);
|
||||
store.Delete(id);
|
||||
Log.Remove(id);
|
||||
if (gone is not null)
|
||||
{
|
||||
Deleted?.Invoke(this, gone);
|
||||
}
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
/// Changes one field of a logged contact. A refused edit leaves the log
|
||||
/// alone and the caller gets the reason back.
|
||||
public QsoEdit Edit(string id, QsoField field, string text) =>
|
||||
EditWith(id, before => Editor.Apply(before, field, text));
|
||||
|
||||
/// Replaces a logged contact with one the caller has already built and
|
||||
/// checked. The edit contact dialog changes many fields at once and comes
|
||||
/// in this way.
|
||||
public void Update(Qso qso) => EditWith(qso.Id, _ => QsoEdit.Accept(qso));
|
||||
|
||||
private QsoEdit EditWith(string id, Func<Qso, QsoEdit> change)
|
||||
{
|
||||
Qso before = Log.Qsos.FirstOrDefault(q => q.Id == id)
|
||||
?? throw new InvalidOperationException($"no contact with id {id} in the log");
|
||||
QsoEdit edit = change(before);
|
||||
if (edit.Result is not null)
|
||||
{
|
||||
store.Update(edit.Result);
|
||||
Log.Replace(edit.Result);
|
||||
Edited?.Invoke(this, new QsoChange(edit.Result, before.Call.Text, before.TimestampUtc));
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
return edit;
|
||||
}
|
||||
|
||||
private int NextSentNumber() =>
|
||||
Log.Qsos.Count == 0 ? 1 : Log.Qsos.Max(q => q.SentNumber) + 1;
|
||||
ModeCategory.Cw => "599",
|
||||
ModeCategory.Phone => "59",
|
||||
_ => "599",
|
||||
};
|
||||
|
||||
private Qso BuildQso(string call)
|
||||
{
|
||||
Callsign parsed = Callsign.Parse(call);
|
||||
Qso qso = new()
|
||||
{
|
||||
Id = Qso.NewId(),
|
||||
TimestampUtc = DateTime.UtcNow.AddTicks(-(DateTime.UtcNow.Ticks % TimeSpan.TicksPerSecond)),
|
||||
Call = parsed,
|
||||
Call = Callsign.Parse(call),
|
||||
Frequency = Frequency,
|
||||
QsxFrequency = TransmitFrequency,
|
||||
Mode = Mode,
|
||||
@@ -182,10 +117,10 @@ public sealed class LoggingSession
|
||||
ContestNumber = Instance.ContestNumber,
|
||||
SentReport = DefaultReport(),
|
||||
SentNumber = SentNumber,
|
||||
Operator = Operator.Length > 0 ? Operator : Me.Callsign,
|
||||
Operator = Session.Operator.Length > 0 ? Session.Operator : Me.Callsign,
|
||||
IsRunQso = IsRunning,
|
||||
};
|
||||
return ApplyExchange(CountryFields.Apply(qso, countries));
|
||||
return ApplyExchange(CountryFields.Apply(qso, Session.Countries));
|
||||
}
|
||||
|
||||
private Qso ApplyExchange(Qso qso)
|
||||
@@ -214,14 +149,5 @@ public sealed class LoggingSession
|
||||
return qso;
|
||||
}
|
||||
|
||||
/// 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 static int Number(string value) => int.TryParse(value, out int parsed) ? parsed : 0;
|
||||
}
|
||||
Reference in New Issue
Block a user