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>
794 lines
30 KiB
C#
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 & 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,
|
|
};
|
|
}
|