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