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:
2026-08-28 07:33:55 +00:00
parent 648da1918a
commit d9f9d880f2
40 changed files with 2278 additions and 272 deletions

View File

@@ -21,5 +21,6 @@
<Project Path="tests/Nonemm.Keying.Tests/Nonemm.Keying.Tests.csproj" />
<Project Path="tests/Nonemm.Rig.Tests/Nonemm.Rig.Tests.csproj" />
<Project Path="tests/Nonemm.Network.Tests/Nonemm.Network.Tests.csproj" />
<Project Path="tests/Nonemm.App.Tests/Nonemm.App.Tests.csproj" />
</Folder>
</Solution>

102
README.md
View File

@@ -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.

View File

@@ -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.

View File

@@ -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<string> 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<RadioPosition> 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);

View File

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

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

View 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,
};
}

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

View File

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

View File

@@ -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);

View File

@@ -1,23 +0,0 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Nonemm.App.Dialogs.ClusterDialog"
Title="DX cluster" Width="440" SizeToContent="Height"
WindowStartupLocation="CenterOwner" CanResize="False">
<StackPanel Margin="14" Spacing="6">
<Grid ColumnDefinitions="*,8,110" RowDefinitions="Auto,Auto">
<TextBlock Text="Node" FontSize="11" Opacity="0.7" Margin="0,0,0,1" />
<TextBox Name="HostBox" Grid.Row="1" PlaceholderText="dxc.example.net" />
<TextBlock Grid.Column="2" Text="Port" FontSize="11" Opacity="0.7" Margin="0,0,0,1" />
<TextBox Name="PortBox" Grid.Row="1" Grid.Column="2" />
</Grid>
<TextBlock Text="Password, if the node asks for one" FontSize="11" Opacity="0.7" Margin="0,6,0,1" />
<TextBox Name="PasswordBox" PasswordChar="•" />
<TextBlock Text="Commands sent after login, one per line" FontSize="11" Opacity="0.7" Margin="0,6,0,1" />
<TextBox Name="CommandsBox" AcceptsReturn="True" Height="90" />
<CheckBox Name="EnabledBox" Content="Connect" Margin="0,6,0,0" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="6" Margin="0,8,0,0">
<Button Content="Cancel" Click="OnCancel" />
<Button Content="Save" Click="OnSave" IsDefault="True" />
</StackPanel>
</StackPanel>
</Window>

View File

@@ -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);
}

View File

