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