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 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 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 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; } /// 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 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; } = []; /// 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(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 = [], }; } 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, }; }