@@ -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<Settings?>(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<T>(Func<T> create) where T : Window
/// The one window of that kind, opened if it was not open already.
private T Show<T>(Func<T> 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<IStorageFolder?> Folder(string path) =>

View File

@@ -49,7 +49,7 @@
<MenuItem Header="_Bandmap" Click="OnShowBandmap" />
<MenuItem Header="_Available Mults and Qs" Click="OnShowAvailable" />
<MenuItem Header="_Score Summary" Click="OnShowScore" />
<MenuItem Header="_Packet" Click="OnShowPacket" />
<MenuItem Header="_Telnet" Click="OnShowTelnet" />
</MenuItem>
<MenuItem Header="_Config">
<MenuItem Header="_Station…" Click="OnStationSettings" />

View File

@@ -559,7 +559,7 @@ public sealed partial class EntryWindow : Window
{
return;
}
string text = MessageExpander.Expand(template, Logging);
string text = MessageExpander.Expand(template, Logging, session.Other(Logging));
// the box has to point at this radio before the key does
_ = session.PointTransmitAtAsync(radioNumber);
Status($"sending {text}");

View File

@@ -1,17 +0,0 @@
<local:RefreshableWindow xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:Nonemm.App.Windows"
x:Class="Nonemm.App.Windows.PacketWindow"
Title="Packet" Width="640" Height="420">
<DockPanel>
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Margin="6" Spacing="6">
<TextBox Name="CommandBox" Width="520" Watermark="command to the node" />
<Button Content="Send" Click="OnSend" IsDefault="True" />
</StackPanel>
<TextBlock DockPanel.Dock="Top" Name="StateText" Margin="8,6,8,2" FontSize="11" Opacity="0.7" />
<ScrollViewer Name="Scroller">
<TextBlock Name="Traffic" FontFamily="monospace" FontSize="12" Margin="8,0"
TextWrapping="NoWrap" />
</ScrollViewer>
</DockPanel>
</local:RefreshableWindow>

View File

@@ -1,61 +0,0 @@
using Avalonia.Interactivity;
using Avalonia.Threading;
namespace Nonemm.App.Windows;
/// The cluster node's traffic as it arrived, and a line to send commands on.
public sealed partial class PacketWindow : RefreshableWindow
{
private const int LinesKept = 500;
private readonly AppSession session;
private readonly Queue<string> lines = new();
public PacketWindow(AppSession session)
{
this.session = session;
InitializeComponent();
if (session.Cluster is not null)
{
session.Cluster.LineArrived += OnLine;
}
Closed += (_, _) =>
{
if (session.Cluster is not null)
{
session.Cluster.LineArrived -= OnLine;
}
};
Refresh();
}
public override void Refresh() =>
StateText.Text = session.Cluster is null
? "no cluster node — Config ▸ Cluster"
: session.Cluster.IsConnected
? $"connected to {session.Settings.ClusterHost}:{session.Settings.ClusterPort}"
: "connecting…";
private void OnLine(object? sender, string line) => Dispatcher.UIThread.Post(() =>
{
lines.Enqueue(line);
while (lines.Count > LinesKept)
{
lines.Dequeue();
}
Traffic.Text = string.Join('\n', lines);
Scroller.ScrollToEnd();
Refresh();
});
private async void OnSend(object? sender, RoutedEventArgs e)
{
if (session.Cluster is null || CommandBox.Text is not { Length: > 0 } command)
{
return;
}
await session.Cluster.SendAsync(command);
CommandBox.Text = "";
}
}

View File

@@ -0,0 +1,68 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Nonemm.App.Configuration;
namespace Nonemm.App.Windows;
/// The Buttons tab: the twelve command buttons under the traffic, as N1MM's
/// telnet window has.
public sealed partial class TelnetWindow
{
private const int ButtonCount = 12;
private readonly List<(TextBox Label, TextBox Command)> buttonRows = [];
private void LoadButtonRows()
{
buttonRows.Clear();
ButtonRows.Children.Clear();
ButtonRows.RowDefinitions.Clear();
IReadOnlyList<StoredTelnetButton> stored = Buttons();
for (int at = 0; at < ButtonCount; at++)
{
ButtonRows.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
StoredTelnetButton button = at < stored.Count ? stored[at] : new StoredTelnetButton();
TextBox label = Box(at, 0, button.Label, "label");
TextBox command = Box(at, 2, button.Command, "sh/dx; sh/wwv");
buttonRows.Add((label, command));
}
}
private TextBox Box(int row, int column, string text, string placeholder)
{
TextBox box = new()
{
Text = text,
PlaceholderText = placeholder,
Margin = new Avalonia.Thickness(0, 0, 0, 4),
};
Grid.SetRow(box, row);
Grid.SetColumn(box, column);
ButtonRows.Children.Add(box);
return box;
}
private void OnSaveButtons(object? sender, RoutedEventArgs e)
{
session.Save(session.Settings with
{
TelnetButtons = [.. buttonRows
.Select(row => new StoredTelnetButton
{
Label = (row.Label.Text ?? "").Trim(),
Command = (row.Command.Text ?? "").Trim(),
})
.Where(button => button.Label.Length > 0 && button.Command.Length > 0)],
});
LoadButtons();
Show("*** buttons saved", NoticeColour);
}
private void OnResetButtons(object? sender, RoutedEventArgs e)
{
session.Save(session.Settings with { TelnetButtons = [] });
LoadButtons();
LoadButtonRows();
Show("*** buttons back to the defaults", NoticeColour);
}
}

View File

@@ -0,0 +1,232 @@
using System.Globalization;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Nonemm.App.Configuration;
using Nonemm.Spotting;
namespace Nonemm.App.Windows;
/// The Clusters tab: the nodes the operator keeps, the one being talked to, and
/// how to log on to it.
public sealed partial class TelnetWindow
{
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(30) };
private readonly List<StoredClusterNode> nodes = [];
private readonly List<ClusterNode> published = [];
private void LoadClusters()
{
nodes.Clear();
nodes.AddRange(session.Settings.ClusterNodes);
NodeList.ItemsSource = nodes.Select(n => n.Label).ToList();
NodeList.SelectedIndex = nodes.FindIndex(Matches);
Settings settings = session.Settings;
HostBox.Text = settings.ClusterHost;
PortBox.Text = settings.ClusterPort.ToString(CultureInfo.InvariantCulture);
PasswordBox.Text = settings.ClusterPassword;
CommandsBox.Text = string.Join("\n", settings.ClusterCommands);
LoadPublishedNodes();
AutoLogonBox.IsChecked = settings.ClusterAutoLogon;
LogonCallBox.Text = settings.ClusterLogonCall;
KeepAliveBox.Text = settings.ClusterKeepAliveMinutes.ToString(CultureInfo.InvariantCulture);
}
/// True for the node the settings are pointing at, so the list opens on it.
private bool Matches(StoredClusterNode node) =>
string.Equals(node.Host, session.Settings.ClusterHost, StringComparison.OrdinalIgnoreCase)
&& node.Port == session.Settings.ClusterPort;
private void OnNodePicked(object? sender, SelectionChangedEventArgs e)
{
if (NodeList.SelectedIndex < 0 || NodeList.SelectedIndex >= nodes.Count)
{
return;
}
StoredClusterNode node = nodes[NodeList.SelectedIndex];
NameBox.Text = node.Name;
HostBox.Text = node.Host;
PortBox.Text = node.Port.ToString(CultureInfo.InvariantCulture);
PasswordBox.Text = node.Password;
CommandsBox.Text = string.Join("\n", node.Commands);
}
/// Adds what is in the boxes to the list, or replaces the entry that has
/// the same host and port.
private void OnStoreNode(object? sender, RoutedEventArgs e)
{
StoredClusterNode node = new()
{
Name = (NameBox.Text ?? "").Trim(),
Host = Host(),
Port = Port(),
Password = PasswordBox.Text ?? "",
Commands = Lines(CommandsBox.Text),
};
if (node.Host.Length == 0)
{
return;
}
int at = nodes.FindIndex(n =>
string.Equals(n.Host, node.Host, StringComparison.OrdinalIgnoreCase) && n.Port == node.Port);
if (at >= 0)
{
nodes[at] = node;
}
else
{
nodes.Add(node);
}
session.Save(session.Settings with { ClusterNodes = [.. nodes] });
LoadClusters();
}
private void OnRemoveNode(object? sender, RoutedEventArgs e)
{
if (NodeList.SelectedIndex < 0 || NodeList.SelectedIndex >= nodes.Count)
{
return;
}
nodes.RemoveAt(NodeList.SelectedIndex);
session.Save(session.Settings with { ClusterNodes = [.. nodes] });
LoadClusters();
}
/// The list downloaded from the published page, if one has been. Its
/// entries are not favourites until the operator saves one.
private void LoadPublishedNodes()
{
published.Clear();
if (File.Exists(session.Paths.ClusterListFile))
{
try
{
published.AddRange(ClusterList.Parse(File.ReadAllText(session.Paths.ClusterListFile)));
}
catch (Exception e) when (e is FormatException or IOException)
{
Show($"*** the stored node list could not be read: {e.Message}", NoticeColour);
}
}
PublishedNodeList.ItemsSource = published
.Select(n => n.Description.Length > 0
? $"{n.Name} — {n.Address} · {n.Description}"
: $"{n.Name} — {n.Address}")
.ToList();
PublishedText.Text = published.Count > 0
? $"Published list · {published.Count} nodes"
: "Published list — nothing downloaded yet";
}
private void OnPublishedNodePicked(object? sender, SelectionChangedEventArgs e)
{
if (PublishedNodeList.SelectedIndex < 0 || PublishedNodeList.SelectedIndex >= published.Count)
{
return;
}
ClusterNode node = published[PublishedNodeList.SelectedIndex];
NameBox.Text = node.Name;
HostBox.Text = node.Host;
PortBox.Text = node.Port.ToString(CultureInfo.InvariantCulture);
PasswordBox.Text = "";
CommandsBox.Text = "";
}
private async void OnDownloadNodes(object? sender, RoutedEventArgs e)
{
PublishedText.Text = "fetching the node list…";
try
{
string result = await new SupportFileDownloader(Http)
.DownloadClusterListAsync(session.Paths.ClusterListFile);
Show($"*** node list: {result} from {SupportFileDownloader.ClusterListUrl}", NoticeColour);
}
catch (Exception error) when (error is InvalidOperationException or IOException)
{
Show($"*** {error.Message}", NoticeColour);
}
LoadPublishedNodes();
}
/// `{CONN}name` connects to the favourite of that name rather than sending
/// anything, as it does on N1MM's telnet buttons. Anything after the
/// semicolon is dropped, which is what N1MM does with it too.
private bool ConnectToFavourite(string command)
{
const string marker = "{CONN}";
if (!command.StartsWith(marker, StringComparison.OrdinalIgnoreCase))
{
return false;
}
string name = command[marker.Length..].Split(';')[0].Trim();
int at = nodes.FindIndex(node => string.Equals(node.Name, name, StringComparison.OrdinalIgnoreCase));
if (at < 0)
{
Show($"*** no favourite is called '{name}'", NoticeColour);
return true;
}
NodeList.SelectedIndex = at;
Connect();
return true;
}
private void OnConnect(object? sender, RoutedEventArgs e) => Connect();
private void OnDisconnect(object? sender, RoutedEventArgs e)
{
Forget(session.Cluster);
session.Save(session.Settings with { ClusterEnabled = false });
session.DisconnectCluster();
Show("*** disconnected", NoticeColour);
Refresh();
}
private void OnSaveOptions(object? sender, RoutedEventArgs e)
{
session.Save(session.Settings with
{
ClusterAutoLogon = AutoLogonBox.IsChecked == true,
ClusterLogonCall = (LogonCallBox.Text ?? "").Trim(),
ClusterKeepAliveMinutes = Minutes(KeepAliveBox.Text, session.Settings.ClusterKeepAliveMinutes),
});
Show("*** options saved — they apply on the next connection", NoticeColour);
}
/// Dials the node in the boxes and keeps it as the one to connect to on the
/// next run.
private void Connect()
{
if (Host().Length == 0)
{
Show("*** fill in a node on the Clusters tab", NoticeColour);
ShowClusters();
return;
}
Forget(session.Cluster);
session.Save(session.Settings with
{
ClusterHost = Host(),
ClusterPort = Port(),
ClusterPassword = PasswordBox.Text ?? "",
ClusterCommands = Lines(CommandsBox.Text),
ClusterEnabled = true,
});
Traffic.Items.Clear();
session.ConnectCluster();
Listen(session.Cluster);
Tabs.SelectedIndex = 0;
Refresh();
}
private string Host() => (HostBox.Text ?? "").Trim();
private int Port() =>
int.TryParse(PortBox.Text, out int port) && port > 0 ? port : session.Settings.ClusterPort;
private static int Minutes(string? text, int fallback) =>
int.TryParse(text, out int minutes) && minutes > 0 ? minutes : fallback;
private static IReadOnlyList<string> Lines(string? text) =>
[.. (text ?? "").Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)];
}

View File

