using System.Reflection; using System.Text.Json; using Nonemm.Core; using Nonemm.Keying; using Nonemm.Network; 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; /// The width of the slice the bandmap shows, in hertz. Zero is the whole band. public int BandmapSpanHertz { get; init; } = 40_000; /// The bandmap keeps the receiver in the middle instead of moving only when /// the receiver nears an edge. public bool BandmapCentred { get; init; } /// The height of the callsigns on the bandmap, in points. public int BandmapTextSize { get; init; } = 12; /// The text size of the log, check and available windows, in points. Ctrl /// with the mouse wheel or plus and minus changes the one in front. public int LogFontSize { get; init; } = 12; public int CheckFontSize { get; init; } = 14; public int AvailableFontSize { get; init; } = 13; /// 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; /// How far the radio has to move on the band, in hertz, with a callsign /// left in the entry window, before the call is spotted locally and the /// boxes are wiped. N1MM uses 2 kHz. public int QsySpotHertz { get; init; } = 2000; 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; } = []; /// Also send the contacts the other logging computers make to the programs /// that read the contact broadcast. N1MM's All Computers. public bool BroadcastAllComputers { get; init; } /// The other rows of Config > Broadcast Data. The contacts row is /// `NetworkEnabled` and `NetworkPeers`. public BroadcastChannel BroadcastAppInfo { get; init; } = new(); public BroadcastChannel BroadcastRadio { get; init; } = new(); public BroadcastChannel BroadcastSpots { get; init; } = new(); public BroadcastChannel BroadcastRotor { get; init; } = new(); public BroadcastChannel BroadcastScore { get; init; } = new(); public BroadcastChannel BroadcastLookup { get; init; } = new(); /// Copy the frequency, mode and split of a radio that another computer /// broadcasts. The data comes one way: this station never sends anything /// back to it. public bool MirrorRadioEnabled { get; init; } /// The UDP port the radio data arrives on. public int MirrorRadioPort { get; init; } = BroadcastTargets.DefaultPort; /// Only mirror the computer with this name. Empty follows any computer. public string MirrorRadioStation { get; init; } = ""; /// The radio number on the computer being mirrored. public int MirrorRadioSource { get; init; } = 1; /// The radio on this computer that does the mirroring. public int MirrorRadioTarget { get; init; } = 1; public IReadOnlyDictionary BroadcastChannels() => new Dictionary { [BroadcastKind.AppInfo] = BroadcastAppInfo, [BroadcastKind.Radio] = BroadcastRadio, [BroadcastKind.Spots] = BroadcastSpots, [BroadcastKind.Rotor] = BroadcastRotor, [BroadcastKind.Score] = BroadcastScore, [BroadcastKind.Lookup] = BroadcastLookup, }; /// The link to the other logging computers of a multi-operator entry, on /// N1MM's port 12070. It is not the same thing as `NetworkEnabled`, which /// is the XML broadcast on 12060 that other programs read; a station can /// want either, or both. public bool StationLinkEnabled { get; init; } public int StationLinkPort { get; init; } = StationBeacon.DefaultPort; /// Which station of the entry this computer is. It goes into every message /// and lets a contact say which position made it. public int StationNumber { get; init; } = 1; /// The version this program claims to be on the network. N1MM refuses a /// station whose version is not its own, so to work beside N1MM this has to /// be the version those copies are running. The default is the version of /// the N1MM this program was written against. public string StationLinkVersion { get; init; } = "1.0.11364"; /// Stations named by hand, for a network where a broadcast does not reach /// every computer. Each is a name and an address — `RUN-PC@192.168.1.5`, /// with `:port` on the end when that station is not on the usual port. public IReadOnlyList StationLinkPeers { get; init; } = []; /// Whether the contacts the other stations log are kept here. N1MM calls it /// Log QSOs at all Stations and every station of an entry normally has it /// on: a log per station is a log the sponsor has to be sent in pieces. public bool LogQsosAtAllStations { get; init; } = true; /// 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; /// What the voice keyer plays and records with. `{file}` in each command /// stands for the file. Empty means the program has nothing to play or /// record with, and the voice keyer window says so. public string VoicePlayCommand { get; init; } = CommandSoundCard.DefaultPlay; public string VoiceRecordCommand { get; init; } = CommandSoundCard.DefaultRecord; /// Whether the voice keyer keys the transmitter itself, through the same /// cwdaemon that keys CW. Turned off at a station whose radio hears the /// audio and keys itself on VOX. public bool VoiceKeysPtt { get; init; } = true; /// 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; } = ""; /// The RTTY messages the QTC window sends, with N1MM's defaults. The /// spacing goes between the lines of a series rather than between the /// fields of one, and all three are macro templates: `{ENTER}` for a /// carriage return, `{QTC}` for the header of the series. public string QtcRttySpacing { get; init; } = QtcMessages.DefaultRttySpacing; public string QtcRttySendAllHeading { get; init; } = QtcMessages.DefaultSendAllHeading; public string QtcRttySendAllEnding { get; init; } = QtcMessages.DefaultSendAllEnding; /// What goes out when the station reading traffic out closes the window, /// which is N1MM's `WAESSaveQTCText`. public string QtcRttySendSave { get; init; } = QtcMessages.DefaultRttySendSave; /// What the station reading traffic out asks before it starts, N1MM's /// `WAERUQRVText`. public string QtcRttyAreYouReady { get; init; } = QtcMessages.DefaultRttyAreYouReady; /// The four messages of the station taking traffic down, N1MM's /// `WAERXReadyText`, `WAEAllAgnText`, `WAEAGNText` and `WAESaveQTCText`. /// `$` in the line message stands for the line being asked for. public string QtcRttyReady { get; init; } = QtcMessages.DefaultRttyReady; public string QtcRttyAllAgain { get; init; } = QtcMessages.DefaultRttyAllAgain; public string QtcRttyLineAgain { get; init; } = QtcMessages.DefaultRttyLineAgain; public string QtcRttySave { get; init; } = QtcMessages.DefaultRttySave; /// The log window's columns, as `QsoField` names left to right. Empty means /// the set the contest opens with. public IReadOnlyList LogColumns { get; init; } = []; /// What the statistics window counts down the rows and across the columns. /// N1MM opens on the contacts per hand per hour, so this program does too. public string StatsRow { get; init; } = "Hour"; public string StatsColumn { get; init; } = "Band"; public string StatsPalette { get; init; } = "Berry"; /// N1MM's "Include Dupes & X-QSOs". public bool StatsIncludeDupes { get; init; } public bool StatsShowTitle { get; init; } = true; /// Which of the statistics window's two tabs was last open. public bool StatsOnGraph { 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 operators at this station, and their windows and colours. N1MM /// keeps an operator's callsign against every contact he logs; this keeps /// what he wants the program to look like as well. public IReadOnlyList Operators { get; init; } = []; /// Whoever is at the radio now, or empty at a station with one operator. public string CurrentOperator { get; init; } = ""; /// The operators who were last at the radio, newest first. `Operators` is /// kept in the order they took it, so the end of the list is the most /// recent. public IEnumerable RecentOperators(int count) => Operators.Reverse().Take(count); /// 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 mirrored by a space, which moves to the exchange and /// fills the report in, the way typing one does. public bool DigitalGrabSendsSpace { get; init; } = true; /// The speed the engine transmits at. The type-ahead buffer is paced by /// it, so a station running 75 baud sets it here as well as in the engine. /// MMTTY's own default, and what nearly every RTTY contest runs at. public double DigitalBaud { get; init; } = 45.45; /// N1MM caps the receive pane's font at 14 points. public int DigitalFontSize { get; init; } = 12; /// How many macro buttons the digital window shows. N1MM offers 0, 8, 16 /// or 24. public int DigitalMessageButtons { get; init; } = 24; /// N1MM's window scroll type. False keeps text where it was printed and /// underlines the line being printed to; true scrolls old text off the top. public bool DigitalScrolling { get; init; } = true; /// A click on a callsign is mirrored by a space, so the cursor moves to /// the exchange the way it does after a grab. public bool DigitalClickSendsSpace { get; init; } /// The pointer alone puts a callsign in the entry window, without a click. /// N1MM's hover mode. public bool DigitalHoverMode { get; init; } /// The pointer resting on a callsign stops the pane until it moves off or /// the call is clicked. N1MM's RX window callsign pause routines. public bool DigitalPauseOnCallsign { get; init; } /// What a callsign is checked against before it is highlighted and put in /// the grab list: `generic` takes anything shaped like a call, `resources` /// takes only calls in the files ticked below, and `both` takes anything /// but marks in yellow what the files do not hold. public string DigitalHighlightRoutine { get; init; } = "generic"; public bool DigitalUseMasterScp { get; init; } = true; public bool DigitalUseCallHistory { get; init; } public bool DigitalUseTelnetCalls { get; init; } public bool DigitalUseLoggedCalls { get; init; } /// Look for a known call inside a run of noise, not only between spaces. public bool DigitalSearchGarbage { get; init; } /// Only calls in Master.scp or already worked this contest go in the grab /// list. public bool DigitalGrabKnownOnly { get; init; } public bool DigitalClearGrabOnCq { get; init; } public bool DigitalClearGrabOnQsy { get; init; } /// Send MMTTY's ham default when the operator leaves run for search and /// pounce, which puts the mark tone back. public bool DigitalHamDefaultOnSearch { get; init; } /// Turn AFC off on the same change, so the receiver stops following a /// drifting signal away from the transmit frequency. public bool DigitalAfcOffOnSearch { get; init; } /// Turn AFC on when a CQ goes out, which pulls in a caller who is off /// frequency. public bool DigitalAfcOnCq { get; init; } /// The window title carries the radio's dial frequency rather than the /// dial frequency with the audio tone added to it. public bool DigitalRadioFrequencyInTitle { get; init; } /// Raise the digital window with the entry window it belongs to. public bool DigitalBringToForeground { get; init; } /// Write what the receive pane prints to a date-stamped file in /// `ExportFiles`, the way N1MM does. public bool DigitalOutputToTextFile { get; init; } /// 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, RoverQth = RoverQth, Check = Check, Precedence = Precedence, Latitude = Latitude, Longitude = Longitude, }; }