The call frame. Above the callsign box, N1MM writes which spotted station the radio is sitting on, coloured like the bandmap colours it, or CQ-Frequency when this is the frequency the last CQ went out on. Space with an empty callsign box takes the call out of the frame and into the box, but only while searching: TabPressed does that when the run box is unchecked, because a running station is answering callers rather than chasing the one under its VFO. How close counts as sitting on it is N1MM's tuning tolerance, 300 Hz, now a setting. Sh/dx while unassisted. TelnetClient.Output refuses any command holding SH/DX when the entry is not assisted, and the telnet window does the same now. Whether an entry is assisted follows N1MM's own rule: assisted, multi-operator or checklog may use spots, a single operator who did not say assisted may not. The node's flavour. Click_LogonButton reads DXSpider, AR-Cluster, CC Cluster or GoCluster out of the banner; ClusterClient reads the same words and the window shows what it found. It changes nothing yet — the commands it would change are ones this does not send — but the node says so and now we know. CQ WW RTTY's state box. NextTab only stops on SectionText when the station is US or Canadian, because nobody else sends a state, so a contest can now say a box is not for this station and the walk steps over it. The box is still there to type in. The telnet button editor shows as many buttons as are defined, with an empty row to write the next one in, rather than N1MM's twelve. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
396 lines
14 KiB
C#
396 lines
14 KiB
C#
using System.Reflection;
|
|
using System.Text.Json;
|
|
using Nonemm.Core;
|
|
|
|
namespace Nonemm.App.Configuration;
|
|
|
|
/// What the program remembers between runs.
|
|
public sealed record Settings
|
|
{
|
|
public string DatabasePath { get; init; } = "";
|
|
|
|
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 asks for the same number, one per mode; this is
|
|
/// one number for all of them.
|
|
public int TuningToleranceHertz { get; init; } = 300;
|
|
|
|
/// 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; }
|
|
|
|
/// 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; } = [];
|
|
|
|
/// 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 = [],
|
|
};
|
|
}
|
|
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 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,
|
|
};
|
|
}
|