Files
Nonemm/src/Nonemm.App/AppSession.cs
ericek111 c8de74e24a Add the digital interface, running MMTTY under Wine
MMTTY and 2Tone have no socket or pipe interface: N1MM hosts XMMT.ocx and
exchanges window messages with the engine. A Linux process cannot load that
control, so bridge/nonemm-mmtty-bridge.exe hosts it under Wine and passes
lines over its standard input and output. docs/digital-bridge.md states the
protocol and what the control needs.

The window is N1MM's: receive pane with coloured callsigns, grab list, call
stacking, twenty-four macro buttons and the engine controls. One left click
copies what is under it — a callsign to the callsign box, anything else to the
exchange box the contest keeps for that kind of value.

Config > Digital registers XMMT.ocx in the Wine prefix on its own, making the
prefix first if it is not there. The engine, the bridge and the control start
at the copies shipped beside the program.

The bridge reads the control's own events. OnTranslateMessage carries only the
messages the control has no event for, so nothing was ever decoded through it.

hamlib's data mode names read as digital modes now, and a mode typed into the
callsign box changes mode the way a frequency changes band, so a station with
no radio can reach RTTY at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD
2026-08-31 21:35:45 +00:00

649 lines
23 KiB
C#

using Avalonia.Threading;
using Nonemm.App.Configuration;
using Nonemm.Contests;
using Nonemm.Core;
using Nonemm.Core.Calls;
using Nonemm.Core.Country;
using Nonemm.Digital;
using Nonemm.Keying;
using Nonemm.Network;
using Nonemm.Rig;
using Nonemm.Session;
using Nonemm.Spotting;
using Nonemm.Storage;
namespace Nonemm.App;
/// Everything the running program owns: the log store, the contest in progress,
/// the bandmap, the radio and the cluster. The windows read from this and one
/// `Changed` event tells them all to redraw.
public sealed class AppSession : IDisposable
{
private LogStore? store;
private readonly List<RigctldRadio> radios = [];
private readonly List<OperatingPosition> positions = [];
private int activeRadio;
private ClusterClient? cluster;
private StationNetwork? network;
private MessageSender? keyer;
private AlternatingCq? alternating;
private MmttyEngine? digital;
private DigitalEngineSender? digitalSender;
private So2rBox? box;
/// Which cluster spots reach the bandmap. Rebuilt whenever the settings or
/// the support files change.
private SpotFilter spotFilter = new();
private readonly Random jitter = new();
/// Spots wait here until the next flush. N1MM collects them the same way,
/// because a skimmer feed sends more spots a minute than any window can
/// usefully redraw.
private readonly List<Spot> arriving = [];
private readonly Lock arrivingGuard = new();
private readonly Timer spotFlush;
public AppSession(UserPaths paths, Settings settings)
{
Paths = paths;
Settings = settings;
paths.CreateFolders();
Countries = LoadCountryFile(paths.CountryFile);
Calls = LoadCallDatabase(paths.CallDatabaseFile);
History = LoadCallHistory(settings.CallHistoryFile);
Registry = ContestRegistry.FromFolder(
paths.UserDefinedContests,
out IReadOnlyList<string> problems,
paths.SupportFiles);
UserDefinedContestProblems = problems;
BandPlan = settings.ToBandPlan();
ApplySpotSettings();
spotFlush = new Timer(_ => FlushSpots(), null, SpotFlushInterval, SpotFlushInterval);
}
/// How often the spots that have arrived go onto the bandmap. N1MM's own
/// queue is flushed once a second.
private static readonly TimeSpan SpotFlushInterval = TimeSpan.FromSeconds(1);
public UserPaths Paths { get; }
public Settings Settings { get; private set; }
public ContestRegistry Registry { get; private set; }
/// Where the bandmap paints the CW, digital and phone parts of a band.
public BandPlan BandPlan { get; private set; }
public IReadOnlyList<string> UserDefinedContestProblems { get; }
public CountryFile? Countries { get; private set; }
public CallDatabase Calls { get; private set; }
/// What was published about the stations in this contest. Empty when no
/// file is chosen, and then nothing is filled in for the operator.
public CallHistory History { get; private set; } = CallHistory.Empty;
public Bandmap Bandmap { get; } = new();
/// Where the operator was last on each band and mode, for the band buttons.
public BandMemory BandMemory { get; } = new();
/// The contest in progress: one log and one score, however many radios.
public ContestSession? Logging { get; private set; }
/// The radio the operator is not on, or null at a one-radio station. The
/// message macros that pass a station to the other band need it.
public OperatingPosition? Other(OperatingPosition position) =>
positions.FirstOrDefault(p => p.RadioNumber != position.RadioNumber);
/// One per radio, in radio-number order. There is always at least one, so
/// the program works with no radio connected.
public IReadOnlyList<OperatingPosition> Positions => positions;
/// The radio the operator is on.
public OperatingPosition? Position =>
activeRadio < positions.Count ? positions[activeRadio] : null;
public CheckWindowSources? Check { get; private set; }
/// What is spotted and not worked yet.
public AvailableStations? Available { get; private set; }
/// One entry per configured radio, in radio-number order.
public IReadOnlyList<Radio> Radios => radios;
/// The radio the operator is on. Null when none is configured, and then
/// frequency and mode stay wherever they were last typed.
public Radio? Radio => activeRadio < radios.Count ? radios[activeRadio] : null;
public int ActiveRadioNumber => Position?.RadioNumber ?? 1;
public ClusterClient? Cluster => cluster;
public StationNetwork? Network => network;
public MessageSender? Keyer => keyer;
/// The digital modem, started by the digital window rather than at startup:
/// it runs an engine under Wine, which is not something to do to an
/// operator who is working CW.
public MmttyEngine? Digital => digital;
/// The same engine as something a macro can be sent through.
public MessageSender? DigitalKeyer => digitalSender;
/// Alternating CQ, or null while there is no keyer. It needs two radios to
/// do anything, and a keyer that reports when a message has gone out.
public AlternatingCq? Alternating => alternating;
/// The SO2R box, or null when there is none and the operator switches the
/// transmitter and the headphones by hand.
public So2rBox? Box => box;
/// True while both radios are in the headphones.
public bool IsListeningToBoth { get; private set; }
public event EventHandler? Changed;
public event EventHandler? ContestChanged;
public void Save(Settings settings)
{
Settings = settings;
BandPlan = settings.ToBandPlan();
ApplySpotSettings();
settings.Save(Paths.SettingsFile);
Changed?.Invoke(this, EventArgs.Empty);
}
public void OpenDatabase(string path)
{
bool sameFile = string.Equals(Settings.DatabasePath, path, StringComparison.Ordinal);
store?.Dispose();
store = SqliteLogStore.Open(path);
Logging = null;
Check = null;
Available = null;
Save(Settings with
{
DatabasePath = path,
ContestNumber = sameFile ? Settings.ContestNumber : 0,
});
ContestChanged?.Invoke(this, EventArgs.Empty);
}
public LogStore Store =>
store ?? throw new InvalidOperationException("no log database is open");
public ContestInstance StartContest(ContestInstance instance)
{
ContestInstance stored = Store.AddContest(instance);
OpenContest(stored.ContestNumber);
return stored;
}
/// N1MM reads the `Contest` table when it opens a log, so the definition is
/// written again whenever a contest is opened, not only when it is created.
private void SaveDefinition(Contest contest) =>
Store.SaveContestDefinition(ContestDefinitions.For(contest));
public void OpenContest(int contestNumber)
{
ContestInstance instance = Store.Contest(contestNumber)
?? throw new InvalidOperationException($"no contest numbered {contestNumber} in the log");
Contest contest = Registry.Create(instance.ContestName, ModeCategoryOf(instance));
SaveDefinition(contest);
Logging = new ContestSession(
Store, contest, instance, Settings.Station.ToStationInfo(), Countries, History);
positions.Clear();
for (int number = 1; number <= PositionCount; number++)
{
positions.Add(new OperatingPosition(Logging, number));
}
activeRadio = Math.Min(activeRadio, positions.Count - 1);
Logging.Changed += (_, _) => Changed?.Invoke(this, EventArgs.Empty);
Logging.Logged += (_, qso) =>
{
// a WAE QTC row is traffic about a station, not a station on the air
if (contest.IsContact(qso))
{
Bandmap.Add(new Spot(qso.Call, qso.Frequency, qso.TimestampUtc, SpotSource.Log));
}
};
Logging.Logged += (_, qso) => _ = network?.SendAsync(qso, Settings.Station.Callsign);
Logging.Edited += (_, change) => _ = network?.SendEditAsync(
change.Qso, Settings.Station.Callsign, change.OldCall, change.OldTimestampUtc);
Logging.Deleted += (_, qso) => _ = network?.SendDeleteAsync(qso, Settings.Station.Callsign);
Check = new CheckWindowSources(positions[0], Calls, Bandmap);
Available = new AvailableStations(positions[0], Bandmap);
Save(Settings with { ContestNumber = contestNumber });
ContestChanged?.Invoke(this, EventArgs.Empty);
}
/// One entry position per radio, and one when there is no radio at all.
private int PositionCount => Math.Max(1, Settings.Radios.Count(r => r.IsEnabled));
/// Starts, restarts or stops the link to the other stations of a
/// multi-operator entry, following what the settings now say.
public void ApplyNetworkSettings()
{
network?.Dispose();
network = null;
if (!Settings.NetworkEnabled)
{
Changed?.Invoke(this, EventArgs.Empty);
return;
}
network = new StationNetwork(
Settings.NetworkPort,
Settings.NetworkStationName.Length > 0 ? Settings.NetworkStationName : Environment.MachineName,
Settings.NetworkPeers);
// the socket thread must not touch the log: every other change to it is
// made where the windows read it
network.UpdateArrived += (_, update) =>
Dispatcher.UIThread.Post(() => TakeFromNetwork(update));
network.Start();
Changed?.Invoke(this, EventArgs.Empty);
}
/// What another station did to its log, applied to ours. Nothing goes back
/// out to the network, and the contact is scored here from the rules
/// instead of trusting what the sender put in the message.
private void TakeFromNetwork(ContactUpdate update)
{
if (Logging is null)
{
return;
}
switch (update)
{
case ContactLogged logged:
Logging.AddFromNetwork(logged.Qso);
break;
case ContactReplaced replaced:
Logging.ReplaceFromNetwork(replaced.Qso, replaced.OldCall, replaced.OldTimestampUtc);
break;
case ContactDeleted deleted:
Logging.DeleteFromNetwork(deleted.Id, deleted.Call, deleted.TimestampUtc);
break;
}
}
/// Starts, restarts or stops the keyer, following what the settings say.
/// A keyer that will not open is reported; the program keeps running
/// without one.
public void ApplyKeyerSettings()
{
alternating?.Dispose();
alternating = null;
keyer?.Dispose();
keyer = null;
switch (Settings.KeyerKind.ToLowerInvariant())
{
case "cwdaemon":
keyer = new CwDaemonSender(Settings.KeyerHost, Settings.KeyerPort);
break;
case "winkeyer":
WinkeyerSender winkeyer = new(Settings.KeyerSerialPort);
winkeyer.Open();
keyer = winkeyer;
break;
}
if (keyer is not null)
{
_ = keyer.SetSpeedAsync(Settings.KeyerSpeed);
alternating = new AlternatingCq(
keyer,
CallCqOnAsync,
TimeSpan.FromMilliseconds(Settings.AlternatingCqGapMs));
}
Changed?.Invoke(this, EventArgs.Empty);
}
/// Starts the digital engine under Wine and returns it. The engine that is
/// already running is handed back rather than started again.
public async Task<MmttyEngine> StartDigitalAsync()
{
if (digital is { IsConnected: true } running)
{
return running;
}
StopDigital();
MmttyOptions options = new()
{
EnginePath = Settings.DigitalEnginePath,
Number = ActiveRadioNumber,
Window = Enum.TryParse(Settings.DigitalEngineWindow, ignoreCase: true, out EngineWindow window)
? window
: EngineWindow.Normal,
OnTop = Settings.DigitalEngineOnTop,
PttPort = Settings.DigitalPttPort.Trim().Length > 0 ? Settings.DigitalPttPort : null,
};
WineBridgeChannel channel = new(
Settings.DigitalBridgePath,
Settings.DigitalWinePrefix.Trim().Length > 0 ? Settings.DigitalWinePrefix : null,
Settings.DigitalWineCommand.Trim().Length > 0 ? Settings.DigitalWineCommand : "wine");
MmttyEngine started = new(channel, options);
await started.StartAsync();
digital = started;
digitalSender = new DigitalEngineSender(started);
Changed?.Invoke(this, EventArgs.Empty);
return started;
}
public void StopDigital()
{
digitalSender?.Dispose();
digitalSender = null;
digital?.Dispose();
digital = null;
Changed?.Invoke(this, EventArgs.Empty);
}
/// Opens a connection per enabled radio. A second one makes the station
/// SO2R: both are read, but only the one the operator is on drives the
/// entry window.
public void ConnectRadios()
{
DisposeRadios();
int number = 1;
foreach (StoredRadio configured in Settings.Radios.Where(r => r.IsEnabled))
{
RigctldRadio opened = new(configured.Host, configured.Port, number++);
opened.Moved += (_, state) => RadioMoved(opened, state);
opened.ConnectionChanged += (_, _) => Changed?.Invoke(this, EventArgs.Empty);
radios.Add(opened);
opened.Start();
}
if (Logging is not null && positions.Count != PositionCount)
{
OpenContest(Logging.Instance.ContestNumber);
return;
}
activeRadio = Math.Min(activeRadio, Math.Max(0, positions.Count - 1));
Changed?.Invoke(this, EventArgs.Empty);
}
public void DisconnectRadios()
{
DisposeRadios();
Changed?.Invoke(this, EventArgs.Empty);
}
/// Moves the operator to the other radio. The entry window follows where
/// that radio is sitting, and contacts are logged against its number.
public void SwapRadio()
{
if (positions.Count < 2)
{
return;
}
MoveToRadio(positions[(activeRadio + 1) % positions.Count].RadioNumber);
}
/// Moves the operator to a radio by number. Alternating CQ calls this from
/// the keyer's thread, so everything it raises is posted by the windows.
public void MoveToRadio(int radioNumber)
{
int at = positions.FindIndex(p => p.RadioNumber == radioNumber);
if (at < 0 || at == activeRadio)
{
return;
}
activeRadio = at;
_ = FollowActiveRadioAsync();
Changed?.Invoke(this, EventArgs.Empty);
ActiveRadioChanged?.Invoke(this, ActiveRadioNumber);
}
/// Moves to the radio and sends its CQ message, which is F1. Used by
/// alternating CQ; the operator's own F1 goes through the entry window.
private async Task CallCqOnAsync(int radioNumber)
{
OperatingPosition position = positions.FirstOrDefault(p => p.RadioNumber == radioNumber)
?? throw new InvalidOperationException($"there is no radio {radioNumber}");
if (keyer is null)
{
throw new InvalidOperationException("there is no keyer");
}
string template = Messages
.For(position.Mode.Category, Settings.CwMessageFile, Settings.PhoneMessageFile)
.Keys(position.IsRunning)[Esm.CallCq]
.Message;
if (template.Length == 0)
{
throw new InvalidOperationException("F1 has no message — Config ▸ Keyer and messages");
}
MoveToRadio(radioNumber);
await PointTransmitAtAsync(radioNumber).ConfigureAwait(false);
await keyer.SendAsync(MessageExpander.Expand(template, position, Other(position))).ConfigureAwait(false);
}
/// Puts both radios in the headphones, or goes back to one. An operator
/// listens to the second radio while the first is sending.
public void ToggleListenToBoth()
{
IsListeningToBoth = !IsListeningToBoth;
_ = FollowActiveRadioAsync();
Changed?.Invoke(this, EventArgs.Empty);
}
/// Points the box at the radio about to transmit. Called before keying, so
/// a message never goes out of the radio the operator has just left.
public Task PointTransmitAtAsync(int radioNumber) =>
box?.SetTransmitAsync(radioNumber) ?? Task.CompletedTask;
/// Starts, restarts or stops the SO2R box, following what the settings say.
public void ApplySo2rSettings()
{
box?.Dispose();
box = Settings.So2rBoxPort.Length > 0 ? OtrspBox.Open(Settings.So2rBoxPort) : null;
_ = FollowActiveRadioAsync();
Changed?.Invoke(this, EventArgs.Empty);
}
private async Task FollowActiveRadioAsync()
{
if (box is null)
{
return;
}
try
{
await box.SetTransmitAsync(ActiveRadioNumber).ConfigureAwait(false);
await box.SetReceiveAsync(ActiveRadioNumber, IsListeningToBoth).ConfigureAwait(false);
}
catch (Exception e) when (e is IOException or InvalidOperationException)
{
box.Dispose();
box = null;
}
}
/// Raised when the operator moves to the other radio, so the entry windows
/// can hand the keyboard over and the SO2R box can follow.
public event EventHandler<int>? ActiveRadioChanged;
/// The radio the operator is not on still moves, and the bandmap shows it,
/// but it must not drag the entry window off the contact being worked.
private void RadioMoved(Radio moved, RadioState state)
{
OperatingPosition? position = positions.FirstOrDefault(p => p.RadioNumber == moved.Number);
if (position is null)
{
return;
}
position.Tune(state.Frequency, state.Mode, state.TransmitFrequency);
}
private void DisposeRadios()
{
foreach (RigctldRadio open in radios)
{
open.Dispose();
}
radios.Clear();
}
public void ConnectCluster()
{
cluster?.Dispose();
cluster = new ClusterClient(
Settings.ClusterHost,
Settings.ClusterPort,
Settings.ClusterLogonCall.Length > 0 ? Settings.ClusterLogonCall : Settings.Station.Callsign,
Settings.ClusterCommands,
Settings.ClusterPassword,
autoLogon: Settings.ClusterAutoLogon,
keepAliveInterval: TimeSpan.FromMinutes(Math.Max(1, Settings.ClusterKeepAliveMinutes)));
cluster.SpotArrived += (_, spot) => TakeSpot(spot);
cluster.ConnectionChanged += (_, _) => Changed?.Invoke(this, EventArgs.Empty);
cluster.Start();
}
public void DisconnectCluster()
{
cluster?.Dispose();
cluster = null;
Changed?.Invoke(this, EventArgs.Empty);
}
/// A spot the node sent. It waits for the next flush rather than going
/// straight onto the bandmap.
private void TakeSpot(Spot spot)
{
lock (arrivingGuard)
{
arriving.Add(spot);
}
}
/// The spots that arrived since the last flush, filtered and moved about,
/// put on the bandmap in one go.
private void FlushSpots()
{
List<Spot> batch;
lock (arrivingGuard)
{
if (arriving.Count == 0)
{
return;
}
batch = [.. arriving];
arriving.Clear();
}
List<Spot> keeping = [];
foreach (Spot spot in batch)
{
if (spotFilter.Accepts(spot))
{
keeping.Add(Settings.RandomizeSpots
? SpotJitter.Shifted(spot, BandPlan, jitter, IsDupe(spot))
: spot);
}
}
if (keeping.Count > 0)
{
Bandmap.AddRange(keeping);
}
}
/// Whether the station has already been worked, which decides whether a
/// spot is moved about: N1MM leaves a dupe where it is.
private bool IsDupe(Spot spot) =>
Logging is not null && Logging.Log.Judge(new Qso
{
Id = "",
TimestampUtc = spot.AtUtc,
Call = spot.Call,
Frequency = spot.Frequency,
Mode = Position?.Mode ?? Modes.Cw,
ContestName = Logging.Contest.Name,
}) is { IsDupe: true };
private void ApplySpotSettings()
{
spotFilter = Settings.SpotFilter.ToFilter(BandPlan, Settings.Station, Countries, Calls, History);
Bandmap.Lifetime = TimeSpan.FromMinutes(Math.Max(1, Settings.SpotTimeoutMinutes));
}
public void ReloadSupportFiles()
{
Countries = LoadCountryFile(Paths.CountryFile);
Calls = LoadCallDatabase(Paths.CallDatabaseFile);
History = LoadCallHistory(Settings.CallHistoryFile);
Registry = ContestRegistry.FromFolder(Paths.UserDefinedContests, out _, Paths.SupportFiles);
ApplySpotSettings();
if (Logging is not null)
{
OpenContest(Logging.Instance.ContestNumber);
}
Changed?.Invoke(this, EventArgs.Empty);
}
public void Dispose()
{
StopDigital();
spotFlush.Dispose();
DisposeRadios();
cluster?.Dispose();
network?.Dispose();
alternating?.Dispose();
keyer?.Dispose();
box?.Dispose();
store?.Dispose();
}
private static ModeCategory ModeCategoryOf(ContestInstance instance) =>
instance.ModeCategory.ToUpperInvariant() switch
{
"SSB" or "PH" or "PHONE" => ModeCategory.Phone,
"RTTY" or "DIGI" or "DIGITAL" => ModeCategory.Digital,
_ => ModeCategory.Cw,
};
/// Without a country file the program still runs; country and continent
/// scoring just loses accuracy.
private static CountryFile? LoadCountryFile(string path)
{
try
{
return File.Exists(path) ? CountryFile.Parse(File.ReadAllText(path)) : null;
}
catch (Exception e) when (e is FormatException or IOException)
{
return null;
}
}
/// A file that will not parse is reported by coming back empty rather than
/// stopping the program before a contest.
private static CallHistory LoadCallHistory(string path)
{
try
{
return path.Length > 0 && File.Exists(path)
? CallHistory.Parse(File.ReadAllText(path))
: CallHistory.Empty;
}
catch (Exception e) when (e is FormatException or IOException)
{
return CallHistory.Empty;
}
}
private static CallDatabase LoadCallDatabase(string path)
{
try
{
return File.Exists(path) ? CallDatabase.Parse(File.ReadAllText(path)) : CallDatabase.Empty;
}
catch (Exception e) when (e is FormatException or IOException)
{
return CallDatabase.Empty;
}
}
}