Files
Nonemm/src/Nonemm.App/Configuration/Settings.cs
T
erikbrockoandClaude Sonnet 5.5 45040dff83 Zoom the text of the log, check, available and bandmap windows
Ctrl+wheel and Ctrl+plus / Ctrl+minus change the text size of the
window in front, and Ctrl+0 puts it back. Each size is kept in the
settings file.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 11:37:35 +00:00

794 lines
30 KiB
C#

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<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;
/// 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<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; } = [];
/// 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<BroadcastKind, BroadcastChannel> BroadcastChannels() =>
new Dictionary<BroadcastKind, BroadcastChannel>
{
[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<string> 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<string> 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 &amp; 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<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; } = [];
/// 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<StoredOperator> 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<StoredOperator> 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<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 = [],
};
}
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,
};
}