@@ -0,0 +1,112 @@
using System.Globalization;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Nonemm.App.Configuration;
namespace Nonemm.App.Windows;
/// The Filters and Spot comment tabs: which spots reach the bandmap, how long
/// they stay there, and what goes out with a spot the operator sends.
public sealed partial class TelnetWindow
{
/// The bands worth a tick box. The rest of the microwave bands would fill
/// the tab and no cluster carries them.
private static readonly IReadOnlyList<string> FilterBands =
["160M", "80M", "60M", "40M", "30M", "20M", "17M", "15M", "12M", "10M", "6M", "4M", "2M", "70CM"];
private readonly List<CheckBox> bandBoxes = [];
private void LoadFilters()
{
StoredSpotFilter filter = session.Settings.SpotFilter;
bandBoxes.Clear();
BandBoxes.Children.Clear();
foreach (string band in FilterBands)
{
CheckBox box = new()
{
Content = band,
Margin = new Avalonia.Thickness(0, 0, 10, 0),
IsChecked = filter.Bands.Contains(band, StringComparer.OrdinalIgnoreCase),
};
bandBoxes.Add(box);
BandBoxes.Children.Add(box);
}
CwBox.IsChecked = HasMode(filter, "CW");
PhoneBox.IsChecked = HasMode(filter, "PHONE");
DigitalBox.IsChecked = HasMode(filter, "DIGITAL");
BeaconBox.IsChecked = filter.ShowBeacons;
BustedBox.IsChecked = filter.RemoveBustedSpots;
CallHistoryBox.IsChecked = filter.OnlyInCallHistory;
RandomizeBox.IsChecked = session.Settings.RandomizeSpots;
PreferredSpottersBox.Text = string.Join(' ', filter.PreferredSpotters);
MyCountryBox.IsChecked = filter.MyCountryOnly;
MyContinentBox.IsChecked = filter.MyContinentOnly;
MyCountryBox.Content = Named("my country", session.Settings.Station.CountryPrefix);
MyContinentBox.Content = Named("my continent", session.Settings.Station.Continent);
CallAreasBox.Text = string.Join(' ', filter.CallAreas);
BlockedSpottersBox.Text = string.Join(' ', filter.BlockedSpotters);
BlockedCallsBox.Text = string.Join(' ', filter.BlockedCalls);
TimeoutBox.Text = session.Settings.SpotTimeoutMinutes.ToString(CultureInfo.InvariantCulture);
SpotCommentBox.Text = session.Settings.SpotComment;
}
private void OnApplyFilters(object? sender, RoutedEventArgs e)
{
session.Save(session.Settings with
{
SpotFilter = new StoredSpotFilter
{
Bands = [.. bandBoxes.Where(b => b.IsChecked == true).Select(b => (string)b.Content!)],
Modes = Modes(),
MyCountryOnly = MyCountryBox.IsChecked == true,
MyContinentOnly = MyContinentBox.IsChecked == true,
CallAreas = Words(CallAreasBox.Text),
BlockedSpotters = Words(BlockedSpottersBox.Text),
BlockedCalls = Words(BlockedCallsBox.Text),
ShowBeacons = BeaconBox.IsChecked == true,
RemoveBustedSpots = BustedBox.IsChecked == true,
OnlyInCallHistory = CallHistoryBox.IsChecked == true,
PreferredSpotters = Words(PreferredSpottersBox.Text),
},
RandomizeSpots = RandomizeBox.IsChecked == true,
SpotTimeoutMinutes = Minutes(TimeoutBox.Text, session.Settings.SpotTimeoutMinutes),
});
Show("*** filters applied — they hold for the spots that arrive from now on", NoticeColour);
}
private void OnSaveSpotComment(object? sender, RoutedEventArgs e)
{
session.Save(session.Settings with { SpotComment = (SpotCommentBox.Text ?? "").Trim() });
Show("*** spot comment saved", NoticeColour);
}
private IReadOnlyList<string> Modes()
{
List<string> modes = [];
if (CwBox.IsChecked == true)
{
modes.Add("CW");
}
if (PhoneBox.IsChecked == true)
{
modes.Add("PHONE");
}
if (DigitalBox.IsChecked == true)
{
modes.Add("DIGITAL");
}
return modes;
}
private static bool HasMode(StoredSpotFilter filter, string mode) =>
filter.Modes.Contains(mode, StringComparer.OrdinalIgnoreCase);
private static string Named(string what, string value) =>
value.Length > 0 ? $"{what} ({value})" : what;
private static IReadOnlyList<string> Words(string? text) =>
[.. (text ?? "").Split([' ', ',', '\n', '\r', '\t'], StringSplitOptions.RemoveEmptyEntries)
.Select(word => word.ToUpperInvariant())];
}

View File

