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

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