diff --git a/Nonemm.slnx b/Nonemm.slnx
index d7f39d1..839928a 100644
--- a/Nonemm.slnx
+++ b/Nonemm.slnx
@@ -21,5 +21,6 @@
+
diff --git a/README.md b/README.md
index aa76f7a..e8da5cc 100644
--- a/README.md
+++ b/README.md
@@ -31,7 +31,7 @@ To work a contest: **Config → Station** for your callsign and zones, then
**File → New Database** and **File → New Contest**. In the entry window, type a
callsign and press **space** to move to the exchange, then **Enter** to log.
Type a frequency into the callsign box and press Enter to change band. **View**
-opens the log, check, bandmap, score and packet windows.
+opens the log, check, bandmap, score and telnet windows.
Everything that judges a station — the bar under the callsign box, a row in the
check window, a spot on the bandmap, a line in the log — is coloured by the same
@@ -44,10 +44,10 @@ scorer: red for a dupe, green for a new multiplier, blue for points.
| Contests | CQ WW (CW, SSB, RTTY), CQ WPX (CW, SSB, RTTY), WAE (CW, SSB, RTTY), ARRL DX, IARU HF, Sweepstakes, RTTY Roundup, NAQP, general logging, and user-defined `.udc` contests |
| Log | N1MM `.s3db`, Cabrillo 3.0 out, ADIF in and out |
| While typing | dupe check, multiplier check, points, country and zone from the country file, exchange filled from a call history file |
-| Windows | entry, log, check, bandmap, available mults and Qs, score summary, packet |
+| Windows | entry, log, check, bandmap, available mults and Qs, score summary, telnet |
| Editing | double-click a cell in the log, or open the whole contact with Ctrl+Y; Delete removes it. All of it goes out to the other stations |
| Radio | one or two radios over hamlib `rigctld`, split, reconnecting on its own |
-| Cluster | DX cluster over telnet, spots feeding the bandmap, Alt+P to spot a station |
+| Cluster | DX cluster over telnet: a telnet window with the node's traffic, the published list of nodes, command buttons and spot filters, spots feeding the bandmap, Alt+P to spot a station |
| Bandmap | drawn like N1MM's: a frequency scale with the receiver on it and callsigns beside it, joined by leader lines |
| Network | contacts shared with the other stations of a multi-operator entry, in N1MM's own contact message |
| Keying | CW through `cwdaemon` or a WinKeyer, with N1MM's message macros |
@@ -92,9 +92,30 @@ mode stay where they were last typed.
**Config → Keyer and messages** picks `cwdaemon` (a UDP port, usually 6789) or a
WinKeyer (a serial port), sets the speed, and edits the twelve function key
-messages for CW and for phone. The macros are N1MM's: `{MYCALL}`, `{CALL}`,
-`{EXCH}`, `{SENTRST}`, `{SENTNR}`, `#` for the serial number, and `{SENTRSTCUT}`
-for cut numbers. Escape stops sending.
+messages for CW and for phone. Escape stops sending.
+
+The macros are N1MM's, spelled the way N1MM's function-key documentation spells
+them, so a `.mc` file written for either program says the same thing. The text
+macros that are filled in:
+
+| | |
+|---|---|
+| `*`, `{MYCALL}` | your callsign |
+| `!`, `{CALL}` | the call being worked, or the last one logged when the box is empty |
+| `#` | the serial number for this contact, or the last one when the box is empty |
+| `{LASTCALL}`, `{PREVNR}` | the call and the serial of the contact just logged |
+| `{EXCH}` | the sent exchange from the contest setup |
+| `{SENTRST}`, `{SENTRSTCUT}` | the report you send, plain or in cut numbers |
+| `{NAME}`, `{NAMEANDSPACE}`, `{CHNAME}` | the other operator's name, from the entry window or the call history file |
+| `{GRID}` / `{GRIDSQUARE}` | your grid / theirs |
+| `{GRIDBEARING}`, `{REVGRIDBEARING}`, `{KMGRIDDISTANCE}` | bearing and distance between the two |
+| `{FREQ}`, `{FREQROUND}` | this radio's frequency in kilohertz, with `R` for the decimal point on CW |
+| `{OTHERFREQ}`, `{OTHERFREQROUND}`, `{OTHERMHZ}`, `{OTHERBAND}`, `{LRMHZ}`, `{RRMHZ}` | the other radio, for passing a station |
+| `{TIMESTAMP}`, `{TIME2}` | the time now |
+
+N1MM's action macros — `{WIPE}`, `{LOG}`, `{RUN}`, the CAT and SO2R families —
+are not run; they stand for nothing, so a message that holds one still sends the
+right characters.
### Editing the log
@@ -318,9 +339,57 @@ always 0, so 2190M and 630M are told apart by their edges alone.
### The DX cluster
-**Config → Cluster** takes the node's address, a password for the few nodes that
-ask for one, and the commands to send after login. Filters are the node's
-business, so whatever goes in the command box is sent as typed and left alone.
+**View → Telnet**, or **Config → Cluster**, opens the telnet window. It has four
+tabs, the ones N1MM has:
+
+- **Telnet** shows the node's traffic as it arrived, spot lines in green and
+ what went out in blue. Type a command at the bottom, or press one of the
+ buttons; up and down walk back through what has been typed. Double clicking a
+ spot line, or "Jump to this spot" on the right-click menu, puts the radio
+ there with the call in the entry window. Scrolling stops while the pointer is
+ over the traffic, so a line can be read while the node keeps sending.
+- **Clusters** keeps a list of nodes with their names, ports, passwords and the
+ commands to send after login. Connect and Disconnect are there, with automatic
+ logon, the call to log on with, and the keep-alive interval. **Download** under
+ it fetches the published list of nodes from
+ [NG3K](https://www.ng3k.com/misc/cluster.html) — around fifty nodes with the
+ sysop's call, the address 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; this reads a public page instead.
+- **Filters** says which spots reach the bandmap: bands, modes, beacons, busted
+ calls, stations outside the call history file, blacklisted spotters and calls,
+ whether to take spots only from your country, your continent or a list of
+ prefixes, and how long a spot stays on the bandmap. It also holds the preferred
+ spotters and the switch that randomises incoming CW spot frequencies. Nothing
+ is filtered out of the traffic itself; the operator sees everything the node
+ sends.
+- **Buttons** edits the twelve command buttons: a label and its commands, or
+ back to the defaults.
+- **Spot comment** is what goes out with the spots you send.
+
+The buttons come from the settings; without any, they are `sh/dx`, `sh/dx/20`,
+`sh/wwv`, `sh/users`, `sh/c/n`, `help` and `bye`. A button holds what N1MM's
+buttons hold: several commands separated by semicolons, the function-key message
+macros — `sh/dx {MYCALL}` sends the station callsign — or `{CONN}` and the name
+of a favourite, which connects to that node instead of sending anything. The
+label takes the macros too, expanded once when the buttons load, so a label of
+`{MYCALL}` reads as the callsign. Right-clicking a button opens the editor, and
+its tooltip shows the command behind it. What is typed on the command line goes
+out as typed, again as N1MM sends it. The one place this differs from N1MM: the
+commands of one button go out together rather than a quarter of a second apart.
+
+Three of the filters are worth spelling out:
+
+- **Busted spots.** A spotted call the callsign database has never heard, but
+ which is one character away from a call it does know, is a miscopy and is
+ dropped. A call nothing resembles is kept, because that is what a new station
+ looks like. Without `MASTER.SCP` nothing is called busted.
+- **Preferred spotters.** Spot lines from those spotters are written in bold, so
+ the spotters worth believing stand out. They are matched on the start of the
+ call, so `W3LPL` also covers `W3LPL-#`. Nothing is filtered by this.
+- **Randomised frequencies.** Each incoming CW spot is moved thirty or sixty
+ hertz either way, which is what N1MM does, so the operator has to find the
+ station by ear. Phone and digital spots are left alone.
The client speaks telnet properly: it answers the option negotiation instead of
letting the control bytes turn up in the first lines of text, and it reads the
@@ -328,12 +397,17 @@ login prompt out of a partial line, because nodes write `login: ` with no line
ending. If no prompt arrives within ten seconds the callsign goes out anyway,
which is what N1MM does. A connection that has heard nothing for four minutes
gets a blank line, so the node does not drop it as idle. A dropped connection is
-retried.
+retried. The last two hundred lines are kept, so a window opened mid-contest is
+not blank.
Alt+P, or **Edit → Spot It**, puts the call being typed on the cluster at the
-current frequency; with nothing typed it spots the last contact logged. The spot
-also goes straight onto our own bandmap rather than waiting to come back round
-from the node.
+current frequency, with the comment from the Spot comment tab — which takes the
+message macros too; with nothing
+typed it spots the last contact logged. The spot also goes straight onto our own
+bandmap rather than waiting to come back round from the node.
+
+Filters are applied to the spots, not to the node: it is still worth filtering
+at the node as well, because that is less traffic on the wire.
### Networked stations
@@ -371,7 +445,7 @@ covered by plain unit tests.
Working: logging a contest end to end, live dupe and multiplier checking, eight
built-in contests plus user-defined ones, Cabrillo and ADIF export, ADIF import,
-the log, check, graphical bandmap, score and packet windows, editing and deleting logged
+the log, check, graphical bandmap, score and telnet windows, editing and deleting logged
contacts, radio control, DX cluster spots, contacts shared between networked
stations, and CW keying.
diff --git a/docs/unfinished.md b/docs/unfinished.md
index e0ae10b..f875d95 100644
--- a/docs/unfinished.md
+++ b/docs/unfinished.md
@@ -59,6 +59,43 @@ and is listed as a US call now, and China was not split by call area yet. The
counts a callsign it cannot place — `D1M` — as a multiplier with an empty
value, and we do not.
+**Telnet window: what N1MM has and this does not.** Left out: the special-calls
+list, and saving received spots to a database — N1MM keeps those spots in its
+admin database, which is not the log file the two programs share, so there is
+nothing to be compatible with and nothing asks to read them back. The band plan
+tab is not repeated either, because Config ▸ Sub bands already edits the same
+numbers.
+
+The node list is downloaded from the public NG3K page rather than from N1MM's
+own web service, which asks the operator to opt in to data collection and is
+N1MM's to run. A page that changes shape would stop the download working; the
+reading only needs `telnet://` links in a table, and the old list stays in place
+when a download brings back something unreadable.
+
+**The action macros do nothing.** The text macros are N1MM's, spelled as its
+function-key documentation spells them, and they are listed in the README. What
+is missing is the other half of N1MM's table: the macros that run a program
+command rather than standing for text — `{WIPE}`, `{LOG}`, `{ENTER}`, `{RUN}`,
+`{S&P}`, `{SPOTME}`, `{TOGGLE}`, `{END}`, the `{CAT…}` and `{OTRSP…}` families,
+the audio, rotator and call-stacking macros. They expand to nothing, so a
+message that holds one sends the right characters and takes no action.
+
+They cannot be added to `MessageExpander` as it stands: it turns a template into
+a string, and an action macro has to reach the entry window, the keyer or the
+SO2R box, some of it after the message has been sent (that is what N1MM's
+`{END}` is for). That wants a different shape — a list of steps rather than a
+string.
+
+Two more text macros are out for want of the data behind them: `{LASTEXCH}`,
+which N1MM only fills for ROPOCO and LZ Open, and `{OPERATOR}`, because there is
+no operator separate from the station callsign here. `@`, which voices the
+receive frequency from recorded letter files, waits on voice keying.
+
+**The busted-spot check is one character wide.** N1MM offers two. Every call one
+character away from the spotted one is looked up in the callsign database, which
+is a few hundred lookups per spot; two characters away is a few hundred thousand,
+which is too much work per spot for the little it would add.
+
**Digital modes.** A contact can be logged as RTTY or another digital mode, and
the contest rules score it, but there is no digital window: no decoding, no
transmitting, no interface to fldigi, MMTTY or similar.
diff --git a/src/Nonemm.App/AppSession.cs b/src/Nonemm.App/AppSession.cs
index 6322344..8191331 100644
--- a/src/Nonemm.App/AppSession.cs
+++ b/src/Nonemm.App/AppSession.cs
@@ -27,6 +27,12 @@ public sealed class AppSession : IDisposable
private AlternatingCq? alternating;
private So2rBox? box;
+ /// Which cluster spots reach the bandmap. Rebuilt whenever the settings or
+ /// the support files change.
+ private SpotFilter spotFilter = new();
+
+ private readonly Random jitter = new();
+
public AppSession(UserPaths paths, Settings settings)
{
Paths = paths;
@@ -38,6 +44,7 @@ public sealed class AppSession : IDisposable
Registry = ContestRegistry.FromFolder(paths.UserDefinedContests, out IReadOnlyList problems);
UserDefinedContestProblems = problems;
BandPlan = settings.ToBandPlan();
+ ApplySpotSettings();
}
public UserPaths Paths { get; }
@@ -64,6 +71,11 @@ public sealed class AppSession : IDisposable
/// The contest in progress: one log and one score, however many radios.
public ContestSession? Logging { get; private set; }
+ /// The radio the operator is not on, or null at a one-radio station. The
+ /// message macros that pass a station to the other band need it.
+ public RadioPosition? Other(RadioPosition position) =>
+ positions.FirstOrDefault(p => p.RadioNumber != position.RadioNumber);
+
/// One per radio, in radio-number order. There is always at least one, so
/// the program works with no radio connected.
public IReadOnlyList Positions => positions;
@@ -111,6 +123,7 @@ public sealed class AppSession : IDisposable
{
Settings = settings;
BandPlan = settings.ToBandPlan();
+ ApplySpotSettings();
settings.Save(Paths.SettingsFile);
Changed?.Invoke(this, EventArgs.Empty);
}
@@ -358,7 +371,7 @@ public sealed class AppSession : IDisposable
}
MoveToRadio(radioNumber);
await PointTransmitAtAsync(radioNumber).ConfigureAwait(false);
- await keyer.SendAsync(MessageExpander.Expand(template, position)).ConfigureAwait(false);
+ await keyer.SendAsync(MessageExpander.Expand(template, position, Other(position))).ConfigureAwait(false);
}
/// Puts both radios in the headphones, or goes back to one. An operator
@@ -433,10 +446,12 @@ public sealed class AppSession : IDisposable
cluster = new ClusterClient(
Settings.ClusterHost,
Settings.ClusterPort,
- Settings.Station.Callsign,
+ Settings.ClusterLogonCall.Length > 0 ? Settings.ClusterLogonCall : Settings.Station.Callsign,
Settings.ClusterCommands,
- Settings.ClusterPassword);
- cluster.SpotArrived += (_, spot) => Bandmap.Add(spot);
+ Settings.ClusterPassword,
+ autoLogon: Settings.ClusterAutoLogon,
+ keepAliveInterval: TimeSpan.FromMinutes(Math.Max(1, Settings.ClusterKeepAliveMinutes)));
+ cluster.SpotArrived += (_, spot) => TakeSpot(spot);
cluster.ConnectionChanged += (_, _) => Changed?.Invoke(this, EventArgs.Empty);
cluster.Start();
}
@@ -448,12 +463,30 @@ public sealed class AppSession : IDisposable
Changed?.Invoke(this, EventArgs.Empty);
}
+ /// A spot the node sent, kept or dropped by the filters on the telnet
+ /// window.
+ private void TakeSpot(Spot spot)
+ {
+ if (!spotFilter.Accepts(spot))
+ {
+ return;
+ }
+ Bandmap.Add(Settings.RandomizeSpots ? SpotJitter.Shifted(spot, BandPlan, jitter) : spot);
+ }
+
+ private void ApplySpotSettings()
+ {
+ spotFilter = Settings.SpotFilter.ToFilter(BandPlan, Settings.Station, Countries, Calls, History);
+ Bandmap.Lifetime = TimeSpan.FromMinutes(Math.Max(1, Settings.SpotTimeoutMinutes));
+ }
+
public void ReloadSupportFiles()
{
Countries = LoadCountryFile(Paths.CountryFile);
Calls = LoadCallDatabase(Paths.CallDatabaseFile);
History = LoadCallHistory(Settings.CallHistoryFile);
Registry = ContestRegistry.FromFolder(Paths.UserDefinedContests, out _);
+ ApplySpotSettings();
if (Logging is not null)
{
OpenContest(Logging.Instance.ContestNumber);
diff --git a/src/Nonemm.App/Configuration/Settings.cs b/src/Nonemm.App/Configuration/Settings.cs
index 9be36ff..7e8ec83 100644
--- a/src/Nonemm.App/Configuration/Settings.cs
+++ b/src/Nonemm.App/Configuration/Settings.cs
@@ -1,3 +1,4 @@
+using System.Reflection;
using System.Text.Json;
using Nonemm.Core;
@@ -21,6 +22,37 @@ public sealed record Settings
public IReadOnlyList 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 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 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 Radios { get; init; } = [];
@@ -103,8 +135,9 @@ public sealed record Settings
}
try
{
- return Migrated(
- JsonSerializer.Deserialize(File.ReadAllText(path), Json) ?? new Settings());
+ Settings read = JsonSerializer.Deserialize(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
diff --git a/src/Nonemm.App/Configuration/StoredClusterNode.cs b/src/Nonemm.App/Configuration/StoredClusterNode.cs
new file mode 100644
index 0000000..39b6a4c
--- /dev/null
+++ b/src/Nonemm.App/Configuration/StoredClusterNode.cs
@@ -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 Commands { get; init; } = [];
+
+ /// How the node reads in the favourites list.
+ [JsonIgnore]
+ public string Label => Name.Length > 0 ? $"{Name} — {Host}:{Port}" : $"{Host}:{Port}";
+}
diff --git a/src/Nonemm.App/Configuration/StoredSpotFilter.cs b/src/Nonemm.App/Configuration/StoredSpotFilter.cs
new file mode 100644
index 0000000..a3c2c52
--- /dev/null
+++ b/src/Nonemm.App/Configuration/StoredSpotFilter.cs
@@ -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 Bands { get; init; } = [];
+
+ /// `CW`, `PHONE` or `DIGITAL`. Empty means every mode.
+ public IReadOnlyList Modes { get; init; } = [];
+
+ public bool MyCountryOnly { get; init; }
+
+ public bool MyContinentOnly { get; init; }
+
+ public IReadOnlyList CallAreas { get; init; } = [];
+
+ public IReadOnlyList BlockedSpotters { get; init; } = [];
+
+ public IReadOnlyList 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 PreferredSpotters { get; init; } = [];
+
+ public SpotFilter ToFilter(
+ BandPlan plan,
+ StoredStation station,
+ CountryFile? countries,
+ CallDatabase? calls,
+ CallHistory? history) => new()
+ {
+ Bands = [.. Bands.Select(Core.Bands.Named).OfType()],
+ Modes = [.. Modes.Select(ModeOf).OfType()],
+ 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,
+ };
+}
diff --git a/src/Nonemm.App/Configuration/StoredTelnetButton.cs b/src/Nonemm.App/Configuration/StoredTelnetButton.cs
new file mode 100644
index 0000000..141cf68
--- /dev/null
+++ b/src/Nonemm.App/Configuration/StoredTelnetButton.cs
@@ -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 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; } = "";
+}
diff --git a/src/Nonemm.App/Configuration/SupportFileDownloader.cs b/src/Nonemm.App/Configuration/SupportFileDownloader.cs
index 16697ee..62a3771 100644
--- a/src/Nonemm.App/Configuration/SupportFileDownloader.cs
+++ b/src/Nonemm.App/Configuration/SupportFileDownloader.cs
@@ -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 DownloadCallDatabaseAsync(string path, CancellationToken cancellation = default) =>
DownloadAsync(CallDatabaseUrl, path, text => CallDatabase.Parse(text).Count, "callsigns", cancellation);
+ public Task DownloadClusterListAsync(string path, CancellationToken cancellation = default) =>
+ DownloadAsync(ClusterListUrl, path, text => ClusterList.Parse(text).Count, "cluster nodes", cancellation);
+
private async Task DownloadAsync(
string url,
string path,
diff --git a/src/Nonemm.App/Configuration/UserPaths.cs b/src/Nonemm.App/Configuration/UserPaths.cs
index a388ade..fb8a493 100644
--- a/src/Nonemm.App/Configuration/UserPaths.cs
+++ b/src/Nonemm.App/Configuration/UserPaths.cs
@@ -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);
diff --git a/src/Nonemm.App/Dialogs/ClusterDialog.axaml b/src/Nonemm.App/Dialogs/ClusterDialog.axaml
deleted file mode 100644
index 0a725ac..0000000
--- a/src/Nonemm.App/Dialogs/ClusterDialog.axaml
+++ /dev/null
@@ -1,23 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/Nonemm.App/Dialogs/ClusterDialog.axaml.cs b/src/Nonemm.App/Dialogs/ClusterDialog.axaml.cs
deleted file mode 100644
index d751f0d..0000000
--- a/src/Nonemm.App/Dialogs/ClusterDialog.axaml.cs
+++ /dev/null
@@ -1,39 +0,0 @@
-using Avalonia.Controls;
-using Avalonia.Interactivity;
-using Nonemm.App.Configuration;
-
-namespace Nonemm.App.Dialogs;
-
-/// The cluster node to connect to. Filters are the node's business, so whatever
-/// the operator puts in the command box is sent after login and left alone.
-public sealed partial class ClusterDialog : Window
-{
- private readonly Settings settings;
-
- public ClusterDialog(Settings settings)
- {
- this.settings = settings;
- InitializeComponent();
- HostBox.Text = settings.ClusterHost;
- PortBox.Text = settings.ClusterPort.ToString();
- PasswordBox.Text = settings.ClusterPassword;
- CommandsBox.Text = string.Join("\n", settings.ClusterCommands);
- EnabledBox.IsChecked = settings.ClusterEnabled;
- }
-
-
- private void OnSave(object? sender, RoutedEventArgs e) => Close(settings with
- {
- ClusterHost = (HostBox.Text ?? "").Trim(),
- ClusterPort = int.TryParse(PortBox.Text, out int port) ? port : 7373,
- ClusterPassword = PasswordBox.Text ?? "",
- ClusterCommands = (CommandsBox.Text ?? "")
- .Split('\n', StringSplitOptions.RemoveEmptyEntries)
- .Select(l => l.Trim())
- .Where(l => l.Length > 0)
- .ToList(),
- ClusterEnabled = EnabledBox.IsChecked == true,
- });
-
- private void OnCancel(object? sender, RoutedEventArgs e) => Close(null);
-}
diff --git a/src/Nonemm.App/Windows/EntryWindow.Menu.cs b/src/Nonemm.App/Windows/EntryWindow.Menu.cs
index aeeffca..b0c7d81 100644
--- a/src/Nonemm.App/Windows/EntryWindow.Menu.cs
+++ b/src/Nonemm.App/Windows/EntryWindow.Menu.cs
@@ -6,6 +6,7 @@ using Nonemm.App.Dialogs;
using Nonemm.Core;
using Nonemm.Formats.Adif;
using Nonemm.Formats.Cabrillo;
+using Nonemm.Session;
using Nonemm.Spotting;
using Nonemm.Storage;
@@ -219,7 +220,7 @@ public sealed partial class EntryWindow
Status("nothing to spot");
return;
}
- await cluster.SendSpotAsync(where, call);
+ await cluster.SendSpotAsync(where, call, MessageExpander.Expand(session.Settings.SpotComment, Logging, session.Other(Logging)));
session.Bandmap.Add(new Spot(call, where, DateTime.UtcNow, SpotSource.Operator));
Status($"{call.Text} spotted on {where.Kilohertz:0.0}");
}
@@ -235,7 +236,7 @@ public sealed partial class EntryWindow
private void OnShowScore(object? sender, RoutedEventArgs e) => Show(() => new ScoreWindow(session));
- private void OnShowPacket(object? sender, RoutedEventArgs e) => Show(() => new PacketWindow(session));
+ private void OnShowTelnet(object? sender, RoutedEventArgs e) => Show(() => new TelnetWindow(session, Tune));
private async void OnStationSettings(object? sender, RoutedEventArgs e)
{
@@ -278,26 +279,10 @@ public sealed partial class EntryWindow
});
}
- private async void OnClusterSettings(object? sender, RoutedEventArgs e)
- {
- ClusterDialog dialog = new(session.Settings);
- Settings? updated = await dialog.ShowDialog(this);
- if (updated is null)
- {
- return;
- }
- session.Save(updated);
- if (updated.ClusterEnabled)
- {
- session.ConnectCluster();
- Status($"cluster: {updated.ClusterHost}:{updated.ClusterPort}");
- }
- else
- {
- session.DisconnectCluster();
- Status("cluster disconnected");
- }
- }
+ /// N1MM keeps the cluster settings on the telnet window rather than in a
+ /// dialog of their own, and so do we.
+ private void OnClusterSettings(object? sender, RoutedEventArgs e) =>
+ Show(() => new TelnetWindow(session, Tune)).ShowClusters();
private async void OnNetworkSettings(object? sender, RoutedEventArgs e)
{
@@ -427,17 +412,19 @@ public sealed partial class EntryWindow
return false;
}
- private void Show(Func create) where T : Window
+ /// The one window of that kind, opened if it was not open already.
+ private T Show(Func create) where T : Window
{
if (openWindows.TryGetValue(typeof(T), out Window? existing))
{
existing.Activate();
- return;
+ return (T)existing;
}
T window = create();
openWindows[typeof(T)] = window;
window.Closed += (_, _) => openWindows.Remove(typeof(T));
window.Show(this);
+ return window;
}
private async Task Folder(string path) =>
diff --git a/src/Nonemm.App/Windows/EntryWindow.axaml b/src/Nonemm.App/Windows/EntryWindow.axaml
index 7fd873e..1111499 100644
--- a/src/Nonemm.App/Windows/EntryWindow.axaml
+++ b/src/Nonemm.App/Windows/EntryWindow.axaml
@@ -49,7 +49,7 @@
-
+