@@ -0,0 +1,148 @@
<local:RefreshableWindow xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:Nonemm.App.Windows"
x:Class="Nonemm.App.Windows.TelnetWindow"
Title="Telnet" Width="760" Height="520">
<Window.Styles>
<Style Selector="TextBlock.label">
<Setter Property="FontSize" Value="11" />
<Setter Property="Opacity" Value="0.7" />
<Setter Property="Margin" Value="0,6,0,1" />
</Style>
</Window.Styles>
<TabControl Name="Tabs">
<TabItem Header="Telnet">
<DockPanel>
<Grid DockPanel.Dock="Top" ColumnDefinitions="*,Auto,Auto" Margin="8,6,8,4">
<TextBlock Name="StateText" FontSize="11" Opacity="0.7" VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Name="PausedText" Text="scrolling paused" FontSize="11"
Margin="0,0,8,0" IsVisible="False" VerticalAlignment="Center" />
<Button Grid.Column="2" Name="ReconnectButton" Content="Reconnect" Click="OnReconnect" />
</Grid>
<StackPanel DockPanel.Dock="Bottom" Margin="8,4,8,8" Spacing="4">
<WrapPanel Name="ButtonRow" />
<DockPanel>
<Button DockPanel.Dock="Right" Content="Send" Click="OnSend" IsDefault="True"
Margin="6,0,0,0" />
<TextBox Name="CommandBox" PlaceholderText="command to the node" KeyDown="OnCommandKey" />
</DockPanel>
</StackPanel>
<ListBox Name="Traffic" FontFamily="monospace" FontSize="12" Margin="8,0"
DoubleTapped="OnJumpToSpot"
PointerEntered="OnTrafficEntered" PointerExited="OnTrafficExited">
<ListBox.ContextMenu>
<ContextMenu>
<MenuItem Header="Jump to this spot" Click="OnJumpToSpot" />
<MenuItem Header="Copy line" Click="OnCopyLine" />
<MenuItem Header="Clear" Click="OnClearTraffic" />
</ContextMenu>
</ListBox.ContextMenu>
</ListBox>
</DockPanel>
</TabItem>
<TabItem Header="Clusters">
<Grid ColumnDefinitions="*,12,300" Margin="10">
<Grid RowDefinitions="Auto,2*,Auto,Auto,3*">
<TextBlock Classes="label" Text="Favourites" Margin="0,0,0,1" />
<ListBox Grid.Row="1" Name="NodeList" SelectionChanged="OnNodePicked" />
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="6" Margin="0,6,0,0">
<Button Content="Connect" Click="OnConnect" />
<Button Content="Disconnect" Click="OnDisconnect" />
<Button Content="Remove" Click="OnRemoveNode" />
</StackPanel>
<Grid Grid.Row="3" ColumnDefinitions="*,Auto" Margin="0,12,0,1">
<TextBlock Classes="label" Name="PublishedText" Text="Published list" VerticalAlignment="Center" />
<Button Grid.Column="1" Content="Download" Click="OnDownloadNodes" />
</Grid>
<ListBox Grid.Row="4" Name="PublishedNodeList" SelectionChanged="OnPublishedNodePicked" />
</Grid>
<ScrollViewer Grid.Column="2">
<StackPanel>
<TextBlock Classes="label" Text="Name" Margin="0,0,0,1" />
<TextBox Name="NameBox" />
<Grid ColumnDefinitions="*,8,80" >
<StackPanel>
<TextBlock Classes="label" Text="Node" />
<TextBox Name="HostBox" PlaceholderText="dxc.example.net" />
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Classes="label" Text="Port" />
<TextBox Name="PortBox" />
</StackPanel>
</Grid>
<TextBlock Classes="label" Text="Password, if the node asks for one" />
<TextBox Name="PasswordBox" PasswordChar="•" />
<TextBlock Classes="label" Text="Commands sent after login, one per line" />
<TextBox Name="CommandsBox" AcceptsReturn="True" Height="70" />
<Button Content="Save to favourites" Click="OnStoreNode" Margin="0,8,0,0" />
<TextBlock Classes="label" Text="Options" Margin="0,14,0,1" />
<CheckBox Name="AutoLogonBox" Content="Log on automatically" />
<TextBlock Classes="label" Text="Log on with" />
<TextBox Name="LogonCallBox" PlaceholderText="the station callsign" />
<TextBlock Classes="label" Text="Keep alive every (minutes)" />
<TextBox Name="KeepAliveBox" Width="60" HorizontalAlignment="Left" />
<Button Content="Save options" Click="OnSaveOptions" Margin="0,8,0,0" />
</StackPanel>
</ScrollViewer>
</Grid>
</TabItem>
<TabItem Header="Filters">
<ScrollViewer>
<StackPanel Margin="10" Spacing="2">
<TextBlock Classes="label" Text="Bands — none ticked means every band" />
<WrapPanel Name="BandBoxes" />
<TextBlock Classes="label" Text="Modes — none ticked means every mode" />
<StackPanel Orientation="Horizontal" Spacing="12">
<CheckBox Name="CwBox" Content="CW" />
<CheckBox Name="PhoneBox" Content="Phone" />
<CheckBox Name="DigitalBox" Content="Digital" />
</StackPanel>
<CheckBox Name="BeaconBox" Content="Show beacon spots" Margin="0,6,0,0" />
<CheckBox Name="BustedBox" Content="Remove busted spots — a call one character off one the callsign database knows" />
<CheckBox Name="CallHistoryBox" Content="Only the stations in the call history file" />
<CheckBox Name="RandomizeBox" Content="Randomise incoming CW spot frequencies" />
<TextBlock Classes="label" Text="Preferred spotters, whose lines the traffic paints" />
<TextBox Name="PreferredSpottersBox" PlaceholderText="W3LPL K1TTT" />
<TextBlock Classes="label" Text="Take spots only from" />
<CheckBox Name="MyCountryBox" Content="my country" />
<CheckBox Name="MyContinentBox" Content="my continent" />
<TextBlock Classes="label" Text="these prefixes, separated by spaces" />
<TextBox Name="CallAreasBox" PlaceholderText="K1 K2 VE3" />
<TextBlock Classes="label" Text="Blacklisted spotters" />
<TextBox Name="BlockedSpottersBox" />
<TextBlock Classes="label" Text="Blacklisted calls" />
<TextBox Name="BlockedCallsBox" />
<TextBlock Classes="label" Text="Bandmap spot timeout (minutes)" />
<TextBox Name="TimeoutBox" Width="60" HorizontalAlignment="Left" />
<Button Content="Apply" Click="OnApplyFilters" Margin="0,10,0,0" />
</StackPanel>
</ScrollViewer>
</TabItem>
<TabItem Header="Buttons">
<DockPanel Margin="10">
<TextBlock DockPanel.Dock="Top" Classes="label" Margin="0,0,0,6" TextWrapping="Wrap"
Text="The buttons under the traffic. A button takes the message macros — {MYCALL}, {CALL}, {FREQ} — several commands separated by semicolons, or {CONN} and the name of a favourite to connect to it. Leave the label empty to drop the button." />
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Spacing="6" Margin="0,10,0,0">
<Button Content="Save" Click="OnSaveButtons" />
<Button Content="Back to the defaults" Click="OnResetButtons" />
</StackPanel>
<ScrollViewer>
<Grid Name="ButtonRows" ColumnDefinitions="140,8,*" />
</ScrollViewer>
</DockPanel>
</TabItem>
<TabItem Header="Spot comment">
<StackPanel Margin="10">
<TextBlock Classes="label" Text="Sent with the spots you put on the cluster" />
<TextBox Name="SpotCommentBox" PlaceholderText="CQ contest" />
<Button Content="Save" Click="OnSaveSpotComment" Margin="0,10,0,0" />
</StackPanel>
</TabItem>
</TabControl>
</local:RefreshableWindow>

View File

