Files
Nonemm/src/Nonemm.App/Configuration/Settings.cs
ericek111 d347538fbc Lay the station form out the way N1MM lays it out
The dialog now carries N1MM's fields in N1MM's order, with the labels to
the left of the boxes and the tip text at the top right. New fields in
StoredStation: the address, the licence class, the position, the radio,
the antenna and the email address.

The grid square and the position fill each other in, as they do in N1MM.
Which box has focus decides the direction, because TextChanged arrives
too late to tell a typed edit from a programmed one.

The fields N1MM's form does not have are gone from it. Continent and the
country prefix are looked up from the callsign after Ok; the rest keep
the value they had.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 11:32:22 +00:00

440 lines
15 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 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; }
/// 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 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,
};
}