A QSO logged in Run mode is no longer added to the bandmap as a local spot, including when recent contacts are re-spotted at startup. Releasing Alt moves focus to the menu bar. Alt+W now sets a flag, and the Alt key-up handler puts focus back on the callsign box. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1161 lines
44 KiB
C#
1161 lines
44 KiB
C#
using System.Collections.Concurrent;
|
|
using Avalonia.Threading;
|
|
using Nonemm.App.Configuration;
|
|
using Nonemm.Contests;
|
|
using Nonemm.Core;
|
|
using Nonemm.Core.Calls;
|
|
using Nonemm.Core.Country;
|
|
using Nonemm.Digital;
|
|
using Nonemm.Keying;
|
|
using Nonemm.Network;
|
|
using Nonemm.Rig;
|
|
using Nonemm.Session;
|
|
using Nonemm.Spotting;
|
|
using Nonemm.Storage;
|
|
|
|
namespace Nonemm.App;
|
|
|
|
/// Everything the running program owns: the log store, the contest in progress,
|
|
/// the bandmap, the radio and the cluster. The windows read from this and one
|
|
/// `Changed` event tells them all to redraw.
|
|
public sealed partial class AppSession : IDisposable
|
|
{
|
|
/// How often this station tells the others how it is doing. N1MM sends its
|
|
/// status on the same sort of interval; every second would read the log
|
|
/// sixty times a minute for two numbers that barely move.
|
|
private static readonly TimeSpan StatusInterval = TimeSpan.FromSeconds(5);
|
|
|
|
private LogStore? store;
|
|
private readonly List<RigctldRadio> radios = [];
|
|
private readonly List<OperatingPosition> positions = [];
|
|
private int activeRadio;
|
|
private ClusterClient? cluster;
|
|
private StationNetwork? network;
|
|
private StationLink? link;
|
|
|
|
/// What was last said to the other stations about where this one is, so it
|
|
/// is said again only when it changes.
|
|
private (Frequency Where, string Mode, bool Running, int Radio) announced;
|
|
/// When the last `STATUS` went out. It is not sent every second: it carries
|
|
/// the rates, which are read out of the log.
|
|
private DateTime statusSent;
|
|
private DispatcherTimer? announcing;
|
|
private MessageSender? keyer;
|
|
private VoiceKeyer? voice;
|
|
private CancellationTokenSource? recordingStop;
|
|
private SoundCard sound = new CommandSoundCard(
|
|
CommandSoundCard.DefaultPlay,
|
|
CommandSoundCard.DefaultRecord);
|
|
private AlternatingCq? alternating;
|
|
private MmttyEngine? digital;
|
|
private DigitalEngineSender? digitalSender;
|
|
private So2rBox? box;
|
|
|
|
/// Which cluster spots reach the bandmap. Rebuilt whenever the settings or
|
|
/// the support files change.
|
|
private SpotFilter spotFilter = new();
|
|
|
|
private readonly Random jitter = new();
|
|
|
|
/// Spots wait here until the next flush. N1MM collects them the same way,
|
|
/// because a skimmer feed sends more spots a minute than any window can
|
|
/// usefully redraw.
|
|
private readonly List<Spot> arriving = [];
|
|
private readonly Lock arrivingGuard = new();
|
|
private readonly Timer spotFlush;
|
|
|
|
public AppSession(UserPaths paths, Settings settings)
|
|
{
|
|
Paths = paths;
|
|
Settings = settings;
|
|
paths.CreateFolders();
|
|
Recordings = new VoiceFiles(paths.Wav);
|
|
Countries = LoadCountryFile(paths.CountryFile);
|
|
Calls = LoadCallDatabase(paths.CallDatabaseFile);
|
|
History = LoadCallHistory(settings.CallHistoryFile);
|
|
Registry = ContestRegistry.FromFolder(
|
|
paths.UserDefinedContests,
|
|
out IReadOnlyList<string> problems,
|
|
paths.SupportFiles);
|
|
UserDefinedContestProblems = problems;
|
|
BandPlan = settings.ToBandPlan();
|
|
ApplySpotSettings();
|
|
spotFlush = new Timer(_ => FlushSpots(), null, SpotFlushInterval, SpotFlushInterval);
|
|
}
|
|
|
|
/// How often the spots that have arrived go onto the bandmap. N1MM's own
|
|
/// queue is flushed once a second.
|
|
private static readonly TimeSpan SpotFlushInterval = TimeSpan.FromSeconds(1);
|
|
|
|
public UserPaths Paths { get; }
|
|
|
|
public Settings Settings { get; private set; }
|
|
|
|
public ContestRegistry Registry { get; private set; }
|
|
|
|
/// Where the bandmap paints the CW, digital and phone parts of a band.
|
|
public BandPlan BandPlan { get; private set; }
|
|
|
|
public IReadOnlyList<string> UserDefinedContestProblems { get; }
|
|
|
|
public CountryFile? Countries { get; private set; }
|
|
|
|
public CallDatabase Calls { get; private set; }
|
|
|
|
/// What was published about the stations in this contest. Empty when no
|
|
/// file is chosen, and then nothing is filled in for the operator.
|
|
public CallHistory History { get; private set; } = CallHistory.Empty;
|
|
|
|
public Bandmap Bandmap { get; } = new();
|
|
|
|
/// Where the operator was last on each band and mode, for the band buttons.
|
|
public BandMemory BandMemory { get; } = new();
|
|
|
|
/// The contest in progress: one log and one score, however many radios.
|
|
public ContestSession? Logging { get; private set; }
|
|
|
|
/// The radio the operator is not on, or null at a one-radio station. The
|
|
/// message macros that pass a station to the other band need it.
|
|
public OperatingPosition? Other(OperatingPosition position) =>
|
|
positions.FirstOrDefault(p => p.RadioNumber != position.RadioNumber);
|
|
|
|
/// One per radio, in radio-number order. There is always at least one, so
|
|
/// the program works with no radio connected.
|
|
public IReadOnlyList<OperatingPosition> Positions => positions;
|
|
|
|
/// The radio the operator is on.
|
|
public OperatingPosition? Position =>
|
|
activeRadio < positions.Count ? positions[activeRadio] : null;
|
|
|
|
public CheckWindowSources? Check { get; private set; }
|
|
|
|
/// Where a line of received text goes while the QTC window is taking
|
|
/// traffic down on RTTY. The other station sends the header and the whole
|
|
/// series in one transmission, and the operator clicks each line in the
|
|
/// digital window rather than typing it, which is how N1MM works. The QTC
|
|
/// window sets this while it is open and returns whether the line went
|
|
/// into it.
|
|
public Func<string, bool>? QtcLines { get; set; }
|
|
|
|
/// What is spotted and not worked yet.
|
|
public AvailableStations? Available { get; private set; }
|
|
|
|
/// One entry per configured radio, in radio-number order.
|
|
public IReadOnlyList<Radio> Radios => radios;
|
|
|
|
/// The radio the operator is on. Null when none is configured, and then
|
|
/// frequency and mode stay wherever they were last typed.
|
|
public Radio? Radio => activeRadio < radios.Count ? radios[activeRadio] : null;
|
|
|
|
public int ActiveRadioNumber => Position?.RadioNumber ?? 1;
|
|
|
|
public ClusterClient? Cluster => cluster;
|
|
|
|
public StationNetwork? Network => network;
|
|
|
|
/// The link to the other logging computers, or null when the operator has
|
|
/// not turned it on. The network status window reads it.
|
|
public StationLink? Link => link;
|
|
|
|
public MessageSender? Keyer => keyer;
|
|
|
|
/// What sends a message on that mode: the digital engine on a digital
|
|
/// mode, the voice keyer on phone, the CW keyer otherwise. Everything that
|
|
/// sends asks here, so a message always goes out the way the mode needs.
|
|
public MessageSender? SenderFor(ModeCategory mode) => mode switch
|
|
{
|
|
ModeCategory.Digital when digitalSender is { IsReady: true } engine => engine,
|
|
ModeCategory.Phone when voice is { IsReady: true } speaking => speaking,
|
|
_ => keyer,
|
|
};
|
|
|
|
/// What plays the recordings on phone. It is there whether or not there is
|
|
/// a CW keyer: the recordings go out of the sound card, not the keying
|
|
/// port.
|
|
public VoiceKeyer? Voice => voice;
|
|
|
|
/// The recordings under the `Wav` folder, for the voice keyer and for the
|
|
/// window that records them.
|
|
public VoiceFiles Recordings { get; }
|
|
|
|
/// What plays and records a wav file. The voice keyer sends through it and
|
|
/// the voice keyer window auditions through it.
|
|
public SoundCard Sound => sound;
|
|
|
|
/// The recording being made, or null. It is here rather than in the window
|
|
/// that started it because there is one microphone: the voice keyer window
|
|
/// and the entry window's Ctrl+Shift+F keys record into the same one.
|
|
public string? Recording { get; private set; }
|
|
|
|
/// Records over the file named under `Wav` until `StopRecording`, and
|
|
/// answers with what to tell the operator: the file recorded, or why
|
|
/// nothing was.
|
|
public async Task<string> RecordAsync(string name)
|
|
{
|
|
if (Recording is { } already)
|
|
{
|
|
return $"{already} is being recorded — stop that first";
|
|
}
|
|
recordingStop = new CancellationTokenSource();
|
|
Recording = name;
|
|
Changed?.Invoke(this, EventArgs.Empty);
|
|
try
|
|
{
|
|
await sound.RecordAsync(Recordings.PathOf(name), recordingStop.Token)
|
|
.ConfigureAwait(false);
|
|
return $"recorded {name}";
|
|
}
|
|
catch (Exception failure) when (failure is InvalidOperationException or IOException)
|
|
{
|
|
return failure.Message;
|
|
}
|
|
finally
|
|
{
|
|
recordingStop.Dispose();
|
|
recordingStop = null;
|
|
Recording = null;
|
|
Changed?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
}
|
|
|
|
/// Ends the recording, which is what saves it: the recorder is asked to
|
|
/// stop so it can close the file it is writing.
|
|
public void StopRecording() => recordingStop?.Cancel();
|
|
|
|
/// The digital modem, started by the digital window rather than at startup:
|
|
/// it runs an engine under Wine, which is not something to do to an
|
|
/// operator who is working CW.
|
|
public MmttyEngine? Digital => digital;
|
|
|
|
/// The same engine as something a macro can be sent through.
|
|
public DigitalEngineSender? DigitalKeyer => digitalSender;
|
|
|
|
/// Alternating CQ, or null while there is no keyer. It needs two radios to
|
|
/// do anything, and a keyer that reports when a message has gone out.
|
|
public AlternatingCq? Alternating => alternating;
|
|
|
|
/// The SO2R box, or null when there is none and the operator switches the
|
|
/// transmitter and the headphones by hand.
|
|
public So2rBox? Box => box;
|
|
|
|
/// True while both radios are in the headphones.
|
|
public bool IsListeningToBoth { get; private set; }
|
|
|
|
public event EventHandler? Changed;
|
|
|
|
public event EventHandler? ContestChanged;
|
|
|
|
/// What a keyer had to say while a message was going out — a recording
|
|
/// that would not play, most of the time. The entry window puts it in the
|
|
/// status line: by then the caller has been answered and there is nothing
|
|
/// left to throw at.
|
|
public event EventHandler<string>? Reported;
|
|
|
|
public void Save(Settings settings)
|
|
{
|
|
Settings = settings;
|
|
BandPlan = settings.ToBandPlan();
|
|
ApplySpotSettings();
|
|
settings.Save(Paths.SettingsFile);
|
|
Changed?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
|
|
public void OpenDatabase(string path)
|
|
{
|
|
bool sameFile = string.Equals(Settings.DatabasePath, path, StringComparison.Ordinal);
|
|
store?.Dispose();
|
|
store = SqliteLogStore.Open(path);
|
|
Logging = null;
|
|
Check = null;
|
|
Available = null;
|
|
Save(Settings with
|
|
{
|
|
DatabasePath = path,
|
|
ContestNumber = sameFile ? Settings.ContestNumber : 0,
|
|
});
|
|
ContestChanged?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
|
|
public LogStore Store =>
|
|
store ?? throw new InvalidOperationException("no log database is open");
|
|
|
|
public ContestInstance StartContest(ContestInstance instance)
|
|
{
|
|
ContestInstance stored = Store.AddContest(instance);
|
|
OpenContest(stored.ContestNumber);
|
|
return stored;
|
|
}
|
|
|
|
/// N1MM reads the `Contest` table when it opens a log, so the definition is
|
|
/// written again whenever a contest is opened, not only when it is created.
|
|
private void SaveDefinition(Contest contest) =>
|
|
Store.SaveContestDefinition(
|
|
ContestDefinitions.For(contest, Settings.Station.ToStationInfo()));
|
|
|
|
public void OpenContest(int contestNumber)
|
|
{
|
|
ContestInstance instance = Store.Contest(contestNumber)
|
|
?? throw new InvalidOperationException($"no contest numbered {contestNumber} in the log");
|
|
Contest contest = Registry.Create(instance.ContestName, ModeCategoryOf(instance));
|
|
SaveDefinition(contest);
|
|
Logging = new ContestSession(
|
|
Store, contest, instance, Settings.Station.ToStationInfo(), Countries, History)
|
|
{
|
|
// whoever was at the radio when the last contest was open is still
|
|
// there, and every contact he logs carries his callsign
|
|
Operator = Settings.CurrentOperator,
|
|
// N1MM stamps every contact with the computer that made it and the
|
|
// station number of that computer, and a resync asks for the
|
|
// contacts of one computer by name
|
|
StationName = ComputerName,
|
|
StationNumber = Settings.StationNumber,
|
|
};
|
|
positions.Clear();
|
|
for (int number = 1; number <= PositionCount; number++)
|
|
{
|
|
positions.Add(new OperatingPosition(Logging, number) { CqLeaveHertz = Settings.CqLeaveHertz });
|
|
}
|
|
activeRadio = Math.Min(activeRadio, positions.Count - 1);
|
|
// the other stations refuse to resynchronise with a station on another
|
|
// contest, and say so, which is only any use if they are told
|
|
_ = link?.SendContestNameAsync();
|
|
// and the highest serial number taken on each band, so a station that
|
|
// joins a contest already under way starts where the others are. N1MM
|
|
// sends the same message at the same point
|
|
_ = link?.SendSerialNumbersAsync(Logging.Numbers.Snapshot());
|
|
Logging.Changed += (_, _) => Changed?.Invoke(this, EventArgs.Empty);
|
|
Logging.Logged += (_, qso) =>
|
|
{
|
|
// a WAE QTC row is traffic about a station, not a station on the air;
|
|
// a Run QSO is a station that called us, so it is not a spot to work
|
|
if (contest.IsContact(qso) && !qso.IsRunQso)
|
|
{
|
|
Bandmap.Add(new Spot(qso.Call, qso.Frequency, qso.TimestampUtc, SpotSource.Log));
|
|
}
|
|
};
|
|
Logging.Logged += (_, qso) => _ = network?.SendAsync(qso, Settings.Station.Callsign);
|
|
Logging.Edited += (_, change) => _ = network?.SendEditAsync(
|
|
change.Qso, Settings.Station.Callsign, change.OldCall, change.OldTimestampUtc);
|
|
Logging.Deleted += (_, qso) => _ = network?.SendDeleteAsync(qso, Settings.Station.Callsign);
|
|
// and the same three to the other logging computers, which is a
|
|
// different protocol on a different port
|
|
Logging.Logged += (_, qso) => _ = link?.SendLoggedAsync(qso);
|
|
// the number is announced when it is taken rather than when the contact
|
|
// is logged, which is what keeps two operators off the same number
|
|
Logging.Numbers.Reserved += (_, taken) =>
|
|
_ = link?.SendReservedNumberAsync(taken.Number, SomewhereOn(taken.Band));
|
|
Logging.Edited += (_, change) =>
|
|
_ = link?.SendEditedAsync(change.Qso, change.OldCall, change.OldTimestampUtc);
|
|
Logging.Deleted += (_, qso) => _ = link?.SendDeletedAsync(qso);
|
|
SpotRecentContacts(Logging.Log.Qsos);
|
|
Check = new CheckWindowSources(positions[0], Calls, Bandmap);
|
|
Available = new AvailableStations(positions[0], Bandmap);
|
|
Save(Settings with { ContestNumber = contestNumber });
|
|
ContestChanged?.Invoke(this, EventArgs.Empty);
|
|
SendAppInfo();
|
|
}
|
|
|
|
/// A frequency on the band a number was taken on. The message carries a
|
|
/// frequency because N1MM's does, and both ends read nothing off it but the
|
|
/// band.
|
|
private static Frequency SomewhereOn(Band? band) => band?.Low ?? Frequency.Zero;
|
|
|
|
/// One entry position per radio, and one when there is no radio at all.
|
|
private int PositionCount => Math.Max(1, Settings.Radios.Count(r => r.IsEnabled));
|
|
|
|
/// Starts, restarts or stops the link to the other stations of a
|
|
/// multi-operator entry, following what the settings now say.
|
|
public void ApplyNetworkSettings()
|
|
{
|
|
network?.Dispose();
|
|
network = null;
|
|
if (!Settings.NetworkEnabled)
|
|
{
|
|
Changed?.Invoke(this, EventArgs.Empty);
|
|
return;
|
|
}
|
|
network = new StationNetwork(
|
|
Settings.NetworkPort,
|
|
Settings.NetworkStationName.Length > 0 ? Settings.NetworkStationName : Environment.MachineName,
|
|
Settings.NetworkPeers);
|
|
// the socket thread must not touch the log: every other change to it is
|
|
// made where the windows read it
|
|
network.UpdateArrived += (_, update) => QueueFromNetwork(update);
|
|
network.Start();
|
|
Changed?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
|
|
/// Starts, restarts or stops the link to the other logging computers. It is
|
|
/// separate from `ApplyNetworkSettings` because the two are separate
|
|
/// networks: one carries XML to other programs, the other carries contacts
|
|
/// to the other computers of this entry.
|
|
/// What this computer is called on the network of the entry, which is also
|
|
/// what every contact it logs is stamped with.
|
|
public string ComputerName =>
|
|
Settings.NetworkStationName.Length > 0 ? Settings.NetworkStationName : Environment.MachineName;
|
|
|
|
public void ApplyStationLinkSettings()
|
|
{
|
|
announcing?.Stop();
|
|
link?.Dispose();
|
|
link = null;
|
|
if (!Settings.StationLinkEnabled)
|
|
{
|
|
Changed?.Invoke(this, EventArgs.Empty);
|
|
return;
|
|
}
|
|
link = new StationLink(
|
|
ComputerName,
|
|
Settings.StationLinkVersion,
|
|
Settings.StationNumber,
|
|
Settings.StationLinkPort)
|
|
{
|
|
Operator = Settings.Station.Callsign,
|
|
};
|
|
// the socket thread must not touch the log: every other change to it is
|
|
// made where the windows read it
|
|
link.UpdateArrived += (_, update) => QueueFromNetwork(update);
|
|
link.UpdateArrived += (_, update) => ForwardToPrograms(update);
|
|
// the resync reads the log rather than being handed it, and another
|
|
// station can ask for a rescore when its resync is done
|
|
link.Log = new SessionContactLog(this);
|
|
link.RescoreRequested += (_, _) =>
|
|
Dispatcher.UIThread.Post(() => Rescore(DateTime.MinValue));
|
|
link.SerialNumbersReset += (_, _) =>
|
|
Dispatcher.UIThread.Post(ResetSerialNumbers);
|
|
link.SpotArrived += (_, shared) =>
|
|
Dispatcher.UIThread.Post(() => TakeSharedSpot(shared));
|
|
link.CallStacked += (_, stacked) =>
|
|
Dispatcher.UIThread.Post(() => TakeStackedCall(stacked));
|
|
link.NumberReserved += (_, taken) =>
|
|
Dispatcher.UIThread.Post(() => TakeReservedNumber(taken));
|
|
link.SerialNumbersArrived += (_, numbers) =>
|
|
Dispatcher.UIThread.Post(() => Logging?.Numbers.Merge(numbers));
|
|
foreach (string peer in Settings.StationLinkPeers)
|
|
{
|
|
if (Peer(peer) is var (name, address, port))
|
|
{
|
|
link.AddStation(name, address, port);
|
|
}
|
|
}
|
|
link.Start();
|
|
// where this station is has a dozen places it can change from — the
|
|
// radio moving, a band button, a QSY typed into the callsign box, run
|
|
// turning on — so it is read once a second and sent when it has
|
|
// changed, rather than announced from each of them
|
|
announcing?.Stop();
|
|
announced = default;
|
|
statusSent = default;
|
|
announcing = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
|
|
announcing.Tick += (_, _) =>
|
|
{
|
|
AnnounceWhereIAm();
|
|
SayHowIAmDoing();
|
|
};
|
|
announcing.Start();
|
|
Changed?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
|
|
/// Tells the other stations where this one is, if it has moved. The
|
|
/// frequency goes out on its own — N1MM's `FREQ` — and everything else
|
|
/// about the position rides in the `STATUS` that follows it. In a
|
|
/// multi-single entry this is what the other operators watch: two stations
|
|
/// on one band is a contact nobody can make.
|
|
private void AnnounceWhereIAm()
|
|
{
|
|
if (link is null || Position is not { } position)
|
|
{
|
|
return;
|
|
}
|
|
(Frequency, string, bool, int) now =
|
|
(position.Frequency, position.Mode.Name, position.IsRunning, position.RadioNumber);
|
|
if (now == announced)
|
|
{
|
|
return;
|
|
}
|
|
bool moved = announced.Where != position.Frequency;
|
|
announced = now;
|
|
if (moved)
|
|
{
|
|
_ = link.SendFrequencyAsync(position.Frequency);
|
|
}
|
|
// the mode, run and the rest of it ride in `STATUS`, which is sent
|
|
// again now rather than waiting out the rest of its interval
|
|
statusSent = default;
|
|
}
|
|
|
|
/// True while this station is the second run radio of a two-radio
|
|
/// position, which is N1MM's Run1/Run2 switch on the network status window.
|
|
/// It goes out in every `STATUS` and is what the other stations read in
|
|
/// their `R/M` column.
|
|
public bool IsRun2
|
|
{
|
|
get;
|
|
set
|
|
{
|
|
field = value;
|
|
statusSent = default;
|
|
Changed?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
}
|
|
|
|
/// Starts the serial numbers again from the log: the next number becomes
|
|
/// the highest one in it plus one. The contacts already logged keep the
|
|
/// numbers they were sent with. Nothing is sent but `RESETQSONRS`, as in
|
|
/// N1MM: each station works the same number out of its own copy of the log.
|
|
public void ResetSerialNumbers()
|
|
{
|
|
if (Logging is not null)
|
|
{
|
|
Logging.RestartNumbers();
|
|
Changed?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
}
|
|
|
|
/// A serial number another station has taken. It is noted here so this
|
|
/// station counts past it. A number at or below one this station has
|
|
/// already used says the sender is behind, and it is sent the highest
|
|
/// number for each band so it can count on from the right one, which is
|
|
/// what N1MM answers with.
|
|
private void TakeReservedNumber(ReservedNumberMessage taken)
|
|
{
|
|
if (Logging is not { } logging)
|
|
{
|
|
return;
|
|
}
|
|
if (!logging.Numbers.Note(taken.Number, Bands.ForFrequency(taken.Frequency)))
|
|
{
|
|
_ = link?.SendSerialNumbersAsync(logging.Numbers.Snapshot());
|
|
}
|
|
Changed?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
|
|
/// A callsign another station handed to this one. It goes on the call stack
|
|
/// of the radio the operator is on, which is where the entry window takes
|
|
/// them from.
|
|
private void TakeStackedCall(StackedCall stacked)
|
|
{
|
|
if (Position is not { } position || stacked.Call.Length == 0)
|
|
{
|
|
return;
|
|
}
|
|
position.Stack.Add(stacked.Call, IsNewMultiplier(stacked));
|
|
Changed?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
|
|
/// Whether a stacked call is worth a multiplier here, which is what decides
|
|
/// where it goes in the stack. A call with no contact behind it is judged
|
|
/// on the callsign alone.
|
|
private bool IsNewMultiplier(StackedCall stacked) =>
|
|
Logging is not null
|
|
&& (stacked.Contact is { } contact
|
|
? Logging.Log.Judge(contact).NewMultipliers.Count > 0
|
|
: Logging.Log.Judge(new Qso
|
|
{
|
|
Id = "",
|
|
TimestampUtc = DateTime.UtcNow,
|
|
Call = Callsign.Parse(stacked.Call),
|
|
Frequency = Position?.Frequency ?? Frequency.Zero,
|
|
Mode = Position?.Mode ?? Modes.Cw,
|
|
ContestName = Logging.Contest.Name,
|
|
}).NewMultipliers.Count > 0);
|
|
|
|
/// Scores the log again and writes the points and multiplier flags from
|
|
/// `fromUtc` on back to the database. Returns how many rows were written.
|
|
/// Another station asks for this at the end of a resync, and the rescore
|
|
/// tab of the network status window asks for it by hand.
|
|
public int Rescore(DateTime fromUtc) => Logging?.Rescore(fromUtc) ?? 0;
|
|
|
|
/// The `STATUS` message: everything the other operators' network status
|
|
/// windows show about this station, whether anything has changed or not. It
|
|
/// goes out on N1MM's own interval, and the rates in it are read out of the
|
|
/// log each time.
|
|
private void SayHowIAmDoing()
|
|
{
|
|
if (link is null || Position is not { } position
|
|
|| DateTime.UtcNow - statusSent < StatusInterval)
|
|
{
|
|
return;
|
|
}
|
|
statusSent = DateTime.UtcNow;
|
|
TimeSpan off = Logging?.Contest.MinimumOffTime ?? TimeSpan.FromMinutes(30);
|
|
IReadOnlyList<Qso> qsos = Logging?.Log.Qsos ?? [];
|
|
_ = link.SendStatusAsync(new StationStatus(
|
|
link.Me.PassFrequency,
|
|
position.Frequency,
|
|
position.IsRunning,
|
|
link.Operator,
|
|
position.Mode,
|
|
Rates.PerHour(qsos, 10, DateTime.UtcNow, off),
|
|
Rates.PerHour(qsos, 100, DateTime.UtcNow, off),
|
|
Logging?.Instance.OperatorCategory ?? "",
|
|
Logging?.Instance.TransmitterCategory ?? "",
|
|
Countries?.Version ?? "",
|
|
IsRun2));
|
|
}
|
|
|
|
/// A station named by hand, written `RUN-PC@192.168.1.5` with `:port` on
|
|
/// the end when that station is not on the usual one. Null for anything
|
|
/// else, because a line the operator has half typed is not an address.
|
|
private (string Name, string Address, int Port)? Peer(string text)
|
|
{
|
|
string[] parts = text.Split('@');
|
|
if (parts.Length != 2 || parts[0].Trim().Length == 0)
|
|
{
|
|
return null;
|
|
}
|
|
string[] host = parts[1].Split(':');
|
|
return (
|
|
parts[0].Trim(),
|
|
host[0].Trim(),
|
|
host.Length > 1 && int.TryParse(host[1], out int given) ? given : Settings.StationLinkPort);
|
|
}
|
|
|
|
/// Updates that have arrived from the network and not yet been applied.
|
|
/// They are applied together on the window thread, so a resync that sends
|
|
/// a few hundred contacts rescores the log and redraws the windows once
|
|
/// per batch rather than once per contact.
|
|
private readonly ConcurrentQueue<ContactUpdate> fromNetwork = new();
|
|
|
|
private int networkDrainPosted;
|
|
|
|
private void QueueFromNetwork(ContactUpdate update)
|
|
{
|
|
fromNetwork.Enqueue(update);
|
|
if (Interlocked.Exchange(ref networkDrainPosted, 1) == 0)
|
|
{
|
|
Dispatcher.UIThread.Post(TakeQueuedFromNetwork);
|
|
}
|
|
}
|
|
|
|
private void TakeQueuedFromNetwork()
|
|
{
|
|
// cleared first: an update that arrives while this runs posts a new drain
|
|
Interlocked.Exchange(ref networkDrainPosted, 0);
|
|
List<ContactUpdate> batch = [];
|
|
while (fromNetwork.TryDequeue(out ContactUpdate? update))
|
|
{
|
|
batch.Add(update);
|
|
}
|
|
TakeFromNetwork(batch);
|
|
}
|
|
|
|
/// What other stations did to their logs, applied to ours in the order it
|
|
/// arrived. Nothing goes back out to the network, and the contacts are
|
|
/// scored here from the rules instead of trusting what the sender put in
|
|
/// the message.
|
|
private void TakeFromNetwork(IReadOnlyList<ContactUpdate> updates)
|
|
{
|
|
if (Logging is null)
|
|
{
|
|
return;
|
|
}
|
|
List<Qso> logged = [];
|
|
foreach (ContactUpdate update in updates)
|
|
{
|
|
if (!Settings.LogQsosAtAllStations && update is not ContactsCleared)
|
|
{
|
|
continue;
|
|
}
|
|
if (update is ContactLogged contact)
|
|
{
|
|
logged.Add(contact.Qso);
|
|
continue;
|
|
}
|
|
// a run of new contacts is applied before the update that follows
|
|
// it, because that update may be a clear or an edit of one of them
|
|
Logging.AddRangeFromNetwork(logged);
|
|
SpotRecentContacts(logged);
|
|
logged.Clear();
|
|
switch (update)
|
|
{
|
|
case ContactReplaced replaced:
|
|
Logging.ReplaceFromNetwork(replaced.Qso, replaced.OldCall, replaced.OldTimestampUtc);
|
|
break;
|
|
case ContactDeleted deleted:
|
|
Logging.DeleteFromNetwork(deleted.Id, deleted.Call, deleted.TimestampUtc);
|
|
break;
|
|
case ContactsCleared cleared:
|
|
Logging.ClearFromNetwork(cleared.StationName, cleared.FromUtc, cleared.Length);
|
|
break;
|
|
}
|
|
}
|
|
Logging.AddRangeFromNetwork(logged);
|
|
SpotRecentContacts(logged);
|
|
}
|
|
|
|
/// Puts the contacts of the last few minutes on the bandmap as local spots,
|
|
/// so a restart does not lose the stations just worked. A station the
|
|
/// bandmap already holds on that band keeps its spot. Nothing is sent to
|
|
/// the other stations or the cluster.
|
|
private void SpotRecentContacts(IEnumerable<Qso> contacts)
|
|
{
|
|
if (Settings.RecentContactSpotMinutes <= 0)
|
|
{
|
|
return;
|
|
}
|
|
DateTime since = DateTime.UtcNow - TimeSpan.FromMinutes(Settings.RecentContactSpotMinutes);
|
|
HashSet<(string, Band?)> held = [.. Bandmap.All().Select(s => (s.Call.Text, s.Band))];
|
|
List<Spot> spots = [];
|
|
foreach (Qso qso in contacts.Where(q => q.TimestampUtc >= since && !q.IsRunQso && Logging!.Contest.IsContact(q)))
|
|
{
|
|
Spot spot = new(qso.Call, qso.Frequency, qso.TimestampUtc, SpotSource.Log);
|
|
if (held.Add((spot.Call.Text, spot.Band)))
|
|
{
|
|
spots.Add(spot);
|
|
}
|
|
}
|
|
if (spots.Count > 0)
|
|
{
|
|
Bandmap.AddRange(spots);
|
|
}
|
|
}
|
|
|
|
/// Starts, restarts or stops the keyer, following what the settings say.
|
|
/// A keyer that will not open is reported; the program keeps running
|
|
/// without one.
|
|
public void ApplyKeyerSettings()
|
|
{
|
|
alternating?.Dispose();
|
|
alternating = null;
|
|
keyer?.Dispose();
|
|
keyer = null;
|
|
voice?.Dispose();
|
|
voice = null;
|
|
switch (Settings.KeyerKind.ToLowerInvariant())
|
|
{
|
|
case "cwdaemon":
|
|
keyer = new CwDaemonSender(Settings.KeyerHost, Settings.KeyerPort);
|
|
break;
|
|
case "winkeyer":
|
|
WinkeyerSender winkeyer = new(Settings.KeyerSerialPort);
|
|
winkeyer.Open();
|
|
keyer = winkeyer;
|
|
break;
|
|
}
|
|
if (keyer is not null)
|
|
{
|
|
_ = keyer.SetSpeedAsync(Settings.KeyerSpeed);
|
|
}
|
|
sound = new CommandSoundCard(Settings.VoicePlayCommand, Settings.VoiceRecordCommand);
|
|
voice = new VoiceKeyer(
|
|
sound,
|
|
Recordings,
|
|
// only cwdaemon has a way in to the keying port from here, and
|
|
// only when the operator has not said the radio keys itself
|
|
Settings.VoiceKeysPtt && keyer is CwDaemonSender daemon ? daemon.SetPttAsync : null);
|
|
voice.Reported += (_, what) => Reported?.Invoke(this, what);
|
|
// a CQ goes out through the keyer the radio's mode calls for, so
|
|
// alternating CQ listens to both of them
|
|
alternating = new AlternatingCq(
|
|
keyer is null ? [voice] : [keyer, voice],
|
|
CallCqOnAsync,
|
|
TimeSpan.FromMilliseconds(Settings.AlternatingCqGapMs));
|
|
Changed?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
|
|
/// Starts the digital engine under Wine and returns it. The engine that is
|
|
/// already running is handed back rather than started again.
|
|
public async Task<MmttyEngine> StartDigitalAsync()
|
|
{
|
|
if (digital is { IsConnected: true } running)
|
|
{
|
|
return running;
|
|
}
|
|
StopDigital();
|
|
MmttyOptions options = new()
|
|
{
|
|
EnginePath = Settings.DigitalEnginePath,
|
|
Number = ActiveRadioNumber,
|
|
Window = Enum.TryParse(Settings.DigitalEngineWindow, ignoreCase: true, out EngineWindow window)
|
|
? window
|
|
: EngineWindow.Normal,
|
|
OnTop = Settings.DigitalEngineOnTop,
|
|
PttPort = Settings.DigitalPttPort.Trim().Length > 0 ? Settings.DigitalPttPort : null,
|
|
};
|
|
ProcessBridgeChannel channel = new(
|
|
Settings.DigitalBridgePath,
|
|
Settings.DigitalWinePrefix.Trim().Length > 0 ? Settings.DigitalWinePrefix : null,
|
|
OperatingSystem.IsWindows()
|
|
? null
|
|
: Settings.DigitalWineCommand.Trim().Length > 0 ? Settings.DigitalWineCommand : "wine",
|
|
Paths.Diagnostics);
|
|
MmttyEngine started = new(channel, options);
|
|
await started.StartAsync();
|
|
digital = started;
|
|
digitalSender = new DigitalEngineSender(started, Settings.DigitalBaud);
|
|
Changed?.Invoke(this, EventArgs.Empty);
|
|
return started;
|
|
}
|
|
|
|
public void StopDigital()
|
|
{
|
|
digitalSender?.Dispose();
|
|
digitalSender = null;
|
|
digital?.Dispose();
|
|
digital = null;
|
|
Changed?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
|
|
/// Opens a connection per enabled radio. A second one makes the station
|
|
/// SO2R: both are read, but only the one the operator is on drives the
|
|
/// entry window.
|
|
public void ConnectRadios()
|
|
{
|
|
DisposeRadios();
|
|
int number = 1;
|
|
foreach (StoredRadio configured in Settings.Radios.Where(r => r.IsEnabled))
|
|
{
|
|
RigctldRadio opened = new(configured.Host, configured.Port, number++);
|
|
opened.Moved += (_, state) => RadioMoved(opened, state);
|
|
opened.ConnectionChanged += (_, _) => Changed?.Invoke(this, EventArgs.Empty);
|
|
radios.Add(opened);
|
|
opened.Start();
|
|
}
|
|
if (Logging is not null && positions.Count != PositionCount)
|
|
{
|
|
OpenContest(Logging.Instance.ContestNumber);
|
|
return;
|
|
}
|
|
activeRadio = Math.Min(activeRadio, Math.Max(0, positions.Count - 1));
|
|
Changed?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
|
|
public void DisconnectRadios()
|
|
{
|
|
DisposeRadios();
|
|
Changed?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
|
|
/// Moves the operator to the other radio. The entry window follows where
|
|
/// that radio is sitting, and contacts are logged against its number.
|
|
public void SwapRadio()
|
|
{
|
|
if (positions.Count < 2)
|
|
{
|
|
return;
|
|
}
|
|
MoveToRadio(positions[(activeRadio + 1) % positions.Count].RadioNumber);
|
|
}
|
|
|
|
/// Moves the operator to a radio by number. Alternating CQ calls this from
|
|
/// the keyer's thread, so everything it raises is posted by the windows.
|
|
public void MoveToRadio(int radioNumber)
|
|
{
|
|
int at = positions.FindIndex(p => p.RadioNumber == radioNumber);
|
|
if (at < 0 || at == activeRadio)
|
|
{
|
|
return;
|
|
}
|
|
activeRadio = at;
|
|
_ = FollowActiveRadioAsync();
|
|
Changed?.Invoke(this, EventArgs.Empty);
|
|
ActiveRadioChanged?.Invoke(this, ActiveRadioNumber);
|
|
}
|
|
|
|
/// Moves to the radio and sends its CQ message, which is F1. Used by
|
|
/// alternating CQ; the operator's own F1 goes through the entry window.
|
|
private async Task CallCqOnAsync(int radioNumber)
|
|
{
|
|
OperatingPosition position = positions.FirstOrDefault(p => p.RadioNumber == radioNumber)
|
|
?? throw new InvalidOperationException($"there is no radio {radioNumber}");
|
|
if (SenderFor(position.Mode.Category) is not { } sender)
|
|
{
|
|
throw new InvalidOperationException($"there is no keyer for {position.Mode.Name}");
|
|
}
|
|
string template = Messages
|
|
.For(position.Mode.Category, Settings.CwMessageFile, Settings.PhoneMessageFile)
|
|
.Keys(position.IsRunning)[Esm.CallCq]
|
|
.Message;
|
|
if (template.Length == 0)
|
|
{
|
|
throw new InvalidOperationException("F1 has no message — Config ▸ Keyer and messages");
|
|
}
|
|
MoveToRadio(radioNumber);
|
|
await PointTransmitAtAsync(radioNumber).ConfigureAwait(false);
|
|
await sender.SendAsync(MessageExpander.Expand(template, position, Other(position))).ConfigureAwait(false);
|
|
}
|
|
|
|
/// Puts both radios in the headphones, or goes back to one. An operator
|
|
/// listens to the second radio while the first is sending.
|
|
public void ToggleListenToBoth()
|
|
{
|
|
IsListeningToBoth = !IsListeningToBoth;
|
|
_ = FollowActiveRadioAsync();
|
|
Changed?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
|
|
/// Points the box at the radio about to transmit. Called before keying, so
|
|
/// a message never goes out of the radio the operator has just left.
|
|
public Task PointTransmitAtAsync(int radioNumber) =>
|
|
box?.SetTransmitAsync(radioNumber) ?? Task.CompletedTask;
|
|
|
|
/// Starts, restarts or stops the SO2R box, following what the settings say.
|
|
public void ApplySo2rSettings()
|
|
{
|
|
box?.Dispose();
|
|
box = Settings.So2rBoxPort.Length > 0 ? OtrspBox.Open(Settings.So2rBoxPort) : null;
|
|
_ = FollowActiveRadioAsync();
|
|
Changed?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
|
|
private async Task FollowActiveRadioAsync()
|
|
{
|
|
if (box is null)
|
|
{
|
|
return;
|
|
}
|
|
try
|
|
{
|
|
await box.SetTransmitAsync(ActiveRadioNumber).ConfigureAwait(false);
|
|
await box.SetReceiveAsync(ActiveRadioNumber, IsListeningToBoth).ConfigureAwait(false);
|
|
}
|
|
catch (Exception e) when (e is IOException or InvalidOperationException)
|
|
{
|
|
box.Dispose();
|
|
box = null;
|
|
}
|
|
}
|
|
|
|
/// Raised when the operator moves to the other radio, so the entry windows
|
|
/// can hand the keyboard over and the SO2R box can follow.
|
|
public event EventHandler<int>? ActiveRadioChanged;
|
|
|
|
/// The radio the operator is not on still moves, and the bandmap shows it,
|
|
/// but it must not drag the entry window off the contact being worked.
|
|
private void RadioMoved(Radio moved, RadioState state)
|
|
{
|
|
OperatingPosition? position = positions.FirstOrDefault(p => p.RadioNumber == moved.Number);
|
|
if (position is null)
|
|
{
|
|
return;
|
|
}
|
|
TuneAndSpot(position, state.Frequency, state.Mode, state.TransmitFrequency);
|
|
}
|
|
|
|
private void DisposeRadios()
|
|
{
|
|
foreach (RigctldRadio open in radios)
|
|
{
|
|
open.Dispose();
|
|
}
|
|
radios.Clear();
|
|
}
|
|
|
|
private static string FirstNonEmpty(params string[] values) =>
|
|
values.FirstOrDefault(v => v.Length > 0) ?? "";
|
|
|
|
/// The favourite the program connects to when it starts, if one is marked.
|
|
public StoredClusterNode? AutoConnectNode => Settings.ClusterNodes.FirstOrDefault(n => n.AutoConnect);
|
|
|
|
/// Connects to the favourite marked for it. Without one, nothing connects.
|
|
public void ConnectAutoCluster()
|
|
{
|
|
if (AutoConnectNode is not { } node)
|
|
{
|
|
return;
|
|
}
|
|
Save(Settings with
|
|
{
|
|
ClusterHost = node.Host,
|
|
ClusterPort = node.Port,
|
|
ClusterPassword = node.Password,
|
|
ClusterCommands = node.Commands,
|
|
ClusterNodeLogonCall = node.LogonCall,
|
|
});
|
|
ConnectCluster();
|
|
}
|
|
|
|
public void ConnectCluster()
|
|
{
|
|
cluster?.Dispose();
|
|
SetSolar(null);
|
|
cluster = new ClusterClient(
|
|
Settings.ClusterHost,
|
|
Settings.ClusterPort,
|
|
FirstNonEmpty(Settings.ClusterNodeLogonCall, Settings.ClusterLogonCall, Settings.Station.Callsign),
|
|
Settings.ClusterCommands,
|
|
Settings.ClusterPassword,
|
|
autoLogon: Settings.ClusterAutoLogon,
|
|
keepAliveInterval: TimeSpan.FromMinutes(Math.Max(1, Settings.ClusterKeepAliveMinutes)));
|
|
cluster.SpotArrived += (_, spot) => TakeSpot(spot);
|
|
cluster.SolarArrived += (_, report) => SetSolar(report);
|
|
cluster.ConnectionChanged += (_, _) => Changed?.Invoke(this, EventArgs.Empty);
|
|
cluster.Start();
|
|
}
|
|
|
|
/// The latest solar weather the cluster sent, or null when it has sent
|
|
/// none on this connection.
|
|
public SolarReport? Solar { get; private set; }
|
|
|
|
public event EventHandler? SolarChanged;
|
|
|
|
private void SetSolar(SolarReport? report)
|
|
{
|
|
Solar = report;
|
|
SolarChanged?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
|
|
public void DisconnectCluster()
|
|
{
|
|
cluster?.Dispose();
|
|
cluster = null;
|
|
SetSolar(null);
|
|
Changed?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
|
|
/// A spot the node sent. It waits for the next flush rather than going
|
|
/// straight onto the bandmap.
|
|
private void TakeSpot(Spot spot)
|
|
{
|
|
lock (arrivingGuard)
|
|
{
|
|
arriving.Add(spot);
|
|
}
|
|
}
|
|
|
|
/// The spots that arrived since the last flush, filtered and moved about,
|
|
/// put on the bandmap in one go.
|
|
private void FlushSpots()
|
|
{
|
|
List<Spot> batch;
|
|
lock (arrivingGuard)
|
|
{
|
|
if (arriving.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
batch = [.. arriving];
|
|
arriving.Clear();
|
|
}
|
|
List<Spot> keeping = [];
|
|
foreach (Spot spot in batch)
|
|
{
|
|
if (spotFilter.Accepts(spot))
|
|
{
|
|
keeping.Add(Settings.RandomizeSpots
|
|
? SpotJitter.Shifted(spot, BandPlan, jitter, IsDupe(spot))
|
|
: spot);
|
|
}
|
|
}
|
|
if (keeping.Count > 0)
|
|
{
|
|
Bandmap.AddRange(keeping);
|
|
SendSpots(keeping);
|
|
}
|
|
}
|
|
|
|
/// Whether the station has already been worked, which decides whether a
|
|
/// spot is moved about: N1MM leaves a dupe where it is.
|
|
private bool IsDupe(Spot spot) => JudgeSpot(spot) is { IsDupe: true };
|
|
|
|
/// What logging the station of a spot would do, or null with no contest open.
|
|
private Verdict? JudgeSpot(Spot spot) =>
|
|
Logging?.Log.Judge(new Qso
|
|
{
|
|
Id = "",
|
|
TimestampUtc = spot.AtUtc,
|
|
Call = spot.Call,
|
|
Frequency = spot.Frequency,
|
|
Mode = Position?.Mode ?? Modes.Cw,
|
|
ContestName = Logging.Contest.Name,
|
|
});
|
|
|
|
private void ApplySpotSettings()
|
|
{
|
|
spotFilter = Settings.SpotFilter.ToFilter(BandPlan, Settings.Station, Countries, Calls, History);
|
|
Bandmap.LifetimeFor = spot => TimeSpan.FromMinutes(Math.Max(
|
|
1,
|
|
BandPlan.ModeAt(spot.Frequency) is ModeCategory.Cw or ModeCategory.Digital
|
|
? Settings.CwDigitalSpotTimeoutMinutes
|
|
: Settings.PhoneSpotTimeoutMinutes));
|
|
foreach (OperatingPosition position in positions)
|
|
{
|
|
position.CqLeaveHertz = Settings.CqLeaveHertz;
|
|
}
|
|
}
|
|
|
|
public void ReloadSupportFiles()
|
|
{
|
|
Countries = LoadCountryFile(Paths.CountryFile);
|
|
Calls = LoadCallDatabase(Paths.CallDatabaseFile);
|
|
History = LoadCallHistory(Settings.CallHistoryFile);
|
|
Registry = ContestRegistry.FromFolder(Paths.UserDefinedContests, out _, Paths.SupportFiles);
|
|
ApplySpotSettings();
|
|
if (Logging is not null)
|
|
{
|
|
OpenContest(Logging.Instance.ContestNumber);
|
|
}
|
|
Changed?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
StopDigital();
|
|
spotFlush.Dispose();
|
|
DisposeRadios();
|
|
cluster?.Dispose();
|
|
announcing?.Stop();
|
|
broadcasting?.Stop();
|
|
data?.Dispose();
|
|
mirror?.Dispose();
|
|
network?.Dispose();
|
|
link?.Dispose();
|
|
alternating?.Dispose();
|
|
keyer?.Dispose();
|
|
voice?.Dispose();
|
|
box?.Dispose();
|
|
store?.Dispose();
|
|
}
|
|
|
|
private static ModeCategory ModeCategoryOf(ContestInstance instance) =>
|
|
instance.ModeCategory.ToUpperInvariant() switch
|
|
{
|
|
"SSB" or "PH" or "PHONE" => ModeCategory.Phone,
|
|
"RTTY" or "DIGI" or "DIGITAL" => ModeCategory.Digital,
|
|
_ => ModeCategory.Cw,
|
|
};
|
|
|
|
/// Without a country file the program still runs; country and continent
|
|
/// scoring just loses accuracy.
|
|
private static CountryFile? LoadCountryFile(string path)
|
|
{
|
|
try
|
|
{
|
|
return File.Exists(path) ? CountryFile.Parse(File.ReadAllText(path)) : null;
|
|
}
|
|
catch (Exception e) when (e is FormatException or IOException)
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// A file that will not parse is reported by coming back empty rather than
|
|
/// stopping the program before a contest.
|
|
private static CallHistory LoadCallHistory(string path)
|
|
{
|
|
try
|
|
{
|
|
return path.Length > 0 && File.Exists(path)
|
|
? CallHistory.Parse(File.ReadAllText(path))
|
|
: CallHistory.Empty;
|
|
}
|
|
catch (Exception e) when (e is FormatException or IOException)
|
|
{
|
|
return CallHistory.Empty;
|
|
}
|
|
}
|
|
|
|
private static CallDatabase LoadCallDatabase(string path)
|
|
{
|
|
try
|
|
{
|
|
return File.Exists(path) ? CallDatabase.Parse(File.ReadAllText(path)) : CallDatabase.Empty;
|
|
}
|
|
catch (Exception e) when (e is FormatException or IOException)
|
|
{
|
|
return CallDatabase.Empty;
|
|
}
|
|
}
|
|
}
|