@@ -0,0 +1,291 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Threading;
using Nonemm.App.Configuration;
using Nonemm.Core;
using Nonemm.Session;
using Nonemm.Spotting;
namespace Nonemm.App.Windows;
/// The cluster node's traffic as it arrived, a line to send commands on, and
/// the tabs that say which node to talk to and which spots to keep. Double
/// clicking a spot line puts the radio on it.
public sealed partial class TelnetWindow : RefreshableWindow
{
private const int LinesKept = 500;
private static readonly IBrush SpotColour = Verdicts.NewMultiplier;
private static readonly IBrush SentColour = Verdicts.Worth;
private static readonly IBrush NoticeColour = Verdicts.Dupe;
private readonly AppSession session;
private readonly Action<Frequency, string> tune;
private readonly List<string> history = [];
private int recalled = -1;
private bool paused;
public TelnetWindow(AppSession session, Action<Frequency, string> tune)
{
this.session = session;
this.tune = tune;
InitializeComponent();
LoadButtons();
LoadButtonRows();
LoadClusters();
LoadFilters();
Listen(session.Cluster);
Closed += (_, _) => Forget(session.Cluster);
Refresh();
}
/// Config ▸ Cluster opens the window here, because this is where the node
/// is chosen.
public void ShowClusters() => Tabs.SelectedIndex = 1;
public void ShowButtons() => Tabs.SelectedIndex = 3;
public override void Refresh()
{
ClusterClient? cluster = session.Cluster;
StateText.Text = cluster is null
? "not connected — pick a node on the Clusters tab"
: cluster.IsConnected
? $"connected to {cluster.Host}:{cluster.Port}"
: $"connecting to {cluster.Host}:{cluster.Port}…";
ReconnectButton.Content = cluster is null ? "Connect" : "Reconnect";
}
/// The traffic so far is put up first, so a window opened mid-contest is
/// not blank. Taking it before subscribing keeps a line from appearing
/// twice.
private void Listen(ClusterClient? cluster)
{
if (cluster is null)
{
return;
}
IReadOnlyList<ClusterLine> already = cluster.Recent;
cluster.LineArrived += OnLineArrived;
cluster.LineSent += OnLineSent;
cluster.ConnectionChanged += OnConnectionChanged;
foreach (ClusterLine line in already)
{
if (line.WasSent)
{
OnLineSent(cluster, line.Text);
}
else
{
OnLineArrived(cluster, line.Text);
}
}
}
private void Forget(ClusterClient? cluster)
{
if (cluster is null)
{
return;
}
cluster.LineArrived -= OnLineArrived;
cluster.LineSent -= OnLineSent;
cluster.ConnectionChanged -= OnConnectionChanged;
}
private void OnLineArrived(object? sender, string line)
{
if (line.StartsWith("***", StringComparison.Ordinal))
{
Show(line, NoticeColour);
return;
}
if (SpotLine.Parse(line, DateTime.UtcNow) is not { } spot)
{
Show(line, Foreground);
return;
}
Show(line, SpotColour, IsPreferred(spot.Spotter));
}
/// A spotter the operator listed on the Filters tab. N1MM matches on the
/// start of the call, so `W3LPL` also covers `W3LPL-#`.
private bool IsPreferred(string spotter) =>
session.Settings.SpotFilter.PreferredSpotters.Any(one =>
one.Length > 0 && spotter.StartsWith(one, StringComparison.OrdinalIgnoreCase));
private void OnLineSent(object? sender, string line) => Show($"> {line}", SentColour);
private void OnConnectionChanged(object? sender, bool connected) =>
Dispatcher.UIThread.Post(Refresh);
/// `strong` marks the lines from a preferred spotter.
private void Show(string line, IBrush? colour, bool strong = false) => Dispatcher.UIThread.Post(() =>
{
while (Traffic.Items.Count >= LinesKept)
{
Traffic.Items.RemoveAt(0);
}
Traffic.Items.Add(new TextBlock
{
Text = line,
Foreground = colour,
FontWeight = strong ? FontWeight.Bold : FontWeight.Normal,
});
if (!paused && Traffic.Items.Count > 0)
{
Traffic.ScrollIntoView(Traffic.Items.Count - 1);
}
Refresh();
});
/// Scrolling stops while the pointer is over the traffic, so a line can be
/// read or copied while the node keeps sending.
private void OnTrafficEntered(object? sender, PointerEventArgs e)
{
paused = true;
PausedText.IsVisible = true;
}
private void OnTrafficExited(object? sender, PointerEventArgs e)
{
paused = false;
PausedText.IsVisible = false;
if (Traffic.Items.Count > 0)
{
Traffic.ScrollIntoView(Traffic.Items.Count - 1);
}
}
/// The label takes the macros as well, expanded once here, which is what
/// N1MM does when it loads its buttons.
private void LoadButtons()
{
ButtonRow.Children.Clear();
foreach (StoredTelnetButton stored in Buttons())
{
Button button = new()
{
Content = Expanded(stored.Label),
FontSize = 11,
Padding = new Avalonia.Thickness(6, 3),
Margin = new Avalonia.Thickness(0, 0, 3, 3),
Tag = stored,
};
ToolTip.SetTip(button, $"{stored.Command}\nright-click to edit the buttons");
button.Click += OnButtonPressed;
button.PointerReleased += OnButtonRightClick;
ButtonRow.Children.Add(button);
}
}
/// Right-clicking a button opens the editor, as it does in N1MM.
private void OnButtonRightClick(object? sender, PointerReleasedEventArgs e)
{
if (e.InitialPressMouseButton == MouseButton.Right)
{
ShowButtons();
}
}
private IReadOnlyList<StoredTelnetButton> Buttons() =>
session.Settings.TelnetButtons.Count > 0
? session.Settings.TelnetButtons
: StoredTelnetButton.Default;
/// A button's text is N1MM's: the message macros, and `{CONN}node` to
/// connect to a favourite instead of sending anything. Several commands are
/// separated by semicolons and go out one after the other.
private async void OnButtonPressed(object? sender, RoutedEventArgs e)
{
if (sender is not Button { Tag: StoredTelnetButton stored })
{
return;
}
if (ConnectToFavourite(stored.Command))
{
return;
}
foreach (string command in Expanded(stored.Command).Split(';', StringSplitOptions.RemoveEmptyEntries))
{
await SendAsync(command.Trim());
}
}
/// The same macros the function keys take. With no contest open there is
/// nothing to fill them from, so the text goes out as it was typed.
private string Expanded(string text) =>
session.Position is { } position
? MessageExpander.Expand(text, position, session.Other(position))
: text;
private async void OnSend(object? sender, RoutedEventArgs e)
{
if (CommandBox.Text is not { Length: > 0 } command)
{
return;
}
history.Add(command);
recalled = -1;
CommandBox.Text = "";
// what is typed goes out as it was typed, the way N1MM sends it
await SendAsync(command);
}
/// Up and down walk back through what has been typed, the way a terminal
/// does.
private void OnCommandKey(object? sender, KeyEventArgs e)
{
if (history.Count == 0 || (e.Key != Key.Up && e.Key != Key.Down))
{
return;
}
recalled = e.Key == Key.Up
? Math.Min(recalled + 1, history.Count - 1)
: Math.Max(recalled - 1, -1);
CommandBox.Text = recalled < 0 ? "" : history[history.Count - 1 - recalled];
CommandBox.CaretIndex = CommandBox.Text.Length;
e.Handled = true;
}
private async Task SendAsync(string command)
{
if (session.Cluster is not { } cluster)
{
Show("*** not connected to a node", NoticeColour);
return;
}
await cluster.SendAsync(command);
}
private void OnReconnect(object? sender, RoutedEventArgs e) => Connect();
/// Tunes the radio to the spot on the selected line, which is what N1MM's
/// "Jump to this spot" does.
private void OnJumpToSpot(object? sender, RoutedEventArgs e)
{
if (Traffic.SelectedItem is not TextBlock { Text: { } line })
{
return;
}
if (SpotLine.Parse(line, DateTime.UtcNow) is not { } spot)
{
Show("*** that line is not a spot", NoticeColour);
return;
}
tune(spot.Frequency, spot.Call.Text);
}
private async void OnCopyLine(object? sender, RoutedEventArgs e)
{
if (Traffic.SelectedItem is TextBlock { Text: { } line } && Clipboard is { } clipboard)
{
await clipboard.SetTextAsync(line);
}
}
private void OnClearTraffic(object? sender, RoutedEventArgs e) => Traffic.Items.Clear();
}

View File

@@ -68,6 +68,23 @@ public sealed class BandPlan
public BandSegments? For(Band band) =>
byBand.TryGetValue(band.Name, out BandSegments? segments) ? segments : null;
/// Which part of the band a frequency falls in, or null when the plan has
/// no boundaries for that band. A spot line carries no mode, so this is
/// what says what mode a spot is on.
public ModeCategory? ModeAt(Frequency frequency)
{
Band? band = Bands.ForFrequency(frequency);
if (band is null || For(band) is not { } segments)
{
return null;
}
if (segments.HasDigital && frequency >= segments.DigitalLow && frequency <= segments.DigitalHigh)
{
return ModeCategory.Digital;
}
return frequency < segments.CwHigh ? ModeCategory.Cw : ModeCategory.Phone;
}
/// The same plan with one band's boundaries changed. A band the plan does
/// not hold is added.
public BandPlan With(BandSegments segments) =>

View File

