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 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 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 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 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 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 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 CwMessages { get; init; } = []; public IReadOnlyList 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(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 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, }; }