Add the session, radio control, bandmap and cluster

Nonemm.Session holds what the operator is typing, what the log says about it and
what happens on Enter, with no UI toolkit behind it. Nonemm.Rig talks to
hamlib's rigctld and reconnects on its own. Nonemm.Spotting reads DX cluster
lines into a bandmap that drops spots after an hour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik
2026-08-27 10:39:53 +00:00
parent ef631f1dc4
commit ffeeb2cdc1
25 changed files with 1416 additions and 0 deletions

View File

@@ -15,5 +15,7 @@
<Project Path="tests/Nonemm.Contests.Tests/Nonemm.Contests.Tests.csproj" />
<Project Path="tests/Nonemm.Formats.Tests/Nonemm.Formats.Tests.csproj" />
<Project Path="tests/Nonemm.Storage.Tests/Nonemm.Storage.Tests.csproj" />
<Project Path="tests/Nonemm.Session.Tests/Nonemm.Session.Tests.csproj" />
<Project Path="tests/Nonemm.Spotting.Tests/Nonemm.Spotting.Tests.csproj" />
</Folder>
</Solution>

View File

@@ -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<string> calls;
private CallDatabase(IReadOnlyList<string> 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<string> 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<PartialMatch> Matches(string query, int limit = 40)
{
List<PartialMatch> 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 == '/');
}

View File

@@ -0,0 +1,3 @@
namespace Nonemm.Core.Calls;
public sealed record PartialMatch(string Call, PartialMatchQuality Quality);

View File

@@ -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,
}

View File

@@ -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);
}

View File

@@ -1,5 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Nonemm.Core\Nonemm.Core.csproj" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>

23
src/Nonemm.Rig/Radio.cs Normal file
View File

@@ -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<RadioState>? Moved;
/// Raised when the connection comes up or goes down.
event EventHandler<bool>? ConnectionChanged;
Task TuneAsync(Frequency frequency, CancellationToken cancellation = default);
Task SetModeAsync(Mode mode, CancellationToken cancellation = default);
}

View File

@@ -0,0 +1,6 @@
using Nonemm.Core;
namespace Nonemm.Rig;
/// Where the radio is now.
public sealed record RadioState(Frequency Frequency, Mode Mode);

View File

@@ -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<RadioState>? Moved;
public event EventHandler<bool>? 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<IReadOnlyList<string>> 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<string> 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<string> 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<string> 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",
};
}

View File

@@ -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<string> values;
public EntryFields(IReadOnlyList<ExchangeField> exchange)
{
Exchange = exchange;
values = [.. Enumerable.Repeat("", exchange.Count)];
}
public IReadOnlyList<ExchangeField> 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;
}
}

View File

@@ -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;
}
}

View File

@@ -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<Qso>? 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<Qso> 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;
}

View File

@@ -1,5 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Nonemm.Core\Nonemm.Core.csproj" />
<ProjectReference Include="..\Nonemm.Contests\Nonemm.Contests.csproj" />
<ProjectReference Include="..\Nonemm.Storage\Nonemm.Storage.csproj" />
<ProjectReference Include="..\Nonemm.Formats\Nonemm.Formats.csproj" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>

View File

@@ -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<string, Spot> 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<string> 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<Spot> On(Band band) =>
spots.Values
.Where(s => s.Band == band)
.OrderBy(s => s.Frequency.Hertz)
.ToList();
public IReadOnlyList<Spot> 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}";
}

View File

@@ -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<string> 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<string>? 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<Spot>? SpotArrived;
public event EventHandler<string>? LineArrived;
public event EventHandler<bool>? 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);
}
}
}

View File

@@ -1,5 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Nonemm.Core\Nonemm.Core.csproj" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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,
}

View File

@@ -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<Qso> qsos = [];
private readonly List<ContestInstance> contests = [];
public IReadOnlyList<ContestInstance> 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<Qso> 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()
{
}
}

View File

@@ -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<InvalidOperationException>(() => 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);
}
}

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Nonemm.Session\Nonemm.Session.csproj" />
<ProjectReference Include="..\..\src\Nonemm.Core\Nonemm.Core.csproj" />
<ProjectReference Include="..\..\src\Nonemm.Contests\Nonemm.Contests.csproj" />
<ProjectReference Include="..\..\src\Nonemm.Storage\Nonemm.Storage.csproj" />
</ItemGroup>
</Project>

View File

@@ -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)));
}
}

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Nonemm.Spotting\Nonemm.Spotting.csproj" />
<ProjectReference Include="..\..\src\Nonemm.Core\Nonemm.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -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);
}
}