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
546 lines
19 KiB
C#
546 lines
19 KiB
C#
using System.Reflection;
|
|
using System.Text.Json;
|
|
using Nonemm.Core;
|
|
using Nonemm.Session;
|
|
|
|
namespace Nonemm.App.Configuration;
|
|
|
|
/// What the program remembers between runs.
|
|
public sealed record Settings
|
|
{
|
|
public string DatabasePath { get; init; } = "";
|
|
|
|
/// `light` is N1MM's own palette, `dark` the same states in colours for a
|
|
/// dark screen. Config > Manage Skins, Colors and Fonts picks one.
|
|
public string Theme { get; init; } = "light";
|
|
|
|
public int ContestNumber { get; init; }
|
|
|
|
public StoredStation Station { get; init; } = new();
|
|
|
|
public string ClusterHost { get; init; } = "";
|
|
|
|
public int ClusterPort { get; init; } = 7373;
|
|
|
|
/// Only the few nodes that ask for one; most take the callsign alone.
|
|
public string ClusterPassword { get; init; } = "";
|
|
|
|
public IReadOnlyList<string> ClusterCommands { get; init; } = [];
|
|
|
|
/// The nodes the operator keeps, listed on the telnet window's Clusters
|
|
/// tab. Connecting to one copies it into `ClusterHost` and the rest.
|
|
public IReadOnlyList<StoredClusterNode> ClusterNodes { get; init; } = [];
|
|
|
|
/// Off for a node that wants the call typed in by hand.
|
|
public bool ClusterAutoLogon { get; init; } = true;
|
|
|
|
/// The call sent at login, or empty for the station callsign.
|
|
public string ClusterLogonCall { get; init; } = "";
|
|
|
|
/// How often to send an empty line to a node that has said nothing. Nodes
|
|
/// drop a connection that has been quiet for a quarter of an hour.
|
|
public int ClusterKeepAliveMinutes { get; init; } = 4;
|
|
|
|
/// The buttons along the bottom of the telnet window. Empty means the ones
|
|
/// in `StoredTelnetButton.Default`.
|
|
public IReadOnlyList<StoredTelnetButton> TelnetButtons { get; init; } = [];
|
|
|
|
/// Which cluster spots reach the bandmap.
|
|
public StoredSpotFilter SpotFilter { get; init; } = new();
|
|
|
|
/// How long a spot stays on the bandmap. N1MM's own default is 60.
|
|
public int SpotTimeoutMinutes { get; init; } = 60;
|
|
|
|
/// Moves each incoming CW spot by a few tens of hertz, so the operator has
|
|
/// to find the station by ear. N1MM calls it randomising.
|
|
public bool RandomizeSpots { get; init; }
|
|
|
|
/// Enter sends the message the contact has got to, rather than only logging
|
|
/// it. N1MM calls it ESM and most operators run with it on.
|
|
public bool EsmEnabled { get; init; }
|
|
|
|
/// N1MM's "big gun" switch: while searching, send the call once and be
|
|
/// ready to copy the exchange rather than calling again.
|
|
public bool EsmSendsCallOnce { get; init; }
|
|
|
|
/// Work a station that calls in again while running, which is what N1MM
|
|
/// recommends.
|
|
public bool EsmWorksDupes { get; init; } = true;
|
|
|
|
/// Send the call again in front of the message that ends the contact when
|
|
/// it has changed since it went out.
|
|
public bool EsmSendsCorrectedCall { get; init; } = true;
|
|
|
|
/// How close to a spot the radio has to be for the entry window to say the
|
|
/// station is there. N1MM keeps one per mode and defaults each to 300 Hz.
|
|
public int CwTuningToleranceHertz { get; init; } = 300;
|
|
|
|
public int PhoneTuningToleranceHertz { get; init; } = 300;
|
|
|
|
public int DigitalTuningToleranceHertz { get; init; } = 300;
|
|
|
|
public int TuningToleranceFor(ModeCategory mode) => mode switch
|
|
{
|
|
ModeCategory.Phone => PhoneTuningToleranceHertz,
|
|
ModeCategory.Digital => DigitalTuningToleranceHertz,
|
|
_ => CwTuningToleranceHertz,
|
|
};
|
|
|
|
/// How far `{FREQUP}` and `{FREQDN}` move the radio. N1MM asks for the same
|
|
/// number in its Configurer.
|
|
public int FrequencyStepHertz { get; init; } = 100;
|
|
|
|
/// How far `{PGUP}` and `{PGDN}` move the radio.
|
|
public int PageStepHertz { get; init; } = 1_000;
|
|
|
|
/// Sent with a spot the operator puts on the cluster with Alt+P.
|
|
public string SpotComment { get; init; } = "";
|
|
|
|
/// One entry per radio, in radio-number order. A second radio makes the
|
|
/// station SO2R.
|
|
public IReadOnlyList<StoredRadio> Radios { get; init; } = [];
|
|
|
|
/// Written by versions that only knew one radio. Read once, to fill
|
|
/// `Radios` in, and never written again.
|
|
public string RigctldHost { get; init; } = "";
|
|
|
|
public int RigctldPort { get; init; }
|
|
|
|
public bool RadioEnabled { get; init; }
|
|
|
|
public bool ClusterEnabled { get; init; }
|
|
|
|
public string NetworkStationName { get; init; } = "";
|
|
|
|
public int NetworkPort { get; init; } = 12060;
|
|
|
|
public bool NetworkEnabled { get; init; }
|
|
|
|
public IReadOnlyList<string> NetworkPeers { get; init; } = [];
|
|
|
|
/// The call history file for this contest, or empty for none. They are
|
|
/// published per contest, so this is not a fixed name.
|
|
public string CallHistoryFile { get; init; } = "";
|
|
|
|
/// The serial port of an SO2R box speaking OTRSP, or empty for none. The
|
|
/// box routes the transmitter and the headphones between the two radios.
|
|
public string So2rBoxPort { get; init; } = "";
|
|
|
|
/// `none`, `cwdaemon` or `winkeyer`.
|
|
public string KeyerKind { get; init; } = "none";
|
|
|
|
public string KeyerHost { get; init; } = "127.0.0.1";
|
|
|
|
public int KeyerPort { get; init; } = 6789;
|
|
|
|
public string KeyerSerialPort { get; init; } = "";
|
|
|
|
public int KeyerSpeed { get; init; } = 28;
|
|
|
|
/// How long alternating CQ leaves between one message ending and the other
|
|
/// radio starting. N1MM asks for the same number and will not go below
|
|
/// 100 ms, because an SO2R box works relays.
|
|
public int AlternatingCqGapMs { get; init; } = 100;
|
|
|
|
/// How the WAE QTC window behaves. The names and the defaults are N1MM's:
|
|
/// it skips the confirm and ready steps out of the box, clears the header
|
|
/// when the header is asked for again, and leaves a line alone when the
|
|
/// line is.
|
|
public int QtcLinesPerSeries { get; init; } = 10;
|
|
|
|
public bool QtcSkipReady { get; init; } = true;
|
|
|
|
public bool QtcSkipConfirm { get; init; } = true;
|
|
|
|
public bool QtcHeaderAgainClears { get; init; } = true;
|
|
|
|
public bool QtcAgainClearsLine { get; init; }
|
|
|
|
/// The CW messages the QTC window sends, with N1MM's defaults. The spacing
|
|
/// is written N1MM's way, with `S` for a space. An empty TU message sends
|
|
/// nothing, which is N1MM's "Not Set".
|
|
public string QtcCwFieldSpacing { get; init; } = QtcMessages.DefaultFieldSpacing;
|
|
|
|
public string QtcCwTimeAgain { get; init; } = QtcMessages.DefaultTimeAgain;
|
|
|
|
public string QtcCwCallAgain { get; init; } = QtcMessages.DefaultCallAgain;
|
|
|
|
public string QtcCwNumberAgain { get; init; } = QtcMessages.DefaultNumberAgain;
|
|
|
|
public string QtcCwTu { get; init; } = "";
|
|
|
|
/// Sub-band boundaries the operator has changed. Empty means the defaults
|
|
/// in `BandPlan.Default`; an entry replaces one band's boundaries.
|
|
public IReadOnlyList<StoredSubBand> SubBands { get; init; } = [];
|
|
|
|
/// The function key messages as the text of an N1MM `.mc` file, which is
|
|
/// what the editor edits and what import and export read and write. Empty
|
|
/// means the built-in messages.
|
|
public string CwMessageFile { get; init; } = "";
|
|
|
|
public string PhoneMessageFile { get; init; } = "";
|
|
|
|
/// Written by versions that kept twelve messages and no labels. Read once,
|
|
/// to fill the file text in, and never written again.
|
|
public IReadOnlyList<string> CwMessages { get; init; } = [];
|
|
|
|
public IReadOnlyList<string> PhoneMessages { get; init; } = [];
|
|
|
|
/// The digital window's twenty-four macro buttons, in the same
|
|
/// `label,message` lines as a function key file. Empty means the built-in
|
|
/// set.
|
|
public string DigitalMessageFile { get; init; } = "";
|
|
|
|
public bool DigitalEnabled { get; init; }
|
|
|
|
/// The engine to run: MMTTY or 2Tone. The path is a Linux path; it is
|
|
/// handed to Wine in the form Wine expects. It starts at the copy shipped
|
|
/// beside the program.
|
|
public string DigitalEnginePath { get; init; } = ProgramFile("mmtty", "mmtty.exe");
|
|
|
|
public string DigitalBridgePath { get; init; } =
|
|
ProgramFile("bridge", "nonemm-mmtty-bridge.exe");
|
|
|
|
/// The XMMT.ocx the bridge hosts, as it sits beside the program. It is
|
|
/// only read when the control is registered in the Wine prefix; after that
|
|
/// the copy in the prefix is the one that is loaded.
|
|
public string DigitalControlPath { get; init; } = ProgramFile("bridge", "XMMT.ocx");
|
|
|
|
/// Empty means Wine's own default prefix.
|
|
public string DigitalWinePrefix { get; init; } = "";
|
|
|
|
public string DigitalWineCommand { get; init; } = "wine";
|
|
|
|
/// `Normal`, `Small`, `Medium1` or `Medium2`, which are MMTTY's four
|
|
/// window sizes.
|
|
public string DigitalEngineWindow { get; init; } = "Normal";
|
|
|
|
public bool DigitalEngineOnTop { get; init; } = true;
|
|
|
|
/// The port MMTTY keys FSK and PTT on. Empty reads it from Mmtty.INI, which
|
|
/// is where MMTTY itself keeps it.
|
|
public string DigitalPttPort { get; init; } = "";
|
|
|
|
/// The mark tone the Align button moves the engine to. MMTTY's own default.
|
|
public int DigitalMarkHertz { get; init; } = 2125;
|
|
|
|
/// Colour the callsign itself, or the ground behind it. N1MM offers both.
|
|
public bool DigitalHighlightBackground { get; init; }
|
|
|
|
/// `everything`, `notdupes` or `knowncalls`.
|
|
public string DigitalGrabFilter { get; init; } = "everything";
|
|
|
|
public bool DigitalGrabNewestFirst { get; init; } = true;
|
|
|
|
/// `multipliers`, `firstin`, `lastin` or `disabled`.
|
|
public string DigitalCallStacking { get; init; } = "disabled";
|
|
|
|
/// N1MM's right-click option: a right click in the receive pane presses
|
|
/// Enter in the entry window instead of opening the menu.
|
|
public bool DigitalRightClickSendsEnter { get; init; }
|
|
|
|
/// With the right click sending Enter, whether the call stays in the box
|
|
/// afterwards. N1MM's "don't drop call".
|
|
public bool DigitalRightClickKeepsCall { get; init; }
|
|
|
|
/// A grabbed call is followed by a space, which moves to the exchange and
|
|
/// fills the report in, the way typing one does.
|
|
public bool DigitalGrabSendsSpace { get; init; } = true;
|
|
|
|
/// N1MM caps the receive pane's font at 14 points.
|
|
public int DigitalFontSize { get; init; } = 12;
|
|
|
|
/// A file shipped beside the program. MMTTY and the bridge are distributed
|
|
/// with Nonemm, so the paths are filled in already; a file that is not
|
|
/// there leaves the setting empty and the operator browses for it.
|
|
private static string ProgramFile(string folder, string name)
|
|
{
|
|
string path = Path.Combine(AppContext.BaseDirectory, folder, name);
|
|
return File.Exists(path) ? path : "";
|
|
}
|
|
|
|
/// Reflection rather than a generated serializer: the generated one hands
|
|
/// back null for every property the file leaves out instead of the value
|
|
/// the property is declared with.
|
|
private static readonly JsonSerializerOptions Json = new() { WriteIndented = true };
|
|
|
|
public static Settings Load(string path)
|
|
{
|
|
if (!File.Exists(path))
|
|
{
|
|
return new Settings();
|
|
}
|
|
try
|
|
{
|
|
Settings read = JsonSerializer.Deserialize<Settings>(File.ReadAllText(path), Json) ?? new Settings();
|
|
FillNulls(read);
|
|
return Migrated(read);
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
// a settings file that will not parse is replaced rather than
|
|
// stopping the program before a contest
|
|
return new Settings();
|
|
}
|
|
}
|
|
|
|
/// The defaults with the operator's changes applied over them.
|
|
public BandPlan ToBandPlan()
|
|
{
|
|
BandPlan plan = BandPlan.Default;
|
|
foreach (StoredSubBand stored in SubBands)
|
|
{
|
|
if (stored.ToSegments() is { } segments)
|
|
{
|
|
plan = plan.With(segments);
|
|
}
|
|
}
|
|
return plan;
|
|
}
|
|
|
|
public void Save(string path) =>
|
|
File.WriteAllText(path, JsonSerializer.Serialize(this, Json));
|
|
|
|
/// A settings file can hold a null where the property is not nullable: a
|
|
/// file written before the property existed and then edited, or one an
|
|
/// older version wrote. JSON puts the null in and the property's own
|
|
/// default never runs, so the program reads a null it does not expect.
|
|
/// Every null is put back to the default the property declares, top to
|
|
/// bottom, so nothing downstream has to check.
|
|
private static void FillNulls(object target)
|
|
{
|
|
Type type = target.GetType();
|
|
object fresh = Activator.CreateInstance(type)!;
|
|
foreach (PropertyInfo property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
|
|
{
|
|
if (!property.CanWrite || property.GetIndexParameters().Length > 0)
|
|
{
|
|
continue;
|
|
}
|
|
object? value = property.GetValue(target);
|
|
if (value is null)
|
|
{
|
|
property.SetValue(target, property.GetValue(fresh));
|
|
}
|
|
else if (value is System.Collections.IEnumerable items and not string)
|
|
{
|
|
foreach (object? item in items)
|
|
{
|
|
FillOurOwn(item);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
FillOurOwn(value);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Only the types in this folder are walked into. A string, a number or a
|
|
/// framework type has nothing to fill in.
|
|
private static void FillOurOwn(object? value)
|
|
{
|
|
if (value is not null && value.GetType().Namespace == typeof(Settings).Namespace)
|
|
{
|
|
FillNulls(value);
|
|
}
|
|
}
|
|
|
|
/// The twelve messages an older settings file holds, written out as the
|
|
/// text of a function key file with the labels this program used then.
|
|
private static string FileFrom(IReadOnlyList<string> messages)
|
|
{
|
|
IReadOnlyList<(string Key, string Label)> keys =
|
|
[
|
|
("F1", "CQ"), ("F2", "Exch"), ("F3", "TU"), ("F4", "MyCall"),
|
|
("F5", "HisCall"), ("F6", "QSO B4"), ("F7", "?"), ("F8", "Agn"),
|
|
("F9", "Nr?"), ("F10", "Call?"), ("F11", "Spot"), ("F12", "Wipe"),
|
|
];
|
|
return string.Join(
|
|
'\n',
|
|
messages.Select((message, at) => at < keys.Count
|
|
? $"{keys[at].Key} {keys[at].Label},{message}"
|
|
: $",{message}"));
|
|
}
|
|
|
|
/// Carries what older settings files hold into the shape this one uses: the
|
|
/// single radio into the list of radios, and the twelve messages into the
|
|
/// text of a function key file.
|
|
private static Settings Migrated(Settings settings)
|
|
{
|
|
if (settings.RigctldHost.Length > 0 && settings.Radios.Count == 0)
|
|
{
|
|
settings = settings with
|
|
{
|
|
Radios = [new StoredRadio
|
|
{
|
|
Host = settings.RigctldHost,
|
|
Port = settings.RigctldPort,
|
|
IsEnabled = settings.RadioEnabled,
|
|
}],
|
|
RigctldHost = "",
|
|
RigctldPort = 0,
|
|
RadioEnabled = false,
|
|
};
|
|
}
|
|
if (settings.CwMessages.Count > 0 && settings.CwMessageFile.Length == 0)
|
|
{
|
|
settings = settings with { CwMessageFile = FileFrom(settings.CwMessages), CwMessages = [] };
|
|
}
|
|
if (settings.PhoneMessages.Count > 0 && settings.PhoneMessageFile.Length == 0)
|
|
{
|
|
settings = settings with
|
|
{
|
|
PhoneMessageFile = FileFrom(settings.PhoneMessages),
|
|
PhoneMessages = [],
|
|
};
|
|
}
|
|
if (settings.DigitalEnginePath.Length == 0)
|
|
{
|
|
settings = settings with { DigitalEnginePath = ProgramFile("mmtty", "mmtty.exe") };
|
|
}
|
|
if (settings.DigitalBridgePath.Length == 0)
|
|
{
|
|
settings = settings with
|
|
{
|
|
DigitalBridgePath = ProgramFile("bridge", "nonemm-mmtty-bridge.exe"),
|
|
};
|
|
}
|
|
if (settings.DigitalControlPath.Length == 0)
|
|
{
|
|
settings = settings with { DigitalControlPath = ProgramFile("bridge", "XMMT.ocx") };
|
|
}
|
|
return settings;
|
|
}
|
|
}
|
|
|
|
/// One band's sub-band boundaries as they are stored. Kilohertz, because that
|
|
/// is what the operator types into the editor.
|
|
public sealed record StoredSubBand
|
|
{
|
|
public string Band { get; init; } = "";
|
|
|
|
public double CwHigh { get; init; }
|
|
|
|
public double DigitalLow { get; init; }
|
|
|
|
public double DigitalHigh { get; init; }
|
|
|
|
/// Null when the band name is not one we know, or the CW boundary is
|
|
/// missing: an entry that cannot be read leaves the default in place.
|
|
public BandSegments? ToSegments() =>
|
|
Bands.Named(Band) is { } band && CwHigh > 0
|
|
? new BandSegments(
|
|
band,
|
|
Frequency.FromKilohertz(CwHigh),
|
|
Frequency.FromKilohertz(DigitalLow),
|
|
Frequency.FromKilohertz(DigitalHigh))
|
|
: null;
|
|
|
|
public static StoredSubBand From(BandSegments segments) => new()
|
|
{
|
|
Band = segments.Band.Name,
|
|
CwHigh = segments.CwHigh.Kilohertz,
|
|
DigitalLow = segments.DigitalLow.Kilohertz,
|
|
DigitalHigh = segments.DigitalHigh.Kilohertz,
|
|
};
|
|
}
|
|
|
|
/// One radio's `rigctld`.
|
|
public sealed record StoredRadio
|
|
{
|
|
public string Host { get; init; } = "127.0.0.1";
|
|
|
|
public int Port { get; init; } = 4532;
|
|
|
|
public bool IsEnabled { get; init; }
|
|
}
|
|
|
|
/// The operator's station as it is stored, kept separate from `StationInfo` so
|
|
/// the file format does not follow every change to the domain type.
|
|
public sealed record StoredStation
|
|
{
|
|
public string Callsign { get; init; } = "";
|
|
|
|
public string Address1 { get; init; } = "";
|
|
|
|
public string Address2 { get; init; } = "";
|
|
|
|
public string City { get; init; } = "";
|
|
|
|
public string Zip { get; init; } = "";
|
|
|
|
/// The country written out, as it goes on an award application. The prefix
|
|
/// the contest rules compare against is `CountryPrefix`.
|
|
public string Country { get; init; } = "";
|
|
|
|
public string License { get; init; } = "";
|
|
|
|
public double Latitude { get; init; }
|
|
|
|
public double Longitude { get; init; }
|
|
|
|
/// The radio, as N1MM's "Station" box asks for it.
|
|
public string Rig { get; init; } = "";
|
|
|
|
public string Antenna { get; init; } = "";
|
|
|
|
public string AntennaHeight { get; init; } = "";
|
|
|
|
/// Height above sea level.
|
|
public string AboveSeaLevel { get; init; } = "";
|
|
|
|
public string RoverQth { get; init; } = "";
|
|
|
|
public string Email { get; init; } = "";
|
|
|
|
public int CqZone { get; init; }
|
|
|
|
public int ItuZone { get; init; }
|
|
|
|
public string Continent { get; init; } = "";
|
|
|
|
public string CountryPrefix { get; init; } = "";
|
|
|
|
public string State { get; init; } = "";
|
|
|
|
public string Province { get; init; } = "";
|
|
|
|
public string ArrlSection { get; init; } = "";
|
|
|
|
public string GridSquare { get; init; } = "";
|
|
|
|
public string County { get; init; } = "";
|
|
|
|
public string Name { get; init; } = "";
|
|
|
|
public string Power { get; init; } = "";
|
|
|
|
public string Club { get; init; } = "";
|
|
|
|
public int Check { get; init; }
|
|
|
|
public string Precedence { get; init; } = "A";
|
|
|
|
public StationInfo ToStationInfo() => new()
|
|
{
|
|
Callsign = Callsign,
|
|
CqZone = CqZone,
|
|
ItuZone = ItuZone,
|
|
Continent = Continent,
|
|
CountryPrefix = CountryPrefix,
|
|
State = State,
|
|
Province = Province,
|
|
ArrlSection = ArrlSection,
|
|
GridSquare = GridSquare,
|
|
County = County,
|
|
Name = Name,
|
|
Power = Power,
|
|
Club = Club,
|
|
Check = Check,
|
|
Precedence = Precedence,
|
|
Latitude = Latitude,
|
|
Longitude = Longitude,
|
|
};
|
|
}
|