@@ -5,8 +5,13 @@ namespace Nonemm.Core.Calls;
public sealed class CallDatabase
{
private readonly IReadOnlyList<string> calls;
private readonly HashSet<string> known;
private CallDatabase(IReadOnlyList<string> calls) => this.calls = calls;
private CallDatabase(IReadOnlyList<string> calls)
{
this.calls = calls;
known = new HashSet<string>(calls, StringComparer.OrdinalIgnoreCase);
}
public static readonly CallDatabase Empty = new([]);
@@ -37,6 +42,9 @@ public sealed class CallDatabase
return new CallDatabase(found);
}
/// Whether the database has heard this exact call in a contest.
public bool Holds(string call) => known.Contains(call);
public IReadOnlyList<PartialMatch> Matches(string query, int limit = 40)
{
List<PartialMatch> found = [];

View File

@@ -1,16 +1,29 @@
using System.Globalization;
using System.Text;
using Nonemm.Contests;
using Nonemm.Core;
using Nonemm.Core.Calls;
namespace Nonemm.Session;
/// Fills in a function key message. The macro names are N1MM's, so a message
/// file written for either program says the same thing.
/// Fills in a function key message. The macro names and what they stand for are
/// N1MM's, from its published function-key documentation, so a message file
/// written for either program says the same thing.
///
/// Only the text macros are here. N1MM also has action macros — `{WIPE}`,
/// `{LOG}`, `{RUN}`, the CAT and SO2R families — which run a program command
/// rather than standing for text; they expand to nothing, so a message that
/// holds one still sends the right characters.
public static class MessageExpander
{
/// Cut numbers: a contest operator sends T for zero and N for nine because
/// they are shorter.
private const string CutDigits = "T12345678N";
public static string Expand(string template, RadioPosition session)
/// `other` is the radio the operator is not on, for the macros that pass a
/// station to the other band. Null when the station has one radio, and then
/// those macros stand for nothing.
public static string Expand(string template, RadioPosition session, RadioPosition? other = null)
{
StringBuilder text = new();
int at = 0;
@@ -25,13 +38,14 @@ public static class MessageExpander
text.Append(template[at..]);
break;
}
text.Append(Macro(template[(at + 1)..close], session));
text.Append(Macro(template[(at + 1)..close], session, other));
at = close + 1;
continue;
}
if (c == '#')
// the single-character macros, which take no braces
if (c is '*' or '!' or '#')
{
text.Append(session.SentNumber);
text.Append(Single(c, session));
at++;
continue;
}
@@ -41,23 +55,129 @@ public static class MessageExpander
return text.ToString();
}
private static string Macro(string name, RadioPosition session) => name.ToUpperInvariant() switch
private static string Single(char macro, RadioPosition session) => macro switch
{
"MYCALL" => session.Me.Callsign,
"CALL" => session.Entry.Call.Trim(),
"LOGGEDCALL" => session.Log.Qsos.Count > 0 ? session.Log.Qsos[^1].Call.Text : "",
"EXCH" => session.Instance.SentExchange,
"SENTRST" => session.DefaultReport(),
"SENTRSTCUT" => Cut(session.DefaultReport()),
"SENTNR" => session.SentNumber.ToString(),
"SENTNRCUT" => Cut(session.SentNumber.ToString()),
"NAME" => session.Me.Name,
"GRIDSQUARE" => session.Me.GridSquare,
"MYZONE" => session.Me.CqZone.ToString(),
"OTHERMHZ" => (session.Frequency.Megahertz).ToString("0.###"),
_ => "",
'*' => session.Me.Callsign,
'!' => TheirCall(session),
_ => SerialNumber(session),
};
/// The number for this contact, or the one just logged when nothing is
/// typed in the callsign box, so a repeat after logging sends the same
/// number again.
private static string SerialNumber(RadioPosition session) =>
session.Entry.Call.Trim().Length == 0 && LastLogged(session) is { } last
? last.SentNumber.ToString(CultureInfo.InvariantCulture)
: session.SentNumber.ToString(CultureInfo.InvariantCulture);
private static string Macro(string name, RadioPosition session, RadioPosition? other) =>
name.ToUpperInvariant() switch
{
"MYCALL" => session.Me.Callsign,
"CALL" => TheirCall(session),
"LASTCALL" => LastLogged(session)?.Call.Text ?? "",
"PREVNR" => LastLogged(session) is { } last
? last.SentNumber.ToString("000", CultureInfo.InvariantCulture)
: "",
"EXCH" => session.Instance.SentExchange,
"SENTRST" => session.DefaultReport(),
"SENTRSTCUT" => Cut(session.DefaultReport()),
"GRID" => session.Me.GridSquare,
"GRIDSQUARE" => Theirs(session, ExchangeSlot.GridSquare, known => known.GridSquare),
"GRIDBEARING" => Bearing(session, reverse: false),
"REVGRIDBEARING" => Bearing(session, reverse: true),
// N1MM's own documentation spells this one KMGRIGDISTANCE; the
// program answers to KMGRIDDISTANCE, so that is what is read here
"KMGRIDDISTANCE" => Distance(session),
"NAME" => Name(session),
"NAMEANDSPACE" => Name(session) is { Length: > 0 } theirName ? theirName + " " : "",
"CHNAME" => session.Session.History.Find(session.Entry.Call)?.Name ?? "",
"FREQ" => FrequencyText(session, session.Frequency, round: false),
"FREQROUND" => FrequencyText(session, session.Frequency, round: true),
"OTHERFREQ" => other is null ? "" : FrequencyText(session, other.Frequency, round: false),
"OTHERFREQROUND" => other is null ? "" : FrequencyText(session, other.Frequency, round: true),
"OTHERMHZ" => other is null ? "" : Megahertz(session, other.Frequency),
"OTHERBAND" => other is null ? "" : Bands.ForFrequency(other.Frequency)?.Name ?? "",
"LRMHZ" => Megahertz(session, Radio(session, other, 1)),
"RRMHZ" => Megahertz(session, Radio(session, other, 2)),
"TIMESTAMP" => DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture),
"TIME2" => DateTime.UtcNow.ToString("HHmm", CultureInfo.InvariantCulture),
_ => "",
};
/// The call being worked, or the last one logged when the box is empty,
/// which is what N1MM sends for both `{CALL}` and `!`.
private static string TheirCall(RadioPosition session) =>
session.Entry.Call.Trim() is { Length: > 0 } typed ? typed : LastLogged(session)?.Call.Text ?? "";
private static Qso? LastLogged(RadioPosition session) =>
session.Log.Qsos.Count > 0 ? session.Log.Qsos[^1] : null;
private static string Name(RadioPosition session) =>
Theirs(session, ExchangeSlot.Name, known => known.Name);
/// What the operator has typed in that exchange box, or what the call
/// history file says when the contest has no such box or it is empty.
private static string Theirs(
RadioPosition session,
ExchangeSlot slot,
Func<CallHistoryEntry, string> fromHistory)
{
string typed = session.Entry.ValueOf(slot).Trim();
if (typed.Length > 0)
{
return typed;
}
CallHistoryEntry? known = session.Session.History.Find(session.Entry.Call);
return known is null ? "" : fromHistory(known);
}
/// Kilohertz to one decimal, and on CW the decimal point is sent as R.
private static string FrequencyText(RadioPosition session, Frequency frequency, bool round)
{
if (round)
{
return Math.Round(frequency.Kilohertz).ToString("0", CultureInfo.InvariantCulture);
}
return CwDecimal(session, frequency.Kilohertz.ToString("0.0", CultureInfo.InvariantCulture));
}
/// The band in megahertz, as N1MM sends it: 14, or 3R5 on CW.
private static string Megahertz(RadioPosition session, Frequency frequency) =>
Bands.ForFrequency(frequency) is { } band
? CwDecimal(session, band.MegahertzLabel.ToString("0.###", CultureInfo.InvariantCulture))
: "";
private static Frequency Radio(RadioPosition session, RadioPosition? other, int number) =>
session.RadioNumber == number ? session.Frequency
: other?.RadioNumber == number ? other.Frequency
: Frequency.Zero;
private static string CwDecimal(RadioPosition session, string text) =>
session.Mode.Category == ModeCategory.Cw ? text.Replace('.', 'R') : text;
private static string Bearing(RadioPosition session, bool reverse)
{
if (!Grids(session, out GridSquare mine, out GridSquare theirs))
{
return "";
}
double bearing = reverse ? theirs.BearingTo(mine) : mine.BearingTo(theirs);
return Math.Round(bearing).ToString("0", CultureInfo.InvariantCulture);
}
private static string Distance(RadioPosition session) =>
Grids(session, out GridSquare mine, out GridSquare theirs)
? Math.Round(mine.DistanceTo(theirs)).ToString("0", CultureInfo.InvariantCulture)
: "";
private static bool Grids(RadioPosition session, out GridSquare mine, out GridSquare theirs)
{
theirs = default;
return GridSquare.TryParse(session.Me.GridSquare, out mine)
&& GridSquare.TryParse(Theirs(session, ExchangeSlot.GridSquare, known => known.GridSquare), out theirs);
}
private static string Cut(string digits) =>
new(digits.Select(d => char.IsAsciiDigit(d) ? CutDigits[d - '0'] : d).ToArray());
}

View File

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

View File

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

View 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);

View 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));
}

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

