diff --git a/Nonemm.slnx b/Nonemm.slnx index 320d791..2dbf99f 100644 --- a/Nonemm.slnx +++ b/Nonemm.slnx @@ -15,5 +15,7 @@ + + diff --git a/src/Nonemm.Core/Calls/CallDatabase.cs b/src/Nonemm.Core/Calls/CallDatabase.cs new file mode 100644 index 0000000..4fab637 --- /dev/null +++ b/src/Nonemm.Core/Calls/CallDatabase.cs @@ -0,0 +1,60 @@ +namespace Nonemm.Core.Calls; + +/// The super check partial database (`MASTER.SCP`): the callsigns heard in +/// contests, used to finish a call the operator has half copied. +public sealed class CallDatabase +{ + private readonly IReadOnlyList calls; + + private CallDatabase(IReadOnlyList calls) => this.calls = calls; + + public static readonly CallDatabase Empty = new([]); + + public int Count => calls.Count; + + /// Throws `FormatException` when the text is not a callsign list, so a + /// download that returned an error page cannot replace a good file. + public static CallDatabase Parse(string text) + { + List found = []; + foreach (string rawLine in text.Split('\n')) + { + string line = rawLine.Trim(); + if (line.Length == 0 || line.StartsWith('#') || line.StartsWith('!')) + { + continue; + } + if (!LooksLikeCall(line)) + { + throw new FormatException($"'{line}' is not a callsign, so this is not a MASTER.SCP"); + } + found.Add(line.ToUpperInvariant()); + } + if (found.Count == 0) + { + throw new FormatException("the callsign database holds no callsigns"); + } + return new CallDatabase(found); + } + + public IReadOnlyList Matches(string query, int limit = 40) + { + List found = []; + foreach (string call in calls) + { + PartialMatchQuality? quality = PartialMatching.Judge(query, call); + if (quality is not null) + { + found.Add(new PartialMatch(call, quality.Value)); + } + } + return found + .OrderBy(m => m.Quality) + .ThenBy(m => m.Call, StringComparer.Ordinal) + .Take(limit) + .ToList(); + } + + private static bool LooksLikeCall(string text) => + text.Length is >= 3 and <= 16 && text.All(c => char.IsAsciiLetterOrDigit(c) || c == '/'); +} diff --git a/src/Nonemm.Core/Calls/PartialMatch.cs b/src/Nonemm.Core/Calls/PartialMatch.cs new file mode 100644 index 0000000..10926b3 --- /dev/null +++ b/src/Nonemm.Core/Calls/PartialMatch.cs @@ -0,0 +1,3 @@ +namespace Nonemm.Core.Calls; + +public sealed record PartialMatch(string Call, PartialMatchQuality Quality); diff --git a/src/Nonemm.Core/Calls/PartialMatchQuality.cs b/src/Nonemm.Core/Calls/PartialMatchQuality.cs new file mode 100644 index 0000000..88479f3 --- /dev/null +++ b/src/Nonemm.Core/Calls/PartialMatchQuality.cs @@ -0,0 +1,11 @@ +namespace Nonemm.Core.Calls; + +/// How well a candidate call answers what the operator has typed. The order is +/// the order the check window lists them in. +public enum PartialMatchQuality +{ + Exact, + StartsOrEnds, + Contains, + OneCharacterOut, +} diff --git a/src/Nonemm.Core/Calls/PartialMatching.cs b/src/Nonemm.Core/Calls/PartialMatching.cs new file mode 100644 index 0000000..6226cd7 --- /dev/null +++ b/src/Nonemm.Core/Calls/PartialMatching.cs @@ -0,0 +1,90 @@ +namespace Nonemm.Core.Calls; + +/// Judges how well a callsign answers a partly copied one. `?` stands for a +/// character that was not copied. +public static class PartialMatching +{ + /// Two characters match a large share of any callsign list, so the check + /// windows say nothing until there are three. + public const int ShortestUsefulQuery = 3; + + public static PartialMatchQuality? Judge(string query, string candidate) + { + if (query.Length < ShortestUsefulQuery || candidate.Length == 0) + { + return null; + } + if (Same(query, candidate)) + { + return PartialMatchQuality.Exact; + } + if (query.Length < candidate.Length) + { + if (Same(query, candidate[..query.Length]) || + Same(query, candidate[^query.Length..])) + { + return PartialMatchQuality.StartsOrEnds; + } + for (int at = 1; at + query.Length < candidate.Length; at++) + { + if (Same(query, candidate.Substring(at, query.Length))) + { + return PartialMatchQuality.Contains; + } + } + } + return IsOneCharacterOut(query, candidate) ? PartialMatchQuality.OneCharacterOut : null; + } + + /// Same length with one character different, or two next to each other the + /// wrong way round. + private static bool IsOneCharacterOut(string query, string candidate) + { + if (query.Length != candidate.Length) + { + return false; + } + int differences = 0; + int firstDifference = -1; + for (int at = 0; at < query.Length; at++) + { + if (!SameCharacter(query[at], candidate[at])) + { + differences++; + if (firstDifference < 0) + { + firstDifference = at; + } + } + } + if (differences == 1) + { + return true; + } + if (differences != 2 || firstDifference + 1 >= query.Length) + { + return false; + } + return SameCharacter(query[firstDifference], candidate[firstDifference + 1]) && + SameCharacter(query[firstDifference + 1], candidate[firstDifference]); + } + + private static bool Same(string query, string candidate) + { + if (query.Length != candidate.Length) + { + return false; + } + for (int at = 0; at < query.Length; at++) + { + if (!SameCharacter(query[at], candidate[at])) + { + return false; + } + } + return true; + } + + private static bool SameCharacter(char query, char candidate) => + query == '?' || char.ToUpperInvariant(query) == char.ToUpperInvariant(candidate); +} diff --git a/src/Nonemm.Rig/Nonemm.Rig.csproj b/src/Nonemm.Rig/Nonemm.Rig.csproj index b760144..4464042 100644 --- a/src/Nonemm.Rig/Nonemm.Rig.csproj +++ b/src/Nonemm.Rig/Nonemm.Rig.csproj @@ -1,5 +1,9 @@  + + + + net10.0 enable diff --git a/src/Nonemm.Rig/Radio.cs b/src/Nonemm.Rig/Radio.cs new file mode 100644 index 0000000..47173d5 --- /dev/null +++ b/src/Nonemm.Rig/Radio.cs @@ -0,0 +1,23 @@ +using Nonemm.Core; + +namespace Nonemm.Rig; + +/// A radio the logger can read and tune. With nothing connected the logger +/// leaves frequency and mode wherever they were last typed, so every method +/// here is allowed to do nothing. +public interface Radio : IDisposable +{ + bool IsConnected { get; } + + RadioState? State { get; } + + /// Raised when the radio has moved. + event EventHandler? Moved; + + /// Raised when the connection comes up or goes down. + event EventHandler? ConnectionChanged; + + Task TuneAsync(Frequency frequency, CancellationToken cancellation = default); + + Task SetModeAsync(Mode mode, CancellationToken cancellation = default); +} diff --git a/src/Nonemm.Rig/RadioState.cs b/src/Nonemm.Rig/RadioState.cs new file mode 100644 index 0000000..c5fe7c7 --- /dev/null +++ b/src/Nonemm.Rig/RadioState.cs @@ -0,0 +1,6 @@ +using Nonemm.Core; + +namespace Nonemm.Rig; + +/// Where the radio is now. +public sealed record RadioState(Frequency Frequency, Mode Mode); diff --git a/src/Nonemm.Rig/RigctldRadio.cs b/src/Nonemm.Rig/RigctldRadio.cs new file mode 100644 index 0000000..a666608 --- /dev/null +++ b/src/Nonemm.Rig/RigctldRadio.cs @@ -0,0 +1,198 @@ +using System.Globalization; +using System.Net.Sockets; +using Nonemm.Core; + +namespace Nonemm.Rig; + +/// Talks to hamlib's `rigctld`, which is started separately for whichever radio +/// is on the desk. The connection is retried on its own, so a radio switched on +/// late or a `rigctld` restarted mid-contest comes back without the operator +/// touching anything. +public sealed class RigctldRadio : Radio +{ + private readonly string host; + private readonly int port; + private readonly TimeSpan pollInterval; + private readonly TimeSpan retryInterval; + private readonly CancellationTokenSource stopping = new(); + private readonly SemaphoreSlim writing = new(1, 1); + private TcpClient? client; + private StreamReader? reader; + private StreamWriter? writer; + private Task? loop; + + public RigctldRadio( + string host = "127.0.0.1", + int port = 4532, + TimeSpan? pollInterval = null, + TimeSpan? retryInterval = null) + { + this.host = host; + this.port = port; + this.pollInterval = pollInterval ?? TimeSpan.FromMilliseconds(200); + this.retryInterval = retryInterval ?? TimeSpan.FromSeconds(3); + } + + public bool IsConnected { get; private set; } + + public RadioState? State { get; private set; } + + public event EventHandler? Moved; + + public event EventHandler? ConnectionChanged; + + public void Start() => loop ??= Task.Run(() => RunAsync(stopping.Token)); + + public async Task TuneAsync(Frequency frequency, CancellationToken cancellation = default) => + await SendAsync($"F {frequency.Hertz}", cancellation).ConfigureAwait(false); + + public async Task SetModeAsync(Mode mode, CancellationToken cancellation = default) => + await SendAsync($"M {RigctldMode(mode)} 0", cancellation).ConfigureAwait(false); + + public void Dispose() + { + stopping.Cancel(); + client?.Dispose(); + stopping.Dispose(); + writing.Dispose(); + } + + private async Task RunAsync(CancellationToken cancellation) + { + while (!cancellation.IsCancellationRequested) + { + try + { + await ConnectAsync(cancellation).ConfigureAwait(false); + await PollAsync(cancellation).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return; + } + catch (Exception e) when (e is IOException or SocketException) + { + SetConnected(false); + await Task.Delay(retryInterval, cancellation).ConfigureAwait(false); + } + } + } + + private async Task ConnectAsync(CancellationToken cancellation) + { + client?.Dispose(); + client = new TcpClient(); + await client.ConnectAsync(host, port, cancellation).ConfigureAwait(false); + NetworkStream stream = client.GetStream(); + reader = new StreamReader(stream); + writer = new StreamWriter(stream) { AutoFlush = true }; + SetConnected(true); + } + + private async Task PollAsync(CancellationToken cancellation) + { + while (!cancellation.IsCancellationRequested) + { + Frequency? frequency = ReadFrequency(await AskAsync("f", cancellation).ConfigureAwait(false)); + Mode? mode = ReadMode(await AskAsync("m", cancellation).ConfigureAwait(false)); + if (frequency is not null) + { + RadioState state = new(frequency.Value, mode ?? State?.Mode ?? Modes.Cw); + if (state != State) + { + State = state; + Moved?.Invoke(this, state); + } + } + await Task.Delay(pollInterval, cancellation).ConfigureAwait(false); + } + } + + private async Task> AskAsync(string command, CancellationToken cancellation) + { + await writing.WaitAsync(cancellation).ConfigureAwait(false); + try + { + if (writer is null || reader is null) + { + throw new IOException("the connection to rigctld is not open"); + } + await writer.WriteAsync(command + "\n").ConfigureAwait(false); + List lines = []; + // rigctld answers one value per line and ends with RPRT on an error + string? line = await reader.ReadLineAsync(cancellation).ConfigureAwait(false) + ?? throw new IOException($"rigctld closed the connection during '{command}'"); + lines.Add(line); + if (command == "m") + { + string? passband = await reader.ReadLineAsync(cancellation).ConfigureAwait(false); + if (passband is not null) + { + lines.Add(passband); + } + } + return lines; + } + finally + { + writing.Release(); + } + } + + private async Task SendAsync(string command, CancellationToken cancellation) + { + if (!IsConnected) + { + return; + } + await writing.WaitAsync(cancellation).ConfigureAwait(false); + try + { + if (writer is null || reader is null) + { + return; + } + await writer.WriteAsync(command + "\n").ConfigureAwait(false); + await reader.ReadLineAsync(cancellation).ConfigureAwait(false); + } + catch (Exception e) when (e is IOException or SocketException) + { + SetConnected(false); + } + finally + { + writing.Release(); + } + } + + private void SetConnected(bool connected) + { + if (IsConnected != connected) + { + IsConnected = connected; + ConnectionChanged?.Invoke(this, connected); + } + } + + private static Frequency? ReadFrequency(IReadOnlyList answer) => + answer.Count > 0 && + long.TryParse(answer[0].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out long hertz) && + hertz > 0 + ? Frequency.FromHertz(hertz) + : null; + + private static Mode? ReadMode(IReadOnlyList answer) => + answer.Count > 0 ? Modes.Parse(answer[0].Trim()) : null; + + /// hamlib names the modes CW, USB, LSB, RTTY, FM, AM and PKTUSB. + private static string RigctldMode(Mode mode) => mode.Name switch + { + "CW" => "CW", + "USB" => "USB", + "LSB" => "LSB", + "AM" => "AM", + "FM" => "FM", + "RTTY" => "RTTY", + _ => "PKTUSB", + }; +} diff --git a/src/Nonemm.Session/EntryFields.cs b/src/Nonemm.Session/EntryFields.cs new file mode 100644 index 0000000..9c37805 --- /dev/null +++ b/src/Nonemm.Session/EntryFields.cs @@ -0,0 +1,87 @@ +using Nonemm.Contests; + +namespace Nonemm.Session; + +/// What the operator has typed: the callsign, a value per exchange box, and +/// which box the cursor is in. The callsign box is field 0. +public sealed class EntryFields +{ + private readonly List values; + + public EntryFields(IReadOnlyList exchange) + { + Exchange = exchange; + values = [.. Enumerable.Repeat("", exchange.Count)]; + } + + public IReadOnlyList Exchange { get; } + + public string Call { get; set; } = ""; + + /// 0 is the callsign box; 1 and up are the exchange boxes in order. + public int Focus { get; private set; } + + public int FieldCount => Exchange.Count + 1; + + public string this[int field] + { + get => field == 0 ? Call : values[field - 1]; + set + { + if (field == 0) + { + Call = value; + } + else + { + values[field - 1] = value; + } + } + } + + public string ValueOf(ExchangeSlot slot) + { + for (int at = 0; at < Exchange.Count; at++) + { + if (Exchange[at].Slot == slot) + { + return values[at]; + } + } + return ""; + } + + public void Set(ExchangeSlot slot, string value) + { + for (int at = 0; at < Exchange.Count; at++) + { + if (Exchange[at].Slot == slot) + { + values[at] = value; + } + } + } + + public void FocusOn(int field) => Focus = Math.Clamp(field, 0, FieldCount - 1); + + /// What space does: move to the next box, and round to the callsign again + /// from the last one. + public void Advance() => Focus = (Focus + 1) % FieldCount; + + public void Retreat() => Focus = (Focus + FieldCount - 1) % FieldCount; + + /// True when everything the contest asks for has been typed. + public bool IsComplete => + Call.Trim().Length > 0 && + Exchange.Where((f, at) => f.IsRequired && values[at].Trim().Length == 0).Any() == false; + + public void Clear() + { + Call = ""; + for (int at = 0; at < values.Count; at++) + { + values[at] = ""; + } + Focus = 0; + } +} diff --git a/src/Nonemm.Session/FrequencyEntry.cs b/src/Nonemm.Session/FrequencyEntry.cs new file mode 100644 index 0000000..c6d74fc --- /dev/null +++ b/src/Nonemm.Session/FrequencyEntry.cs @@ -0,0 +1,29 @@ +using System.Globalization; +using Nonemm.Core; + +namespace Nonemm.Session; + +/// Reads a frequency typed into the callsign box, which is how an operator +/// changes band without touching the radio. +public static class FrequencyEntry +{ + /// Kilohertz when the number is 1000 or more, megahertz when it is less, so + /// both `14025` and `14.025` reach 20 metres. Null when the text is not a + /// frequency or lands outside every band. + public static Frequency? Parse(string text) + { + string trimmed = text.Trim(); + if (trimmed.Length == 0 || !trimmed.All(c => char.IsAsciiDigit(c) || c == '.')) + { + return null; + } + if (!double.TryParse(trimmed, NumberStyles.Float, CultureInfo.InvariantCulture, out double value)) + { + return null; + } + Frequency frequency = value >= 1000 + ? Frequency.FromKilohertz(value) + : Frequency.FromMegahertz(value); + return Bands.ForFrequency(frequency) is null ? null : frequency; + } +} diff --git a/src/Nonemm.Session/LoggingSession.cs b/src/Nonemm.Session/LoggingSession.cs new file mode 100644 index 0000000..ebfec31 --- /dev/null +++ b/src/Nonemm.Session/LoggingSession.cs @@ -0,0 +1,189 @@ +using Nonemm.Contests; +using Nonemm.Core; +using Nonemm.Core.Country; +using Nonemm.Storage; + +namespace Nonemm.Session; + +/// The running contest: what the operator is typing, what the log says about +/// it, and what happens when the contact is logged. It holds no reference to a +/// UI toolkit, so the behaviour an operator judges the logger by is tested with +/// plain unit tests. +public sealed class LoggingSession +{ + private readonly LogStore store; + private readonly CountryFile? countries; + + public LoggingSession( + LogStore store, + Contest contest, + ContestInstance instance, + StationInfo me, + CountryFile? countries) + { + this.store = store; + this.countries = countries; + Contest = contest; + Instance = instance; + Me = me; + Log = new ContestLog(contest, me, countries); + Log.Restore(store.Qsos(instance.ContestNumber)); + Entry = new EntryFields(contest.ExchangeFieldsFor(me)); + SentNumber = NextSentNumber(); + } + + public Contest Contest { get; } + + public ContestInstance Instance { get; } + + public StationInfo Me { get; } + + public ContestLog Log { get; } + + public EntryFields Entry { get; } + + /// With no radio connected this stays where it was last typed. + public Frequency Frequency { get; private set; } = Bands.Band20M.Low; + + public Mode Mode { get; private set; } = Modes.Cw; + + public int SentNumber { get; private set; } + + public string Operator { get; set; } = ""; + + /// True while the operator is calling CQ rather than searching. + public bool IsRunning { get; set; } + + public event EventHandler? Changed; + + public event EventHandler? Logged; + + public void Tune(Frequency frequency, Mode? mode = null) + { + Frequency = frequency; + Mode = mode ?? Mode; + Changed?.Invoke(this, EventArgs.Empty); + } + + /// What the log says about what is typed now. Null when there is no call to + /// judge yet. + public Verdict? Verdict() + { + string call = Entry.Call.Trim(); + return call.Length == 0 ? null : Log.Judge(BuildQso(call)); + } + + public CountryLookup? Country() => + Entry.Call.Trim().Length == 0 ? null : countries?.Find(Entry.Call.Trim()); + + /// Every earlier contact with the call being typed, newest first. + public IReadOnlyList WorkedBefore() => + Entry.Call.Trim().Length == 0 ? [] : Log.WorkedBefore(Entry.Call.Trim()); + + /// The frequency typed into the callsign box, if that is what it holds. + public Frequency? PendingQsy() => FrequencyEntry.Parse(Entry.Call); + + /// Logs what is typed and clears the entry. Throws when the entry is not + /// complete, so a caller that has not checked cannot lose an exchange. + public Qso LogContact() + { + if (!Entry.IsComplete) + { + throw new InvalidOperationException($"the exchange for {Entry.Call} is not complete"); + } + Qso scored = Log.Add(BuildQso(Entry.Call.Trim())); + Qso stored = store.Add(scored); + if (stored.TimestampUtc != scored.TimestampUtc) + { + Log.Replace(stored); + } + Entry.Clear(); + SentNumber = NextSentNumber(); + Logged?.Invoke(this, stored); + Changed?.Invoke(this, EventArgs.Empty); + return stored; + } + + public void Wipe() + { + Entry.Clear(); + Changed?.Invoke(this, EventArgs.Empty); + } + + public void Delete(string id) + { + store.Delete(id); + Log.Remove(id); + Changed?.Invoke(this, EventArgs.Empty); + } + + public void Update(Qso qso) + { + store.Update(qso); + Log.Replace(qso); + Changed?.Invoke(this, EventArgs.Empty); + } + + private int NextSentNumber() => + Log.Qsos.Count == 0 ? 1 : Log.Qsos.Max(q => q.SentNumber) + 1; + + private Qso BuildQso(string call) + { + Callsign parsed = Callsign.Parse(call); + CountryLookup? country = countries?.Find(parsed); + Qso qso = new() + { + Id = Qso.NewId(), + TimestampUtc = DateTime.UtcNow.AddTicks(-(DateTime.UtcNow.Ticks % TimeSpan.TicksPerSecond)), + Call = parsed, + Frequency = Frequency, + Mode = Mode, + ContestName = Contest.Name, + ContestNumber = Instance.ContestNumber, + SentReport = DefaultReport(), + SentNumber = SentNumber, + CountryPrefix = country?.Entity.PrimaryPrefix ?? "", + Continent = country?.Continent ?? "", + StationPrefix = parsed.PortablePrefix ?? "", + WpxPrefix = parsed.WpxPrefix() ?? "", + Operator = Operator.Length > 0 ? Operator : Me.Callsign, + IsRunQso = IsRunning, + }; + return ApplyExchange(qso); + } + + private Qso ApplyExchange(Qso qso) + { + foreach (ExchangeField field in Entry.Exchange) + { + string value = Entry.ValueOf(field.Slot).Trim(); + qso = field.Slot switch + { + ExchangeSlot.ReceivedReport => qso with { ReceivedReport = value }, + ExchangeSlot.SerialNumber => qso with { ReceivedNumber = Number(value) }, + ExchangeSlot.Zone => qso with { Zone = Number(value) }, + ExchangeSlot.Section => qso with { Section = value.ToUpperInvariant() }, + ExchangeSlot.Check => qso with { Check = Number(value) }, + ExchangeSlot.Precedence => qso with { Precedence = value.ToUpperInvariant() }, + ExchangeSlot.Exchange1 => qso with { Exchange1 = value.ToUpperInvariant() }, + ExchangeSlot.MiscText => qso with { MiscText = value.ToUpperInvariant() }, + ExchangeSlot.Name => qso with { Name = value }, + ExchangeSlot.Qth => qso with { Qth = value }, + ExchangeSlot.GridSquare => qso with { GridSquare = value.ToUpperInvariant() }, + ExchangeSlot.Power => qso with { Power = value }, + ExchangeSlot.Comment => qso with { Comment = value }, + _ => qso, + }; + } + return qso; + } + + private string DefaultReport() => Mode.Category switch + { + ModeCategory.Cw => "599", + ModeCategory.Phone => "59", + _ => "599", + }; + + private static int Number(string value) => int.TryParse(value, out int parsed) ? parsed : 0; +} diff --git a/src/Nonemm.Session/Nonemm.Session.csproj b/src/Nonemm.Session/Nonemm.Session.csproj index b760144..e5b1bc7 100644 --- a/src/Nonemm.Session/Nonemm.Session.csproj +++ b/src/Nonemm.Session/Nonemm.Session.csproj @@ -1,5 +1,12 @@  + + + + + + + net10.0 enable diff --git a/src/Nonemm.Spotting/Bandmap.cs b/src/Nonemm.Spotting/Bandmap.cs new file mode 100644 index 0000000..d6ee40c --- /dev/null +++ b/src/Nonemm.Spotting/Bandmap.cs @@ -0,0 +1,77 @@ +using Nonemm.Core; + +namespace Nonemm.Spotting; + +/// The stations on the band, in frequency order. A spot ages out after an hour +/// because a bandmap is a picture of the last hour, and a stale spot costs a +/// move to an empty frequency. +public sealed class Bandmap +{ + private readonly Dictionary spots = new(StringComparer.OrdinalIgnoreCase); + private readonly TimeSpan lifetime; + + public Bandmap(TimeSpan? lifetime = null) => this.lifetime = lifetime ?? TimeSpan.FromHours(1); + + public event EventHandler? Changed; + + public void Add(Spot spot) + { + spots[Key(spot)] = spot; + Changed?.Invoke(this, EventArgs.Empty); + } + + public void Remove(Callsign call, Band band) + { + if (spots.Remove($"{call.Text}|{band.Name}")) + { + Changed?.Invoke(this, EventArgs.Empty); + } + } + + public void DropOlderThan(DateTime nowUtc) + { + List stale = spots + .Where(pair => nowUtc - pair.Value.AtUtc > lifetime) + .Select(pair => pair.Key) + .ToList(); + foreach (string key in stale) + { + spots.Remove(key); + } + if (stale.Count > 0) + { + Changed?.Invoke(this, EventArgs.Empty); + } + } + + public IReadOnlyList On(Band band) => + spots.Values + .Where(s => s.Band == band) + .OrderBy(s => s.Frequency.Hertz) + .ToList(); + + public IReadOnlyList All() => + spots.Values.OrderBy(s => s.Frequency.Hertz).ToList(); + + /// The spot nearest the frequency, within the window either side. Used when + /// the radio lands on a spot so the entry window can fill the call in. + public Spot? Near(Frequency frequency, Frequency window) + { + Spot? best = null; + long bestDistance = long.MaxValue; + foreach (Spot spot in spots.Values) + { + long distance = Math.Abs(spot.Frequency.Hertz - frequency.Hertz); + if (distance <= window.Hertz && distance < bestDistance) + { + best = spot; + bestDistance = distance; + } + } + return best; + } + + /// One spot per station per band: a station that moves within the band keeps + /// one entry rather than leaving a trail. + private static string Key(Spot spot) => $"{spot.Call.Text}|{spot.Band?.Name}"; +} diff --git a/src/Nonemm.Spotting/ClusterClient.cs b/src/Nonemm.Spotting/ClusterClient.cs new file mode 100644 index 0000000..244c462 --- /dev/null +++ b/src/Nonemm.Spotting/ClusterClient.cs @@ -0,0 +1,140 @@ +using System.Net.Sockets; +using System.Text; + +namespace Nonemm.Spotting; + +/// A DX cluster node over telnet. Spots go to the bandmap; the rest of the +/// node's traffic goes to the packet window as it arrived, because filters are +/// the node's business and its replies are for the operator to read. +public sealed class ClusterClient : IDisposable +{ + private readonly string host; + private readonly int port; + private readonly string callsign; + private readonly IReadOnlyList commandsAfterLogin; + private readonly TimeSpan retryInterval; + private readonly CancellationTokenSource stopping = new(); + private TcpClient? client; + private StreamWriter? writer; + private Task? loop; + + public ClusterClient( + string host, + int port, + string callsign, + IReadOnlyList? commandsAfterLogin = null, + TimeSpan? retryInterval = null) + { + this.host = host; + this.port = port; + this.callsign = callsign; + this.commandsAfterLogin = commandsAfterLogin ?? []; + this.retryInterval = retryInterval ?? TimeSpan.FromSeconds(10); + } + + public bool IsConnected { get; private set; } + + public event EventHandler? SpotArrived; + + public event EventHandler? LineArrived; + + public event EventHandler? ConnectionChanged; + + public void Start() => loop ??= Task.Run(() => RunAsync(stopping.Token)); + + public async Task SendAsync(string line, CancellationToken cancellation = default) + { + if (writer is null) + { + return; + } + try + { + await writer.WriteAsync(line + "\r\n").ConfigureAwait(false); + } + catch (Exception e) when (e is IOException or SocketException or ObjectDisposedException) + { + SetConnected(false); + } + } + + public void Dispose() + { + stopping.Cancel(); + client?.Dispose(); + stopping.Dispose(); + } + + private async Task RunAsync(CancellationToken cancellation) + { + while (!cancellation.IsCancellationRequested) + { + try + { + await SessionAsync(cancellation).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return; + } + catch (Exception e) when (e is IOException or SocketException) + { + LineArrived?.Invoke(this, $"*** {host}:{port} {e.Message}"); + } + SetConnected(false); + await Task.Delay(retryInterval, cancellation).ConfigureAwait(false); + } + } + + private async Task SessionAsync(CancellationToken cancellation) + { + client?.Dispose(); + client = new TcpClient(); + await client.ConnectAsync(host, port, cancellation).ConfigureAwait(false); + NetworkStream stream = client.GetStream(); + using StreamReader reader = new(stream, Encoding.Latin1); + writer = new StreamWriter(stream, Encoding.Latin1) { AutoFlush = true }; + SetConnected(true); + + bool loggedIn = false; + while (!cancellation.IsCancellationRequested) + { + string? line = await reader.ReadLineAsync(cancellation).ConfigureAwait(false); + if (line is null) + { + return; + } + LineArrived?.Invoke(this, line); + Spot? spot = SpotLine.Parse(line, DateTime.UtcNow); + if (spot is not null) + { + SpotArrived?.Invoke(this, spot); + continue; + } + if (!loggedIn && AsksForCallsign(line)) + { + loggedIn = true; + await SendAsync(callsign, cancellation).ConfigureAwait(false); + foreach (string command in commandsAfterLogin) + { + await SendAsync(command, cancellation).ConfigureAwait(false); + } + } + } + } + + /// Nodes ask in their own words; all of them use one of these. + private static bool AsksForCallsign(string line) => + line.Contains("login", StringComparison.OrdinalIgnoreCase) || + line.Contains("call", StringComparison.OrdinalIgnoreCase) || + line.Contains("callsign", StringComparison.OrdinalIgnoreCase); + + private void SetConnected(bool connected) + { + if (IsConnected != connected) + { + IsConnected = connected; + ConnectionChanged?.Invoke(this, connected); + } + } +} diff --git a/src/Nonemm.Spotting/Nonemm.Spotting.csproj b/src/Nonemm.Spotting/Nonemm.Spotting.csproj index b760144..4464042 100644 --- a/src/Nonemm.Spotting/Nonemm.Spotting.csproj +++ b/src/Nonemm.Spotting/Nonemm.Spotting.csproj @@ -1,5 +1,9 @@  + + + + net10.0 enable diff --git a/src/Nonemm.Spotting/Spot.cs b/src/Nonemm.Spotting/Spot.cs new file mode 100644 index 0000000..88badeb --- /dev/null +++ b/src/Nonemm.Spotting/Spot.cs @@ -0,0 +1,15 @@ +using Nonemm.Core; + +namespace Nonemm.Spotting; + +/// One station on the bandmap. +public sealed record Spot( + Callsign Call, + Frequency Frequency, + DateTime AtUtc, + SpotSource Source, + string Spotter = "", + string Comment = "") +{ + public Band? Band => Bands.ForFrequency(Frequency); +} diff --git a/src/Nonemm.Spotting/SpotLine.cs b/src/Nonemm.Spotting/SpotLine.cs new file mode 100644 index 0000000..05e6076 --- /dev/null +++ b/src/Nonemm.Spotting/SpotLine.cs @@ -0,0 +1,77 @@ +using System.Globalization; +using Nonemm.Core; + +namespace Nonemm.Spotting; + +/// Reads the `DX de` lines a cluster node sends. +public static class SpotLine +{ + private const string Marker = "DX de "; + + /// Null for any other traffic from the node, which the packet window shows + /// as it arrived. + public static Spot? Parse(string line, DateTime nowUtc) + { + int start = line.IndexOf(Marker, StringComparison.OrdinalIgnoreCase); + if (start < 0) + { + return null; + } + string body = line[(start + Marker.Length)..]; + int colon = body.IndexOf(':'); + if (colon < 0) + { + return null; + } + string spotter = body[..colon].Trim(); + string[] parts = body[(colon + 1)..].Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 2) + { + return null; + } + if (!double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out double kilohertz)) + { + return null; + } + string comment = string.Join(' ', parts.Skip(2)).Trim(); + return new Spot( + Callsign.Parse(parts[1]), + Frequency.FromKilohertz(kilohertz), + ParseTime(comment, nowUtc), + SpotSource.Cluster, + spotter, + TrimTime(comment)); + } + + /// The node puts the spot's time at the end as `1234Z`. + private static DateTime ParseTime(string comment, DateTime nowUtc) + { + string? stamp = TimeStamp(comment); + if (stamp is null || + !int.TryParse(stamp[..2], out int hour) || + !int.TryParse(stamp[2..4], out int minute)) + { + return nowUtc; + } + DateTime at = new(nowUtc.Year, nowUtc.Month, nowUtc.Day, hour, minute, 0, DateTimeKind.Utc); + // a spot timed later than now came in just before midnight + return at > nowUtc.AddMinutes(5) ? at.AddDays(-1) : at; + } + + private static string TrimTime(string comment) + { + string? stamp = TimeStamp(comment); + return stamp is null ? comment : comment[..^stamp.Length].TrimEnd(); + } + + private static string? TimeStamp(string comment) + { + string trimmed = comment.TrimEnd(); + if (trimmed.Length < 5 || char.ToUpperInvariant(trimmed[^1]) != 'Z') + { + return null; + } + string candidate = trimmed[^5..]; + return candidate[..4].All(char.IsAsciiDigit) ? candidate : null; + } +} diff --git a/src/Nonemm.Spotting/SpotSource.cs b/src/Nonemm.Spotting/SpotSource.cs new file mode 100644 index 0000000..00ba409 --- /dev/null +++ b/src/Nonemm.Spotting/SpotSource.cs @@ -0,0 +1,9 @@ +namespace Nonemm.Spotting; + +/// Where a spot came from, which decides how much to trust it. +public enum SpotSource +{ + Cluster, + Log, + Operator, +} diff --git a/tests/Nonemm.Session.Tests/FakeLogStore.cs b/tests/Nonemm.Session.Tests/FakeLogStore.cs new file mode 100644 index 0000000..91d99ad --- /dev/null +++ b/tests/Nonemm.Session.Tests/FakeLogStore.cs @@ -0,0 +1,57 @@ +using Nonemm.Core; +using Nonemm.Storage; + +namespace Nonemm.Session.Tests; + +/// A log store that keeps everything in memory, so session tests do not need a +/// file. It keeps the one rule the sqlite store enforces: no two contacts with +/// the same call in the same second. +public sealed class FakeLogStore : LogStore +{ + private readonly List qsos = []; + private readonly List contests = []; + + public IReadOnlyList Contests() => contests; + + public ContestInstance? Contest(int contestNumber) => + contests.FirstOrDefault(c => c.ContestNumber == contestNumber); + + public ContestInstance AddContest(ContestInstance instance) + { + ContestInstance stored = instance with { ContestNumber = contests.Count + 1 }; + contests.Add(stored); + return stored; + } + + public void UpdateContest(ContestInstance instance) + { + int at = contests.FindIndex(c => c.ContestNumber == instance.ContestNumber); + contests[at] = instance; + } + + public IReadOnlyList Qsos(int contestNumber) => + qsos.Where(q => q.ContestNumber == contestNumber).OrderBy(q => q.TimestampUtc).ToList(); + + public Qso Add(Qso qso) + { + Qso candidate = qso; + while (qsos.Any(q => q.Call.Text == candidate.Call.Text && q.TimestampUtc == candidate.TimestampUtc)) + { + candidate = candidate with { TimestampUtc = candidate.TimestampUtc.AddSeconds(1) }; + } + qsos.Add(candidate); + return candidate; + } + + public void Update(Qso qso) + { + int at = qsos.FindIndex(q => q.Id == qso.Id); + qsos[at] = qso; + } + + public void Delete(string id) => qsos.RemoveAll(q => q.Id == id); + + public void Dispose() + { + } +} diff --git a/tests/Nonemm.Session.Tests/LoggingSessionTests.cs b/tests/Nonemm.Session.Tests/LoggingSessionTests.cs new file mode 100644 index 0000000..999c8ff --- /dev/null +++ b/tests/Nonemm.Session.Tests/LoggingSessionTests.cs @@ -0,0 +1,179 @@ +using Nonemm.Contests; +using Nonemm.Contests.Rules; +using Nonemm.Core; +using Nonemm.Core.Country; +using Nonemm.Storage; + +namespace Nonemm.Session.Tests; + +public class LoggingSessionTests +{ + private const string Countries = """ + Germany: 14: 28: EU: 51.00: -10.00: -1.0: DL: + DL,DK,DJ; + Japan: 25: 45: AS: 36.40: -138.38: -9.0: JA: + JA,JH,JR; + """; + + private static readonly StationInfo Me = new() + { + Callsign = "DL1ABC", + CqZone = 14, + ItuZone = 28, + Continent = "EU", + CountryPrefix = "DL", + }; + + private static LoggingSession Session(Contest? contest = null) + { + FakeLogStore store = new(); + ContestInstance instance = store.AddContest(new ContestInstance + { + ContestNumber = 0, + ContestName = "CQWW", + }); + return new LoggingSession( + store, + contest ?? new CqWorldWide(ModeCategory.Cw), + instance, + Me, + CountryFile.Parse(Countries)); + } + + private static void Type(LoggingSession session, string call, string zone) + { + session.Entry.Call = call; + session.Entry.Set(ExchangeSlot.ReceivedReport, "599"); + session.Entry.Set(ExchangeSlot.Zone, zone); + } + + [Fact] + public void LoggingAContactPutsItInTheLogAndClearsTheEntry() + { + LoggingSession session = Session(); + Type(session, "JA1XYZ", "25"); + Qso logged = session.LogContact(); + + Assert.Equal("JA1XYZ", logged.Call.Text); + Assert.Equal(25, logged.Zone); + Assert.Equal(3, logged.Points); + Assert.Equal("", session.Entry.Call); + Assert.Single(session.Log.Qsos); + } + + [Fact] + public void CountryAndPrefixAreFilledInFromTheCountryFile() + { + LoggingSession session = Session(); + Type(session, "JA1XYZ", "25"); + Qso logged = session.LogContact(); + + Assert.Equal("JA", logged.CountryPrefix); + Assert.Equal("AS", logged.Continent); + Assert.Equal("JA1", logged.WpxPrefix); + } + + [Fact] + public void AnIncompleteExchangeIsNotLogged() + { + LoggingSession session = Session(); + session.Entry.Call = "JA1XYZ"; + Assert.Throws(() => session.LogContact()); + } + + [Fact] + public void TheSameStationOnTheSameBandReadsAsADupe() + { + LoggingSession session = Session(); + Type(session, "JA1XYZ", "25"); + session.LogContact(); + Type(session, "JA1XYZ", "25"); + Assert.True(session.Verdict()?.IsDupe); + } + + [Fact] + public void MovingBandClearsTheDupe() + { + LoggingSession session = Session(); + Type(session, "JA1XYZ", "25"); + session.LogContact(); + session.Tune(Frequency.FromKilohertz(7_025)); + Type(session, "JA1XYZ", "25"); + Assert.False(session.Verdict()?.IsDupe); + } + + [Fact] + public void SerialNumbersCountUp() + { + LoggingSession session = Session(new CqWpx(ModeCategory.Cw)); + Assert.Equal(1, session.SentNumber); + session.Entry.Call = "JA1XYZ"; + session.Entry.Set(ExchangeSlot.ReceivedReport, "599"); + session.Entry.Set(ExchangeSlot.SerialNumber, "12"); + session.LogContact(); + Assert.Equal(2, session.SentNumber); + } + + [Fact] + public void SpaceMovesThroughTheBoxesAndBackToTheCall() + { + LoggingSession session = Session(); + Assert.Equal(0, session.Entry.Focus); + session.Entry.Advance(); + Assert.Equal(1, session.Entry.Focus); + session.Entry.Advance(); + session.Entry.Advance(); + Assert.Equal(0, session.Entry.Focus); + } + + [Theory] + [InlineData("14025", 14_025_000)] + [InlineData("14.025", 14_025_000)] + [InlineData("7025.5", 7_025_500)] + public void AFrequencyTypedIntoTheCallBoxIsRecognised(string typed, long hertz) + { + LoggingSession session = Session(); + session.Entry.Call = typed; + Assert.Equal(hertz, session.PendingQsy()?.Hertz); + } + + [Theory] + [InlineData("JA1XYZ")] + [InlineData("12000")] + public void ACallOrAnOutOfBandNumberIsNotAQsy(string typed) + { + LoggingSession session = Session(); + session.Entry.Call = typed; + Assert.Null(session.PendingQsy()); + } + + [Fact] + public void AContactDeletedFromTheLogGivesItsMultiplierBack() + { + LoggingSession session = Session(); + Type(session, "JA1XYZ", "25"); + Qso first = session.LogContact(); + Type(session, "JA2XYZ", "25"); + session.LogContact(); + Assert.Equal(2, session.Log.Tally.TotalMultipliers); + + session.Delete(first.Id); + Assert.Single(session.Log.Qsos); + Assert.Equal(2, session.Log.Tally.TotalMultipliers); + Assert.True(session.Log.Qsos.Single().IsMultiplier1); + } + + [Fact] + public void StoredContactsAreReadBackWhenTheSessionStarts() + { + FakeLogStore store = new(); + ContestInstance instance = store.AddContest(new ContestInstance { ContestNumber = 0, ContestName = "CQWW" }); + LoggingSession first = new(store, new CqWorldWide(ModeCategory.Cw), instance, Me, CountryFile.Parse(Countries)); + Type(first, "JA1XYZ", "25"); + first.LogContact(); + + LoggingSession second = new(store, new CqWorldWide(ModeCategory.Cw), instance, Me, CountryFile.Parse(Countries)); + Assert.Single(second.Log.Qsos); + Assert.Equal(3, second.Log.Tally.Points); + } +} diff --git a/tests/Nonemm.Session.Tests/Nonemm.Session.Tests.csproj b/tests/Nonemm.Session.Tests/Nonemm.Session.Tests.csproj new file mode 100644 index 0000000..15a7c1c --- /dev/null +++ b/tests/Nonemm.Session.Tests/Nonemm.Session.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/Nonemm.Spotting.Tests/BandmapTests.cs b/tests/Nonemm.Spotting.Tests/BandmapTests.cs new file mode 100644 index 0000000..223bc00 --- /dev/null +++ b/tests/Nonemm.Spotting.Tests/BandmapTests.cs @@ -0,0 +1,61 @@ +using Nonemm.Core; + +namespace Nonemm.Spotting.Tests; + +public class BandmapTests +{ + private static readonly DateTime Now = new(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc); + + private static Spot At(string call, double kilohertz, DateTime? at = null) => + new(Callsign.Parse(call), Frequency.FromKilohertz(kilohertz), at ?? Now, SpotSource.Cluster); + + [Fact] + public void SpotsComeBackInFrequencyOrder() + { + Bandmap map = new(); + map.Add(At("DL1ABC", 14_100)); + map.Add(At("JA1XYZ", 14_010)); + Assert.Equal(["JA1XYZ", "DL1ABC"], map.On(Bands.Band20M).Select(s => s.Call.Text)); + } + + [Fact] + public void AStationThatMovesKeepsOneEntryOnItsBand() + { + Bandmap map = new(); + map.Add(At("DL1ABC", 14_010)); + map.Add(At("DL1ABC", 14_020)); + Spot only = Assert.Single(map.On(Bands.Band20M)); + Assert.Equal(14_020_000, only.Frequency.Hertz); + } + + [Fact] + public void TheSameStationOnAnotherBandIsASeparateEntry() + { + Bandmap map = new(); + map.Add(At("DL1ABC", 14_010)); + map.Add(At("DL1ABC", 7_010)); + Assert.Equal(2, map.All().Count); + } + + [Fact] + public void SpotsOlderThanAnHourAreDropped() + { + Bandmap map = new(); + map.Add(At("DL1ABC", 14_010, Now.AddHours(-2))); + map.Add(At("JA1XYZ", 14_020, Now.AddMinutes(-5))); + map.DropOlderThan(Now); + Assert.Equal("JA1XYZ", Assert.Single(map.All()).Call.Text); + } + + [Fact] + public void TheNearestSpotInsideTheWindowIsFound() + { + Bandmap map = new(); + map.Add(At("DL1ABC", 14_010)); + map.Add(At("JA1XYZ", 14_050)); + Assert.Equal( + "DL1ABC", + map.Near(Frequency.FromKilohertz(14_010.2), Frequency.FromKilohertz(0.5))?.Call.Text); + Assert.Null(map.Near(Frequency.FromKilohertz(14_030), Frequency.FromKilohertz(0.5))); + } +} diff --git a/tests/Nonemm.Spotting.Tests/Nonemm.Spotting.Tests.csproj b/tests/Nonemm.Spotting.Tests/Nonemm.Spotting.Tests.csproj new file mode 100644 index 0000000..73d2ac0 --- /dev/null +++ b/tests/Nonemm.Spotting.Tests/Nonemm.Spotting.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/Nonemm.Spotting.Tests/SpotLineTests.cs b/tests/Nonemm.Spotting.Tests/SpotLineTests.cs new file mode 100644 index 0000000..39e398c --- /dev/null +++ b/tests/Nonemm.Spotting.Tests/SpotLineTests.cs @@ -0,0 +1,34 @@ +using Nonemm.Core; + +namespace Nonemm.Spotting.Tests; + +public class SpotLineTests +{ + private static readonly DateTime Now = new(2026, 5, 30, 12, 40, 0, DateTimeKind.Utc); + + [Fact] + public void ANodeSpotIsReadIntoItsParts() + { + Spot? spot = SpotLine.Parse( + "DX de W3LPL: 14025.0 DL1ABC CW 20 dB 25 WPM CQ 1234Z", + Now); + + Assert.NotNull(spot); + Assert.Equal("DL1ABC", spot.Call.Text); + Assert.Equal(14_025_000, spot.Frequency.Hertz); + Assert.Equal("W3LPL", spot.Spotter); + Assert.Equal(new DateTime(2026, 5, 30, 12, 34, 0, DateTimeKind.Utc), spot.AtUtc); + Assert.DoesNotContain("1234Z", spot.Comment); + } + + [Fact] + public void OtherTrafficIsNotASpot() => + Assert.Null(SpotLine.Parse("WWV de VE7CC <18Z> : SFI=142, A=7, K=2", Now)); + + [Fact] + public void ASpotTimedAfterNowCameInYesterday() + { + Spot? spot = SpotLine.Parse("DX de W3LPL: 14025.0 DL1ABC CQ 2350Z", Now); + Assert.Equal(new DateTime(2026, 5, 29, 23, 50, 0, DateTimeKind.Utc), spot?.AtUtc); + } +}