Give the telnet window the rest of what N1MM's has
The packet window showed traffic and took a command line, and everything else
about the cluster lived in a dialog under Config. It is now the window N1MM has,
with the same five tabs.
Telnet shows the traffic with spot lines in green, what went out in blue and
lines from a preferred spotter in bold. Double clicking a spot line, or "Jump to
this spot", puts the radio there with the call in the entry window. Scrolling
stops while the pointer is over the traffic. The client keeps the last two
hundred lines, so a window opened mid-contest is not blank.
Clusters keeps the operator's nodes with their ports, passwords and after-login
commands, connects and disconnects, and holds the logon settings. Download
fetches the published list of telnet nodes from NG3K — around fifty, with the
sysop's call and a note about each — and clicking one fills the boxes in. N1MM
downloads its list from its own web service, which asks the operator to opt in
to data collection and is N1MM's to run, so this reads a public page instead.
ClusterList takes any page with telnet:// links in a table; a page it cannot
read leaves the stored list alone.
Filters decide which spots reach the bandmap: bands, modes, beacons, busted
calls, stations outside the call history file, blacklisted spotters and calls,
spots from outside your country, continent or a list of prefixes, and how long a
spot stays on the map. A busted spot is a call the callsign database has never
heard that is one character away from one it knows; a call nothing resembles is
kept, because that is what a new station looks like. Nothing is filtered out of
the traffic itself — the operator sees everything the node sends.
Buttons edits the twelve command buttons. A button takes what N1MM's takes: the
message macros, several commands separated by semicolons, or {CONN} and the name
of a favourite, which connects to that node instead of sending anything. The
label takes the macros too. Right-clicking a button opens the editor.
Config ▸ Cluster now opens this window on the Clusters tab rather than a dialog
of its own, which is where N1MM keeps those settings.
Three things that could take the program down while a cluster was connected:
Settings.Load read a null where the property is not nullable. A file that names
a key with a null value — one written before the property existed and then
edited — put that null straight through, because the property's own default only
runs when the key is missing. Opening the telnet window then threw on the first
list it touched. Every null is now put back to the default the property
declares, walking into the stored records and the lists of them.
Bandmap was written from the cluster's thread and read from the window's, so a
dictionary could be modified while a window enumerated it. Every method locks
now.
ClusterClient disposed its token source while its own loop still used it, and
the retry delay sat outside the catch, so a disconnect faulted the loop task.
Along the way the message macros were checked against N1MM's function-key
documentation, and several were wrong. {LOGGEDCALL} is N1MM's {LASTCALL}, the
serial is #, and there is no {MYZONE}; {NAME} and {GRIDSQUARE} stand for the
other station's name and grid, not ours; {OTHERMHZ} is the radio the operator is
not on. The single-character macros * and ! were missing. Added from the same
table: {LASTCALL}, {PREVNR}, {NAMEANDSPACE}, {CHNAME}, {GRID}, the two grid
bearings and the grid distance, {FREQ}, {FREQROUND}, the other-radio
frequencies, {TIMESTAMP} and {TIME2}. Frequencies are formatted the way N1MM
formats them, with R for the decimal point on CW. The macros that pass a station
to the other band take the second radio as a new argument, and stand for nothing
at a one-radio station.
Left out, and written down: saving spots to a database, which N1MM keeps in its
admin database rather than in the log file the two programs share; the
special-calls list; the two-character busted check, which is a few hundred
thousand lookups per spot against a few hundred for one character; and N1MM's
action macros, which need a different shape than an expander that returns a
string.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,41 +2,57 @@ 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.
|
||||
/// The stations on the band, in frequency order. A spot ages out after the
|
||||
/// timeout because a bandmap is a picture of the last hour or so, and a stale
|
||||
/// spot costs a move to an empty frequency.
|
||||
///
|
||||
/// Age is counted from when the spot arrived, not from the time written in it.
|
||||
/// A node with a wrong clock, or one replaying its backlog, would otherwise
|
||||
/// empty the bandmap as fast as it filled it.
|
||||
///
|
||||
/// Spots arrive on the cluster's thread and are read on the window's, so every
|
||||
/// method locks. The `Changed` event is raised outside the lock, on whichever
|
||||
/// thread made the change.
|
||||
public sealed class Bandmap
|
||||
{
|
||||
private readonly Dictionary<string, Spot> spots = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, DateTime> arrived = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly TimeSpan lifetime;
|
||||
private readonly Lock guard = new();
|
||||
private readonly Func<DateTime> clock;
|
||||
|
||||
public Bandmap(TimeSpan? lifetime = null, Func<DateTime>? clock = null)
|
||||
{
|
||||
this.lifetime = lifetime ?? TimeSpan.FromHours(1);
|
||||
Lifetime = lifetime ?? TimeSpan.FromHours(1);
|
||||
this.clock = clock ?? (() => DateTime.UtcNow);
|
||||
}
|
||||
|
||||
/// How long a spot stays on the map. N1MM asks for the same number in
|
||||
/// minutes, on the telnet window's Filters tab.
|
||||
public TimeSpan Lifetime { get; set; }
|
||||
|
||||
public event EventHandler? Changed;
|
||||
|
||||
public void Add(Spot spot)
|
||||
{
|
||||
string key = Key(spot);
|
||||
spots[key] = spot;
|
||||
arrived[key] = clock();
|
||||
lock (guard)
|
||||
{
|
||||
spots[key] = spot;
|
||||
arrived[key] = clock();
|
||||
}
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
public void Remove(Callsign call, Band band)
|
||||
{
|
||||
string key = $"{call.Text}|{band.Name}";
|
||||
arrived.Remove(key);
|
||||
if (spots.Remove(key))
|
||||
bool removed;
|
||||
lock (guard)
|
||||
{
|
||||
arrived.Remove(key);
|
||||
removed = spots.Remove(key);
|
||||
}
|
||||
if (removed)
|
||||
{
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
@@ -44,46 +60,64 @@ public sealed class Bandmap
|
||||
|
||||
public void DropOlderThan(DateTime nowUtc)
|
||||
{
|
||||
List<string> stale = spots
|
||||
.Where(pair => nowUtc - arrived.GetValueOrDefault(pair.Key, nowUtc) > lifetime)
|
||||
.Select(pair => pair.Key)
|
||||
.ToList();
|
||||
foreach (string key in stale)
|
||||
int dropped;
|
||||
lock (guard)
|
||||
{
|
||||
spots.Remove(key);
|
||||
arrived.Remove(key);
|
||||
List<string> stale = spots
|
||||
.Where(pair => nowUtc - arrived.GetValueOrDefault(pair.Key, nowUtc) > Lifetime)
|
||||
.Select(pair => pair.Key)
|
||||
.ToList();
|
||||
foreach (string key in stale)
|
||||
{
|
||||
spots.Remove(key);
|
||||
arrived.Remove(key);
|
||||
}
|
||||
dropped = stale.Count;
|
||||
}
|
||||
if (stale.Count > 0)
|
||||
if (dropped > 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> On(Band band)
|
||||
{
|
||||
lock (guard)
|
||||
{
|
||||
return 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();
|
||||
public IReadOnlyList<Spot> All()
|
||||
{
|
||||
lock (guard)
|
||||
{
|
||||
return 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)
|
||||
lock (guard)
|
||||
{
|
||||
long distance = Math.Abs(spot.Frequency.Hertz - frequency.Hertz);
|
||||
if (distance <= window.Hertz && distance < bestDistance)
|
||||
Spot? best = null;
|
||||
long bestDistance = long.MaxValue;
|
||||
foreach (Spot spot in spots.Values)
|
||||
{
|
||||
best = spot;
|
||||
bestDistance = distance;
|
||||
long distance = Math.Abs(spot.Frequency.Hertz - frequency.Hertz);
|
||||
if (distance <= window.Hertz && distance < bestDistance)
|
||||
{
|
||||
best = spot;
|
||||
bestDistance = distance;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/// One spot per station per band: a station that moves within the band keeps
|
||||
|
||||
@@ -6,27 +6,30 @@ using Nonemm.Spotting.Telnet;
|
||||
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
|
||||
/// node's traffic goes to the telnet 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
|
||||
{
|
||||
/// How much traffic is kept for a window that opens after the connection
|
||||
/// did, so it does not start blank.
|
||||
private const int LinesRemembered = 200;
|
||||
|
||||
/// N1MM waits this long for a login prompt and then sends the call anyway.
|
||||
private static readonly TimeSpan LoginDeadline = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// How long to wait for a password prompt before deciding there is none.
|
||||
private static readonly TimeSpan PasswordDeadline = TimeSpan.FromSeconds(3);
|
||||
|
||||
/// Nodes drop a connection that has said nothing for a quarter of an hour.
|
||||
private static readonly TimeSpan KeepAliveInterval = TimeSpan.FromMinutes(4);
|
||||
|
||||
private readonly string host;
|
||||
private readonly int port;
|
||||
private readonly string callsign;
|
||||
private readonly string password;
|
||||
private readonly IReadOnlyList<string> commandsAfterLogin;
|
||||
private readonly bool autoLogon;
|
||||
private readonly TimeSpan keepAliveInterval;
|
||||
private readonly TimeSpan retryInterval;
|
||||
private readonly CancellationTokenSource stopping = new();
|
||||
private readonly LineAssembler lines = new();
|
||||
private readonly Queue<ClusterLine> recent = new();
|
||||
private readonly Lock guard = new();
|
||||
|
||||
private TcpClient? client;
|
||||
private TelnetStream? telnet;
|
||||
@@ -41,13 +44,18 @@ public sealed class ClusterClient : IDisposable
|
||||
string callsign,
|
||||
IReadOnlyList<string>? commandsAfterLogin = null,
|
||||
string password = "",
|
||||
TimeSpan? retryInterval = null)
|
||||
TimeSpan? retryInterval = null,
|
||||
bool autoLogon = true,
|
||||
TimeSpan? keepAliveInterval = null)
|
||||
{
|
||||
this.host = host;
|
||||
this.port = port;
|
||||
Host = host;
|
||||
Port = port;
|
||||
this.callsign = callsign;
|
||||
this.password = password;
|
||||
this.commandsAfterLogin = commandsAfterLogin ?? [];
|
||||
this.autoLogon = autoLogon;
|
||||
// nodes drop a connection that has said nothing for a quarter of an hour
|
||||
this.keepAliveInterval = keepAliveInterval ?? TimeSpan.FromMinutes(4);
|
||||
this.retryInterval = retryInterval ?? TimeSpan.FromSeconds(10);
|
||||
}
|
||||
|
||||
@@ -58,14 +66,34 @@ public sealed class ClusterClient : IDisposable
|
||||
Done,
|
||||
}
|
||||
|
||||
public string Host { get; }
|
||||
|
||||
public int Port { get; }
|
||||
|
||||
public bool IsConnected { get; private set; }
|
||||
|
||||
public event EventHandler<Spot>? SpotArrived;
|
||||
|
||||
public event EventHandler<string>? LineArrived;
|
||||
|
||||
/// What went out to the node, so the telnet window can show the login and
|
||||
/// the commands beside the node's answers.
|
||||
public event EventHandler<string>? LineSent;
|
||||
|
||||
public event EventHandler<bool>? ConnectionChanged;
|
||||
|
||||
/// The traffic so far, oldest first, for a window that has just opened.
|
||||
public IReadOnlyList<ClusterLine> Recent
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (guard)
|
||||
{
|
||||
return [.. recent];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Start() => loop ??= Task.Run(() => RunAsync(stopping.Token));
|
||||
|
||||
public async Task SendAsync(string line, CancellationToken cancellation = default)
|
||||
@@ -77,6 +105,11 @@ public sealed class ClusterClient : IDisposable
|
||||
try
|
||||
{
|
||||
await telnet.WriteLineAsync(line, cancellation).ConfigureAwait(false);
|
||||
if (line.Length > 0)
|
||||
{
|
||||
Remember(line, sent: true);
|
||||
LineSent?.Invoke(this, line);
|
||||
}
|
||||
}
|
||||
catch (Exception e) when (e is IOException or SocketException or ObjectDisposedException)
|
||||
{
|
||||
@@ -100,27 +133,38 @@ public sealed class ClusterClient : IDisposable
|
||||
{
|
||||
stopping.Cancel();
|
||||
client?.Dispose();
|
||||
stopping.Dispose();
|
||||
}
|
||||
|
||||
private async Task RunAsync(CancellationToken cancellation)
|
||||
{
|
||||
while (!cancellation.IsCancellationRequested)
|
||||
if (Host.Length == 0)
|
||||
{
|
||||
try
|
||||
Notice("*** no cluster node is configured");
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
while (!cancellation.IsCancellationRequested)
|
||||
{
|
||||
await SessionAsync(cancellation).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await SessionAsync(cancellation).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception e) when (
|
||||
e is IOException or SocketException or ArgumentException or ObjectDisposedException)
|
||||
{
|
||||
if (cancellation.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Notice($"*** {Host}:{Port} {e.Message}");
|
||||
}
|
||||
SetConnected(false);
|
||||
await Task.Delay(retryInterval, 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);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,10 +172,10 @@ public sealed class ClusterClient : IDisposable
|
||||
{
|
||||
client?.Dispose();
|
||||
client = new TcpClient();
|
||||
await client.ConnectAsync(host, port, cancellation).ConfigureAwait(false);
|
||||
await client.ConnectAsync(Host, Port, cancellation).ConfigureAwait(false);
|
||||
telnet = new TelnetStream(client.GetStream());
|
||||
lines.Clear();
|
||||
login = Login.WaitingForCallsign;
|
||||
login = autoLogon ? Login.WaitingForCallsign : Login.Done;
|
||||
deadline = DateTime.UtcNow + LoginDeadline;
|
||||
lastHeard = DateTime.UtcNow;
|
||||
SetConnected(true);
|
||||
@@ -175,6 +219,7 @@ public sealed class ClusterClient : IDisposable
|
||||
|
||||
private async Task TakeLineAsync(string line, CancellationToken cancellation)
|
||||
{
|
||||
Remember(line, sent: false);
|
||||
LineArrived?.Invoke(this, line);
|
||||
Spot? spot = SpotLine.Parse(line, DateTime.UtcNow);
|
||||
if (spot is not null)
|
||||
@@ -234,7 +279,7 @@ public sealed class ClusterClient : IDisposable
|
||||
{
|
||||
await FinishLoginAsync(cancellation).ConfigureAwait(false);
|
||||
}
|
||||
if (login == Login.Done && now - lastHeard > KeepAliveInterval)
|
||||
if (login == Login.Done && now - lastHeard > keepAliveInterval)
|
||||
{
|
||||
lastHeard = now;
|
||||
await SendAsync("", cancellation).ConfigureAwait(false);
|
||||
@@ -242,6 +287,24 @@ public sealed class ClusterClient : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private void Notice(string line)
|
||||
{
|
||||
Remember(line, sent: false);
|
||||
LineArrived?.Invoke(this, line);
|
||||
}
|
||||
|
||||
private void Remember(string text, bool sent)
|
||||
{
|
||||
lock (guard)
|
||||
{
|
||||
recent.Enqueue(new ClusterLine(text, sent));
|
||||
while (recent.Count > LinesRemembered)
|
||||
{
|
||||
recent.Dequeue();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task Ignoring(Task task)
|
||||
{
|
||||
try
|
||||
|
||||
5
src/Nonemm.Spotting/ClusterLine.cs
Normal file
5
src/Nonemm.Spotting/ClusterLine.cs
Normal file
@@ -0,0 +1,5 @@
|
||||
namespace Nonemm.Spotting;
|
||||
|
||||
/// One line of the node's traffic. `WasSent` marks the lines that went out, so
|
||||
/// the telnet window can tell the operator's commands from the node's answers.
|
||||
public sealed record ClusterLine(string Text, bool WasSent);
|
||||
66
src/Nonemm.Spotting/ClusterList.cs
Normal file
66
src/Nonemm.Spotting/ClusterList.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Nonemm.Spotting;
|
||||
|
||||
/// Reads a published page of cluster nodes. N1MM downloads its list from its
|
||||
/// own web service; this reads the public list at NG3K, which is a table with
|
||||
/// one `telnet://host:port` link per node.
|
||||
///
|
||||
/// The reading is deliberately loose: any page that holds `telnet://` links
|
||||
/// gives up its nodes, and the surrounding table only supplies the name and the
|
||||
/// note beside it.
|
||||
public static class ClusterList
|
||||
{
|
||||
/// A node that names no port is a plain telnet node on port 23.
|
||||
private const int DefaultPort = 23;
|
||||
|
||||
private static readonly Regex Rows =
|
||||
new("<tr[^>]*>(.*?)</tr>", RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
||||
|
||||
private static readonly Regex Cells =
|
||||
new("<td[^>]*>(.*?)</td>", RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
||||
|
||||
private static readonly Regex Address =
|
||||
new(@"telnet://([A-Za-z0-9][A-Za-z0-9.\-]*)(?:[:\s]+([0-9]{1,5}))?", RegexOptions.IgnoreCase);
|
||||
|
||||
private static readonly Regex Tags = new("<[^>]*>", RegexOptions.Singleline);
|
||||
|
||||
/// Throws `FormatException` when the page holds no nodes, so a site that
|
||||
/// answers with an apology page cannot replace a good list.
|
||||
public static IReadOnlyList<ClusterNode> Parse(string html)
|
||||
{
|
||||
List<ClusterNode> found = [];
|
||||
HashSet<string> seen = new(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (Match row in Rows.Matches(html))
|
||||
{
|
||||
string[] cells = [.. Cells.Matches(row.Groups[1].Value).Select(c => Text(c.Groups[1].Value))];
|
||||
if (Address.Match(row.Groups[1].Value) is not { Success: true } address)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
string host = address.Groups[1].Value;
|
||||
int port = address.Groups[2].Success
|
||||
? int.Parse(address.Groups[2].Value, System.Globalization.CultureInfo.InvariantCulture)
|
||||
: DefaultPort;
|
||||
if (port is < 1 or > 65535 || !seen.Add($"{host}:{port}"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
found.Add(new ClusterNode(
|
||||
cells.Length > 0 && cells[0].Length > 0 ? cells[0] : host,
|
||||
host,
|
||||
port,
|
||||
cells.Length > 1 ? cells[^1] : ""));
|
||||
}
|
||||
if (found.Count == 0)
|
||||
{
|
||||
throw new FormatException("this page holds no telnet cluster nodes");
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
private static string Text(string html) =>
|
||||
string.Join(' ', WebUtility.HtmlDecode(Tags.Replace(html, " "))
|
||||
.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
|
||||
}
|
||||
9
src/Nonemm.Spotting/ClusterNode.cs
Normal file
9
src/Nonemm.Spotting/ClusterNode.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace Nonemm.Spotting;
|
||||
|
||||
/// One node from a published list of clusters. `Description` is whatever the
|
||||
/// page says about it — where it is, which cluster program it runs, whether it
|
||||
/// carries skimmer spots.
|
||||
public sealed record ClusterNode(string Name, string Host, int Port, string Description = "")
|
||||
{
|
||||
public string Address => $"{Host}:{Port}";
|
||||
}
|
||||
164
src/Nonemm.Spotting/SpotFilter.cs
Normal file
164
src/Nonemm.Spotting/SpotFilter.cs
Normal file
@@ -0,0 +1,164 @@
|
||||
using Nonemm.Core;
|
||||
using Nonemm.Core.Calls;
|
||||
using Nonemm.Core.Country;
|
||||
|
||||
namespace Nonemm.Spotting;
|
||||
|
||||
/// Which cluster spots reach the bandmap. N1MM puts the same choices on the
|
||||
/// telnet window's Filters tab. Nothing is set out of the box, and then every
|
||||
/// spot passes.
|
||||
///
|
||||
/// Only cluster spots are filtered. A station the operator spotted by hand, or
|
||||
/// one taken from the log, is always shown.
|
||||
public sealed record SpotFilter
|
||||
{
|
||||
/// Empty means every band.
|
||||
public IReadOnlyList<Band> Bands { get; init; } = [];
|
||||
|
||||
/// Empty means every mode. The mode comes from the band plan, because a
|
||||
/// spot line does not carry one.
|
||||
public IReadOnlyList<ModeCategory> Modes { get; init; } = [];
|
||||
|
||||
public BandPlan Plan { get; init; } = BandPlan.Default;
|
||||
|
||||
/// Needed for the country and continent filters. Without it those two let
|
||||
/// everything through, the way the rest of the program works without a
|
||||
/// country file.
|
||||
public CountryFile? Countries { get; init; }
|
||||
|
||||
public string MyCountry { get; init; } = "";
|
||||
|
||||
public string MyContinent { get; init; } = "";
|
||||
|
||||
public bool MyCountryOnly { get; init; }
|
||||
|
||||
public bool MyContinentOnly { get; init; }
|
||||
|
||||
/// Prefixes a spotter's call may start with, such as `K1 VE3`.
|
||||
public IReadOnlyList<string> CallAreas { get; init; } = [];
|
||||
|
||||
public IReadOnlyList<string> BlockedSpotters { get; init; } = [];
|
||||
|
||||
public IReadOnlyList<string> BlockedCalls { get; init; } = [];
|
||||
|
||||
public bool ShowBeacons { get; init; } = true;
|
||||
|
||||
/// The callsign database, for the busted-spot check. Without it nothing is
|
||||
/// called busted.
|
||||
public CallDatabase? Calls { get; init; }
|
||||
|
||||
/// Drops a spotted call the database has never heard when it is one
|
||||
/// character away from a call the database does know. That is a miscopy,
|
||||
/// not a new station. A call nothing resembles is kept, because that is
|
||||
/// what a new station looks like.
|
||||
public bool RemoveBustedSpots { get; init; }
|
||||
|
||||
/// The call history file for this contest, for the choice below.
|
||||
public CallHistory? History { get; init; }
|
||||
|
||||
/// Keeps only the stations the call history file lists, which for a state
|
||||
/// QSO party or a club contest is the stations worth working.
|
||||
public bool OnlyInCallHistory { get; init; }
|
||||
|
||||
public bool Accepts(Spot spot)
|
||||
{
|
||||
if (spot.Source != SpotSource.Cluster)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (Bands.Count > 0 && (spot.Band is not { } band || !Bands.Contains(band)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (Modes.Count > 0 && (Plan.ModeAt(spot.Frequency) is not { } mode || !Modes.Contains(mode)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (!ShowBeacons && IsBeacon(spot.Comment))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (Holds(BlockedCalls, spot.Call.Text) || Holds(BlockedSpotters, spot.Spotter))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (OnlyInCallHistory && History is not null && History.Find(spot.Call.Text) is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (RemoveBustedSpots && LooksBusted(spot.Call.Text))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return AcceptsSpotter(spot.Spotter);
|
||||
}
|
||||
|
||||
/// The three "include spots only originating in" choices are inclusive: a
|
||||
/// spot passes when it matches any of the ones that are on, and all spots
|
||||
/// pass while none of them is. Without a country file the country and
|
||||
/// continent choices cannot be judged, so they are left out.
|
||||
private bool AcceptsSpotter(string spotter)
|
||||
{
|
||||
bool byCallArea = CallAreas.Count > 0;
|
||||
if (Countries is not { } countries || !(MyCountryOnly || MyContinentOnly))
|
||||
{
|
||||
return !byCallArea || StartsWithAny(spotter, CallAreas);
|
||||
}
|
||||
if (byCallArea && StartsWithAny(spotter, CallAreas))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (countries.Find(spotter) is not { } home)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return (MyCountryOnly && Same(home.Entity.PrimaryPrefix, MyCountry))
|
||||
|| (MyContinentOnly && Same(home.Continent, MyContinent));
|
||||
}
|
||||
|
||||
private bool LooksBusted(string call)
|
||||
{
|
||||
if (Calls is not { Count: > 0 } database || database.Holds(call))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return OneCharacterOff(call).Any(database.Holds);
|
||||
}
|
||||
|
||||
/// Every call one character away: one deleted, one changed, one inserted.
|
||||
private static IEnumerable<string> OneCharacterOff(string call)
|
||||
{
|
||||
const string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/";
|
||||
for (int at = 0; at < call.Length; at++)
|
||||
{
|
||||
yield return call.Remove(at, 1);
|
||||
foreach (char letter in alphabet)
|
||||
{
|
||||
if (letter != call[at])
|
||||
{
|
||||
yield return string.Concat(call.AsSpan(0, at), letter.ToString(), call.AsSpan(at + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (int at = 0; at <= call.Length; at++)
|
||||
{
|
||||
foreach (char letter in alphabet)
|
||||
{
|
||||
yield return string.Concat(call.AsSpan(0, at), letter.ToString(), call.AsSpan(at));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsBeacon(string comment) =>
|
||||
comment.Split(' ', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Any(word => Same(word, "BCN") || Same(word, "BEACON"));
|
||||
|
||||
private static bool Holds(IReadOnlyList<string> calls, string call) =>
|
||||
calls.Any(one => Same(one, call));
|
||||
|
||||
private static bool StartsWithAny(string call, IReadOnlyList<string> prefixes) =>
|
||||
prefixes.Any(prefix =>
|
||||
prefix.Length > 0 && call.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static bool Same(string a, string b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
22
src/Nonemm.Spotting/SpotJitter.cs
Normal file
22
src/Nonemm.Spotting/SpotJitter.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Spotting;
|
||||
|
||||
/// N1MM's "randomise incoming spot frequencies". A CW spot is moved thirty or
|
||||
/// sixty hertz either way, so the operator has to find the station by ear
|
||||
/// rather than land exactly on it. Some contest categories are entered that
|
||||
/// way on purpose.
|
||||
public static class SpotJitter
|
||||
{
|
||||
private static readonly int[] Offsets = [-60, -30, 30, 60];
|
||||
|
||||
/// Phone and digital spots are left alone: they are wide enough that thirty
|
||||
/// hertz changes nothing.
|
||||
public static Spot Shifted(Spot spot, BandPlan plan, Random random) =>
|
||||
plan.ModeAt(spot.Frequency) == ModeCategory.Cw
|
||||
? spot with
|
||||
{
|
||||
Frequency = Frequency.FromHertz(spot.Frequency.Hertz + Offsets[random.Next(Offsets.Length)]),
|
||||
}
|
||||
: spot;
|
||||
}
|
||||
Reference in New Issue
Block a user