View 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);
}

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

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Nonemm.App\Nonemm.App.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,67 @@
using Nonemm.App.Configuration;
namespace Nonemm.App.Tests;
public class SettingsTests : IDisposable
{
private readonly string path = Path.Combine(Path.GetTempPath(), $"nonemm-{Guid.NewGuid():N}.json");
public void Dispose() => File.Delete(path);
private Settings Load(string json)
{
File.WriteAllText(path, json);
return Settings.Load(path);
}
[Fact]
public void AFileWithNullsInItReadsAsTheDefaults()
{
Settings settings = Load(
"""
{
"ClusterHost": null,
"ClusterCommands": null,
"ClusterNodes": null,
"TelnetButtons": null,
"SpotFilter": null,
"Radios": null,
"Station": null,
"CwMessages": null
}
""");
Assert.Equal("", settings.ClusterHost);
Assert.Empty(settings.ClusterCommands);
Assert.Empty(settings.ClusterNodes);
Assert.Empty(settings.TelnetButtons);
Assert.Empty(settings.SpotFilter.Bands);
Assert.Empty(settings.Radios);
Assert.Equal("", settings.Station.Callsign);
Assert.Empty(settings.CwMessages);
}
[Fact]
public void ANullInsideAStoredNodeIsFilledInToo()
{
Settings settings = Load(
"""
{
"ClusterNodes": [ { "Name": null, "Host": "dxc.example.net", "Commands": null } ]
}
""");
StoredClusterNode node = Assert.Single(settings.ClusterNodes);
Assert.Equal("", node.Name);
Assert.Empty(node.Commands);
}
[Fact]
public void WhatTheFileSaysIsKept()
{
Settings settings = Load("""{ "ClusterHost": "dxc.example.net", "ClusterPort": 7300 }""");
Assert.Equal("dxc.example.net", settings.ClusterHost);
Assert.Equal(7300, settings.ClusterPort);
}
}

View File

@@ -58,4 +58,30 @@ public class BandPlanTests
Assert.True(segments.HasDigital);
}
[Fact]
public void TheModeOfAFrequencyComesFromTheSegments()
{
Assert.Equal(ModeCategory.Cw, BandPlan.Default.ModeAt(Frequency.FromKilohertz(14_025)));
Assert.Equal(ModeCategory.Phone, BandPlan.Default.ModeAt(Frequency.FromKilohertz(14_250)));
}
[Fact]
public void ABandWithNoSegmentsHasNoMode()
{
Assert.Null(BandPlan.Default.ModeAt(Frequency.FromKilohertz(5_300)));
}
[Fact]
public void TheDigitalPartWinsOverTheCwPart()
{
BandPlan plan = BandPlan.Default.With(new BandSegments(
Bands.Band20M,
Frequency.FromKilohertz(14_150),
Frequency.FromKilohertz(14_070),
Frequency.FromKilohertz(14_099)));
Assert.Equal(ModeCategory.Digital, plan.ModeAt(Frequency.FromKilohertz(14_080)));
Assert.Equal(ModeCategory.Cw, plan.ModeAt(Frequency.FromKilohertz(14_025)));
}
}

View File

@@ -1,13 +1,14 @@
using Nonemm.Contests;
using Nonemm.Contests.Rules;
using Nonemm.Core;
using Nonemm.Core.Calls;
using Nonemm.Storage;
namespace Nonemm.Session.Tests;
public class MessageExpanderTests
{
private static RadioPosition Session()
private static RadioPosition Session(CallHistory? history = null, int radioNumber = 1)
{
FakeLogStore store = new();
ContestInstance instance = store.AddContest(new ContestInstance
@@ -16,12 +17,15 @@ public class MessageExpanderTests
ContestName = "CQWW",
SentExchange = "14",
});
return new RadioPosition(new ContestSession(
store,
new CqWorldWide(ModeCategory.Cw),
instance,
new StationInfo { Callsign = "DL1ABC", CqZone = 14, Name = "Erik" },
null));
return new RadioPosition(
new ContestSession(
store,
new CqWorldWide(ModeCategory.Cw),
instance,
new StationInfo { Callsign = "DL1ABC", CqZone = 14, Name = "Erik", GridSquare = "JN88" },
null,
history),
radioNumber);
}
[Fact]
@@ -51,4 +55,66 @@ public class MessageExpanderTests
[Fact]
public void AnUnclosedMacroIsLeftAsTyped() =>
Assert.Equal("TU {MYCALL", MessageExpander.Expand("TU {MYCALL", Session()));
[Fact]
public void TheSingleCharacterMacrosAreTheirCallMyCallAndTheSerial()
{
RadioPosition session = Session();
session.Entry.Call = "JA1XYZ";
Assert.Equal("JA1XYZ 599 1 DE DL1ABC", MessageExpander.Expand("! 599 # DE *", session));
}
[Fact]
public void TheirCallFallsBackToTheLastOneLogged()
{
RadioPosition session = Session();
session.Entry.Call = "JA1XYZ";
session.Entry.Set(ExchangeSlot.ReceivedReport, "599");
session.Entry.Set(ExchangeSlot.Zone, "25");
session.LogContact();
Assert.Equal("JA1XYZ JA1XYZ", MessageExpander.Expand("{CALL} {LASTCALL}", session));
}
[Fact]
public void TheFrequencySendsRForTheDecimalPointOnCw()
{
RadioPosition session = Session();
session.Tune(Frequency.FromKilohertz(14_025.5));
Assert.Equal("14025R5 14026", MessageExpander.Expand("{FREQ} {FREQROUND}", session));
}
[Fact]
public void TheOtherRadioFillsInTheOtherMacros()
{
RadioPosition here = Session();
RadioPosition there = Session(radioNumber: 2);
there.Tune(Frequency.FromKilohertz(3_525));
Assert.Equal("3525R0 3R5 80M", MessageExpander.Expand("{OTHERFREQ} {OTHERMHZ} {OTHERBAND}", here, there));
}
[Fact]
public void WithOneRadioTheOtherMacrosStandForNothing() =>
Assert.Equal("", MessageExpander.Expand("{OTHERFREQ}{OTHERMHZ}{OTHERBAND}", Session()));
[Fact]
public void TheNameComesFromTheCallHistoryWhenTheContestHasNoNameBox()
{
RadioPosition session = Session(CallHistory.Parse("!!Order!!,Call,Name\nJA1XYZ,Ken"));
session.Entry.Call = "JA1XYZ";
Assert.Equal("TU Ken GL", MessageExpander.Expand("TU {NAMEANDSPACE}GL", session));
}
[Fact]
public void MyGridAndTheirsAreDifferentMacros()
{
RadioPosition session = Session(CallHistory.Parse("!!Order!!,Call,LOC1\nJA1XYZ,PM95"));
session.Entry.Call = "JA1XYZ";
Assert.Equal("JN88 PM95", MessageExpander.Expand("{GRID} {GRIDSQUARE}", session));
}
}

View File

