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