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:
@@ -1,3 +1,4 @@
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using Nonemm.Core;
|
||||
|
||||
@@ -21,6 +22,37 @@ public sealed record Settings
|
||||
|
||||
public IReadOnlyList<string> ClusterCommands { get; init; } = [];
|
||||
|
||||
/// The nodes the operator keeps, listed on the telnet window's Clusters
|
||||
/// tab. Connecting to one copies it into `ClusterHost` and the rest.
|
||||
public IReadOnlyList<StoredClusterNode> ClusterNodes { get; init; } = [];
|
||||
|
||||
/// Off for a node that wants the call typed in by hand.
|
||||
public bool ClusterAutoLogon { get; init; } = true;
|
||||
|
||||
/// The call sent at login, or empty for the station callsign.
|
||||
public string ClusterLogonCall { get; init; } = "";
|
||||
|
||||
/// How often to send an empty line to a node that has said nothing. Nodes
|
||||
/// drop a connection that has been quiet for a quarter of an hour.
|
||||
public int ClusterKeepAliveMinutes { get; init; } = 4;
|
||||
|
||||
/// The buttons along the bottom of the telnet window. Empty means the ones
|
||||
/// in `StoredTelnetButton.Default`.
|
||||
public IReadOnlyList<StoredTelnetButton> TelnetButtons { get; init; } = [];
|
||||
|
||||
/// Which cluster spots reach the bandmap.
|
||||
public StoredSpotFilter SpotFilter { get; init; } = new();
|
||||
|
||||
/// How long a spot stays on the bandmap. N1MM's own default is 60.
|
||||
public int SpotTimeoutMinutes { get; init; } = 60;
|
||||
|
||||
/// Moves each incoming CW spot by a few tens of hertz, so the operator has
|
||||
/// to find the station by ear. N1MM calls it randomising.
|
||||
public bool RandomizeSpots { get; init; }
|
||||
|
||||
/// Sent with a spot the operator puts on the cluster with Alt+P.
|
||||
public string SpotComment { get; init; } = "";
|
||||
|
||||
/// One entry per radio, in radio-number order. A second radio makes the
|
||||
/// station SO2R.
|
||||
public IReadOnlyList<StoredRadio> Radios { get; init; } = [];
|
||||
@@ -103,8 +135,9 @@ public sealed record Settings
|
||||
}
|
||||
try
|
||||
{
|
||||
return Migrated(
|
||||
JsonSerializer.Deserialize<Settings>(File.ReadAllText(path), Json) ?? new Settings());
|
||||
Settings read = JsonSerializer.Deserialize<Settings>(File.ReadAllText(path), Json) ?? new Settings();
|
||||
FillNulls(read);
|
||||
return Migrated(read);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
@@ -131,6 +164,51 @@ public sealed record Settings
|
||||
public void Save(string path) =>
|
||||
File.WriteAllText(path, JsonSerializer.Serialize(this, Json));
|
||||
|
||||
/// A settings file can hold a null where the property is not nullable: a
|
||||
/// file written before the property existed and then edited, or one an
|
||||
/// older version wrote. JSON puts the null in and the property's own
|
||||
/// default never runs, so the program reads a null it does not expect.
|
||||
/// Every null is put back to the default the property declares, top to
|
||||
/// bottom, so nothing downstream has to check.
|
||||
private static void FillNulls(object target)
|
||||
{
|
||||
Type type = target.GetType();
|
||||
object fresh = Activator.CreateInstance(type)!;
|
||||
foreach (PropertyInfo property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
|
||||
{
|
||||
if (!property.CanWrite || property.GetIndexParameters().Length > 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
object? value = property.GetValue(target);
|
||||
if (value is null)
|
||||
{
|
||||
property.SetValue(target, property.GetValue(fresh));
|
||||
}
|
||||
else if (value is System.Collections.IEnumerable items and not string)
|
||||
{
|
||||
foreach (object? item in items)
|
||||
{
|
||||
FillOurOwn(item);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
FillOurOwn(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Only the types in this folder are walked into. A string, a number or a
|
||||
/// framework type has nothing to fill in.
|
||||
private static void FillOurOwn(object? value)
|
||||
{
|
||||
if (value is not null && value.GetType().Namespace == typeof(Settings).Namespace)
|
||||
{
|
||||
FillNulls(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// Carries the single radio an older settings file holds into the list.
|
||||
private static Settings Migrated(Settings settings) =>
|
||||
settings.Radios.Count > 0 || settings.RigctldHost.Length == 0
|
||||
|
||||
24
src/Nonemm.App/Configuration/StoredClusterNode.cs
Normal file
24
src/Nonemm.App/Configuration/StoredClusterNode.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Nonemm.App.Configuration;
|
||||
|
||||
/// One cluster node in the operator's list. N1MM keeps the same list in its
|
||||
/// admin database and calls it Favorites.
|
||||
public sealed record StoredClusterNode
|
||||
{
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
public string Host { get; init; } = "";
|
||||
|
||||
public int Port { get; init; } = 7373;
|
||||
|
||||
/// Only the few nodes that ask for one; most take the callsign alone.
|
||||
public string Password { get; init; } = "";
|
||||
|
||||
/// Sent to this node after login, one command per entry.
|
||||
public IReadOnlyList<string> Commands { get; init; } = [];
|
||||
|
||||
/// How the node reads in the favourites list.
|
||||
[JsonIgnore]
|
||||
public string Label => Name.Length > 0 ? $"{Name} — {Host}:{Port}" : $"{Host}:{Port}";
|
||||
}
|
||||
70
src/Nonemm.App/Configuration/StoredSpotFilter.cs
Normal file
70
src/Nonemm.App/Configuration/StoredSpotFilter.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
using Nonemm.Core;
|
||||
using Nonemm.Core.Calls;
|
||||
using Nonemm.Core.Country;
|
||||
using Nonemm.Spotting;
|
||||
|
||||
namespace Nonemm.App.Configuration;
|
||||
|
||||
/// The telnet window's filter settings as they are stored: band and mode names
|
||||
/// rather than the types, so the file keeps working when those change.
|
||||
public sealed record StoredSpotFilter
|
||||
{
|
||||
/// Band names as `Bands` spells them. Empty means every band.
|
||||
public IReadOnlyList<string> Bands { get; init; } = [];
|
||||
|
||||
/// `CW`, `PHONE` or `DIGITAL`. Empty means every mode.
|
||||
public IReadOnlyList<string> Modes { get; init; } = [];
|
||||
|
||||
public bool MyCountryOnly { get; init; }
|
||||
|
||||
public bool MyContinentOnly { get; init; }
|
||||
|
||||
public IReadOnlyList<string> CallAreas { get; init; } = [];
|
||||
|
||||
public IReadOnlyList<string> BlockedSpotters { get; init; } = [];
|
||||
|
||||
public IReadOnlyList<string> BlockedCalls { get; init; } = [];
|
||||
|
||||
public bool ShowBeacons { get; init; } = true;
|
||||
|
||||
public bool RemoveBustedSpots { get; init; }
|
||||
|
||||
public bool OnlyInCallHistory { get; init; }
|
||||
|
||||
/// Spotters whose lines the telnet window paints, so the ones worth
|
||||
/// believing stand out. N1MM takes up to six.
|
||||
public IReadOnlyList<string> PreferredSpotters { get; init; } = [];
|
||||
|
||||
public SpotFilter ToFilter(
|
||||
BandPlan plan,
|
||||
StoredStation station,
|
||||
CountryFile? countries,
|
||||
CallDatabase? calls,
|
||||
CallHistory? history) => new()
|
||||
{
|
||||
Bands = [.. Bands.Select(Core.Bands.Named).OfType<Band>()],
|
||||
Modes = [.. Modes.Select(ModeOf).OfType<ModeCategory>()],
|
||||
Plan = plan,
|
||||
Countries = countries,
|
||||
MyCountry = station.CountryPrefix,
|
||||
MyContinent = station.Continent,
|
||||
MyCountryOnly = MyCountryOnly,
|
||||
MyContinentOnly = MyContinentOnly,
|
||||
CallAreas = CallAreas,
|
||||
BlockedSpotters = BlockedSpotters,
|
||||
BlockedCalls = BlockedCalls,
|
||||
ShowBeacons = ShowBeacons,
|
||||
Calls = calls,
|
||||
RemoveBustedSpots = RemoveBustedSpots,
|
||||
History = history,
|
||||
OnlyInCallHistory = OnlyInCallHistory,
|
||||
};
|
||||
|
||||
private static ModeCategory? ModeOf(string name) => name.ToUpperInvariant() switch
|
||||
{
|
||||
"CW" => ModeCategory.Cw,
|
||||
"PHONE" => ModeCategory.Phone,
|
||||
"DIGITAL" => ModeCategory.Digital,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
24
src/Nonemm.App/Configuration/StoredTelnetButton.cs
Normal file
24
src/Nonemm.App/Configuration/StoredTelnetButton.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
namespace Nonemm.App.Configuration;
|
||||
|
||||
/// One button on the telnet window. The command holds what N1MM's buttons hold:
|
||||
/// the message macros, several commands separated by semicolons, or `{CONN}` and
|
||||
/// the name of a favourite to connect to it.
|
||||
public sealed record StoredTelnetButton
|
||||
{
|
||||
/// What the operator gets before changing anything: the commands a node
|
||||
/// answers on any of the four cluster programs.
|
||||
public static readonly IReadOnlyList<StoredTelnetButton> Default =
|
||||
[
|
||||
new() { Label = "Sh/DX", Command = "sh/dx" },
|
||||
new() { Label = "Sh/DX/20", Command = "sh/dx/20" },
|
||||
new() { Label = "WWV", Command = "sh/wwv" },
|
||||
new() { Label = "Users", Command = "sh/users" },
|
||||
new() { Label = "Nodes", Command = "sh/c/n" },
|
||||
new() { Label = "Help", Command = "help" },
|
||||
new() { Label = "Bye", Command = "bye" },
|
||||
];
|
||||
|
||||
public string Label { get; init; } = "";
|
||||
|
||||
public string Command { get; init; } = "";
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using Nonemm.Core.Calls;
|
||||
using Nonemm.Core.Country;
|
||||
using Nonemm.Spotting;
|
||||
|
||||
namespace Nonemm.App.Configuration;
|
||||
|
||||
@@ -13,6 +14,11 @@ public sealed class SupportFileDownloader
|
||||
public const string CountryFileUrl = "https://www.country-files.com/cty/wl_cty.dat";
|
||||
public const string CallDatabaseUrl = "https://www.supercheckpartial.com/MASTER.SCP";
|
||||
|
||||
/// NG3K's list of telnet cluster nodes. 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; this is a public page anyone may read.
|
||||
public const string ClusterListUrl = "https://www.ng3k.com/misc/cluster.html";
|
||||
|
||||
private readonly HttpClient http;
|
||||
|
||||
public SupportFileDownloader(HttpClient http) => this.http = http;
|
||||
@@ -23,6 +29,9 @@ public sealed class SupportFileDownloader
|
||||
public Task<string> DownloadCallDatabaseAsync(string path, CancellationToken cancellation = default) =>
|
||||
DownloadAsync(CallDatabaseUrl, path, text => CallDatabase.Parse(text).Count, "callsigns", cancellation);
|
||||
|
||||
public Task<string> DownloadClusterListAsync(string path, CancellationToken cancellation = default) =>
|
||||
DownloadAsync(ClusterListUrl, path, text => ClusterList.Parse(text).Count, "cluster nodes", cancellation);
|
||||
|
||||
private async Task<string> DownloadAsync(
|
||||
string url,
|
||||
string path,
|
||||
|
||||
@@ -28,6 +28,10 @@ public sealed class UserPaths
|
||||
|
||||
public string CallDatabaseFile => Path.Combine(SupportFiles, "MASTER.SCP");
|
||||
|
||||
/// The published list of cluster nodes, kept as the page it was downloaded
|
||||
/// from so a later version can read more out of it.
|
||||
public string ClusterListFile => Path.Combine(SupportFiles, "cluster-list.html");
|
||||
|
||||
public void CreateFolders()
|
||||
{
|
||||
Directory.CreateDirectory(Root);
|
||||
|
||||
Reference in New Issue
Block a user