@@ -66,6 +66,21 @@ public class ClusterClientTests : IDisposable
Assert.Equal("DL1ABC\r\n", Encoding.Latin1.GetString(sent[3..]));
}
[Fact]
public async Task NothingIsSentAtTheLoginPromptWhenLoggingOnByHand()
{
using ClusterClient cluster = new(
"127.0.0.1", Port, "DL1ABC", retryInterval: TimeSpan.FromMinutes(1), autoLogon: false);
cluster.Start();
using TcpClient node = await listener.AcceptTcpClientAsync().WaitAsync(Patience);
NetworkStream stream = node.GetStream();
await stream.WriteAsync(Encoding.Latin1.GetBytes("Please enter your call: "));
await cluster.SendAsync("DL1XYZ");
Assert.Equal("DL1XYZ\r\n", await ReadTextAsync(stream, 8).WaitAsync(Patience));
}
[Fact]
public async Task TheCommandsGoOutAfterTheLogin()
{

View File

@@ -0,0 +1,48 @@
namespace Nonemm.Spotting.Tests;
public class ClusterListTests
{
/// Four rows in the shape the NG3K page uses, one of each thing that has to
/// be read: a port after a colon, a port after a space, no port at all, and
/// the same node listed twice.
private const string Page =
"""
<table>
<tr align="center"><td><a href="http://www.k1ttt.net">K1TTT</a></td>
<td><a href="telnet://k1ttt.net:7373">k1ttt.net:7373</a><br>(dynamically assigned)</td>
<td>call</td><td>&nbsp;</td><td align="left">YCCC AR-Cluster (Peru, MA)</td></tr>
<tr align="center"><td>W4MYA</td>
<td><a href="telnet://dxc.w4mya.us 7373">dxc.w4mya.us 7373</a></td>
<td>call</td><td align="left">W4MYA CC-Cluster (Bumpass, VA)</td></tr>
<tr align="center"><td>W3LPL</td>
<td><a href="telnet://dxc.w3lpl.net">dxc.w3lpl.net</a></td>
<td>call</td><td align="left">W3LPL AR-Cluster, v.4</td></tr>
<tr align="center"><td>K1TTT again</td>
<td><a href="telnet://k1ttt.net:7373">k1ttt.net:7373</a></td></tr>
</table>
""";
[Fact]
public void EveryNodeOnThePageIsRead()
{
IReadOnlyList<ClusterNode> nodes = ClusterList.Parse(Page);
Assert.Equal(["k1ttt.net:7373", "dxc.w4mya.us:7373", "dxc.w3lpl.net:23"],
nodes.Select(n => n.Address));
}
[Fact]
public void TheNameAndTheNoteComeFromTheRow()
{
ClusterNode node = ClusterList.Parse(Page)[0];
Assert.Equal("K1TTT", node.Name);
Assert.Equal("YCCC AR-Cluster (Peru, MA)", node.Description);
}
[Fact]
public void APageWithNoNodesIsRefused()
{
Assert.Throws<FormatException>(() => ClusterList.Parse("<html><body>down for maintenance</body></html>"));
}
}

View File

@@ -0,0 +1,171 @@
using Nonemm.Core;
using Nonemm.Core.Calls;
using Nonemm.Core.Country;
namespace Nonemm.Spotting.Tests;
public class SpotFilterTests
{
private static readonly DateTime Now = new(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc);
/// Enough of a country file to place the spotters these tests use.
private static readonly CountryFile Countries = CountryFile.Parse(
"""
United States: 05: 08: NA: 37.53: 91.67: 5.0: K:
K,W,N,AA;
Germany: 14: 28: EU: 51.00: -10.00: -1.0: DL:
DL,DK,DJ;
Japan: 25: 45: AS: 36.00: -138.00: -9.0: JA:
JA,JH,JR;
""");
private static Spot From(string spotter, string call = "OM5M", double kilohertz = 14_025, string comment = "") =>
new(Callsign.Parse(call), Frequency.FromKilohertz(kilohertz), Now, SpotSource.Cluster, spotter, comment);
[Fact]
public void AFilterNobodyHasTouchedTakesEverything()
{
Assert.True(new SpotFilter().Accepts(From("W3LPL")));
}
[Fact]
public void OnlyTheChosenBandsGetThrough()
{
SpotFilter filter = new() { Bands = [Bands.Band20M] };
Assert.True(filter.Accepts(From("W3LPL", kilohertz: 14_025)));
Assert.False(filter.Accepts(From("W3LPL", kilohertz: 21_025)));
}
[Fact]
public void TheModeComesFromTheBandPlan()
{
SpotFilter filter = new() { Modes = [ModeCategory.Cw] };
Assert.True(filter.Accepts(From("W3LPL", kilohertz: 14_025)));
Assert.False(filter.Accepts(From("W3LPL", kilohertz: 14_250)));
}
[Fact]
public void BeaconsAreDroppedWhenTheyAreTurnedOff()
{
SpotFilter filter = new() { ShowBeacons = false };
Assert.False(filter.Accepts(From("W3LPL", comment: "NCDXF BCN")));
Assert.True(filter.Accepts(From("W3LPL", comment: "CQ contest")));
}
[Fact]
public void ABlacklistedSpotterAndABlacklistedCallAreDropped()
{
SpotFilter filter = new() { BlockedSpotters = ["W3LPL"], BlockedCalls = ["OM5M"] };
Assert.False(filter.Accepts(From("w3lpl", call: "DL1ABC")));
Assert.False(filter.Accepts(From("K1TTT", call: "OM5M")));
Assert.True(filter.Accepts(From("K1TTT", call: "DL1ABC")));
}
[Fact]
public void MyContinentOnlyKeepsTheSpottersFromIt()
{
SpotFilter filter = new()
{
Countries = Countries,
MyContinent = "EU",
MyContinentOnly = true,
};
Assert.True(filter.Accepts(From("DL1ABC")));
Assert.False(filter.Accepts(From("JA1XYZ")));
}
[Fact]
public void MyCountryOnlyKeepsTheSpottersFromIt()
{
SpotFilter filter = new()
{
Countries = Countries,
MyCountry = "K",
MyCountryOnly = true,
};
Assert.True(filter.Accepts(From("W3LPL")));
Assert.False(filter.Accepts(From("DL1ABC")));
}
[Fact]
public void ASpotterMatchingAnyOfTheChoicesGetsThrough()
{
SpotFilter filter = new()
{
Countries = Countries,
MyCountry = "K",
MyCountryOnly = true,
CallAreas = ["JA1"],
};
Assert.True(filter.Accepts(From("W3LPL")));
Assert.True(filter.Accepts(From("JA1XYZ")));
Assert.False(filter.Accepts(From("DL1ABC")));
}
[Fact]
public void WithoutACountryFileTheCountryFiltersLetSpotsThrough()
{
SpotFilter filter = new() { MyContinent = "EU", MyContinentOnly = true };
Assert.True(filter.Accepts(From("JA1XYZ")));
}
[Fact]
public void WhatTheOperatorSpottedIsNeverFiltered()
{
SpotFilter filter = new() { Bands = [Bands.Band20M] };
Spot mine = new(
Callsign.Parse("OM5M"),
Frequency.FromKilohertz(21_025),
Now,
SpotSource.Operator);
Assert.True(filter.Accepts(mine));
}
[Fact]
public void ACallOneCharacterOffAKnownCallIsABust()
{
SpotFilter filter = new()
{
Calls = CallDatabase.Parse("OM5M\nDL1ABC\nJA1XYZ"),
RemoveBustedSpots = true,
};
Assert.True(filter.Accepts(From("W3LPL", call: "OM5M")));
Assert.False(filter.Accepts(From("W3LPL", call: "OM5N")));
Assert.False(filter.Accepts(From("W3LPL", call: "DL1AB")));
Assert.False(filter.Accepts(From("W3LPL", call: "DL1ABCD")));
}
[Fact]
public void ACallNothingResemblesIsKept()
{
SpotFilter filter = new()
{
Calls = CallDatabase.Parse("OM5M\nDL1ABC"),
RemoveBustedSpots = true,
};
Assert.True(filter.Accepts(From("W3LPL", call: "VK9XY")));
}
[Fact]
public void WithoutACallsignDatabaseNothingIsCalledBusted()
{
SpotFilter filter = new() { RemoveBustedSpots = true };
Assert.True(filter.Accepts(From("W3LPL", call: "OM5N")));
}
[Fact]
public void OnlyTheStationsInTheCallHistoryGetThrough()
{
SpotFilter filter = new()
{
History = CallHistory.Parse("!!Order!!,Call,Name\nOM5M,Erik"),
OnlyInCallHistory = true,
};
Assert.True(filter.Accepts(From("W3LPL", call: "OM5M")));
Assert.False(filter.Accepts(From("W3LPL", call: "DL1ABC")));
}
}

View File

@@ -0,0 +1,28 @@
using Nonemm.Core;
namespace Nonemm.Spotting.Tests;
public class SpotJitterTests
{
private static Spot At(double kilohertz) =>
new(Callsign.Parse("OM5M"), Frequency.FromKilohertz(kilohertz), DateTime.UtcNow, SpotSource.Cluster);
[Fact]
public void ACwSpotMovesByThirtyOrSixtyHertz()
{
Random random = new(1);
for (int run = 0; run < 20; run++)
{
Spot moved = SpotJitter.Shifted(At(14_025), BandPlan.Default, random);
long offset = moved.Frequency.Hertz - 14_025_000;
Assert.Contains(offset, (long[])[-60, -30, 30, 60]);
}
}
[Fact]
public void APhoneSpotIsLeftWhereItIs()
{
Spot spot = At(14_250);
Assert.Equal(spot, SpotJitter.Shifted(spot, BandPlan.Default, new Random(1)));
}
}