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:
@@ -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);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using Nonemm.Core;
|
||||
|
||||
@@ -21,6 +22,37 @@ public sealed record Settings
|
||||
|
||||
public IReadOnlyList<string> ClusterCommands { get; init; } = [];
|
||||
|
||||
/// The nodes the operator keeps, listed on the telnet window's Clusters
|
||||
/// tab. Connecting to one copies it into `ClusterHost` and the rest.
|
||||
public IReadOnlyList<StoredClusterNode> ClusterNodes { get; init; } = [];
|
||||
|
||||
/// Off for a node that wants the call typed in by hand.
|
||||
public bool ClusterAutoLogon { get; init; } = true;
|
||||
|
||||
/// The call sent at login, or empty for the station callsign.
|
||||
public string ClusterLogonCall { get; init; } = "";
|
||||
|
||||
/// How often to send an empty line to a node that has said nothing. Nodes
|
||||
/// drop a connection that has been quiet for a quarter of an hour.
|
||||
public int ClusterKeepAliveMinutes { get; init; } = 4;
|
||||
|
||||
/// The buttons along the bottom of the telnet window. Empty means the ones
|
||||
/// in `StoredTelnetButton.Default`.
|
||||
public IReadOnlyList<StoredTelnetButton> TelnetButtons { get; init; } = [];
|
||||
|
||||
/// Which cluster spots reach the bandmap.
|
||||
public StoredSpotFilter SpotFilter { get; init; } = new();
|
||||
|
||||
/// How long a spot stays on the bandmap. N1MM's own default is 60.
|
||||
public int SpotTimeoutMinutes { get; init; } = 60;
|
||||
|
||||
/// Moves each incoming CW spot by a few tens of hertz, so the operator has
|
||||
/// to find the station by ear. N1MM calls it randomising.
|
||||
public bool RandomizeSpots { get; init; }
|
||||
|
||||
/// Sent with a spot the operator puts on the cluster with Alt+P.
|
||||
public string SpotComment { get; init; } = "";
|
||||
|
||||
/// One entry per radio, in radio-number order. A second radio makes the
|
||||
/// station SO2R.
|
||||
public IReadOnlyList<StoredRadio> Radios { get; init; } = [];
|
||||
@@ -103,8 +135,9 @@ public sealed record Settings
|
||||
}
|
||||
try
|
||||
{
|
||||
return Migrated(
|
||||
JsonSerializer.Deserialize<Settings>(File.ReadAllText(path), Json) ?? new Settings());
|
||||
Settings read = JsonSerializer.Deserialize<Settings>(File.ReadAllText(path), Json) ?? new Settings();
|
||||
FillNulls(read);
|
||||
return Migrated(read);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
@@ -131,6 +164,51 @@ public sealed record Settings
|
||||
public void Save(string path) =>
|
||||
File.WriteAllText(path, JsonSerializer.Serialize(this, Json));
|
||||
|
||||
/// A settings file can hold a null where the property is not nullable: a
|
||||
/// file written before the property existed and then edited, or one an
|
||||
/// older version wrote. JSON puts the null in and the property's own
|
||||
/// default never runs, so the program reads a null it does not expect.
|
||||
/// Every null is put back to the default the property declares, top to
|
||||
/// bottom, so nothing downstream has to check.
|
||||
private static void FillNulls(object target)
|
||||
{
|
||||
Type type = target.GetType();
|
||||
object fresh = Activator.CreateInstance(type)!;
|
||||
foreach (PropertyInfo property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
|
||||
{
|
||||
if (!property.CanWrite || property.GetIndexParameters().Length > 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
object? value = property.GetValue(target);
|
||||
if (value is null)
|
||||
{
|
||||
property.SetValue(target, property.GetValue(fresh));
|
||||
}
|
||||
else if (value is System.Collections.IEnumerable items and not string)
|
||||
{
|
||||
foreach (object? item in items)
|
||||
{
|
||||
FillOurOwn(item);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
FillOurOwn(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Only the types in this folder are walked into. A string, a number or a
|
||||
/// framework type has nothing to fill in.
|
||||
private static void FillOurOwn(object? value)
|
||||
{
|
||||
if (value is not null && value.GetType().Namespace == typeof(Settings).Namespace)
|
||||
{
|
||||
FillNulls(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// Carries the single radio an older settings file holds into the list.
|
||||
private static Settings Migrated(Settings settings) =>
|
||||
settings.Radios.Count > 0 || settings.RigctldHost.Length == 0
|
||||
|
||||
24
src/Nonemm.App/Configuration/StoredClusterNode.cs
Normal file
24
src/Nonemm.App/Configuration/StoredClusterNode.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Nonemm.App.Configuration;
|
||||
|
||||
/// One cluster node in the operator's list. N1MM keeps the same list in its
|
||||
/// admin database and calls it Favorites.
|
||||
public sealed record StoredClusterNode
|
||||
{
|
||||
public string Name { get; init; } = "";
|
||||
|
||||
public string Host { get; init; } = "";
|
||||
|
||||
public int Port { get; init; } = 7373;
|
||||
|
||||
/// Only the few nodes that ask for one; most take the callsign alone.
|
||||
public string Password { get; init; } = "";
|
||||
|
||||
/// Sent to this node after login, one command per entry.
|
||||
public IReadOnlyList<string> Commands { get; init; } = [];
|
||||
|
||||
/// How the node reads in the favourites list.
|
||||
[JsonIgnore]
|
||||
public string Label => Name.Length > 0 ? $"{Name} — {Host}:{Port}" : $"{Host}:{Port}";
|
||||
}
|
||||
70
src/Nonemm.App/Configuration/StoredSpotFilter.cs
Normal file
70
src/Nonemm.App/Configuration/StoredSpotFilter.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
using Nonemm.Core;
|
||||
using Nonemm.Core.Calls;
|
||||
using Nonemm.Core.Country;
|
||||
using Nonemm.Spotting;
|
||||
|
||||
namespace Nonemm.App.Configuration;
|
||||
|
||||
/// The telnet window's filter settings as they are stored: band and mode names
|
||||
/// rather than the types, so the file keeps working when those change.
|
||||
public sealed record StoredSpotFilter
|
||||
{
|
||||
/// Band names as `Bands` spells them. Empty means every band.
|
||||
public IReadOnlyList<string> Bands { get; init; } = [];
|
||||
|
||||
/// `CW`, `PHONE` or `DIGITAL`. Empty means every mode.
|
||||
public IReadOnlyList<string> Modes { get; init; } = [];
|
||||
|
||||
public bool MyCountryOnly { get; init; }
|
||||
|
||||
public bool MyContinentOnly { get; init; }
|
||||
|
||||
public IReadOnlyList<string> CallAreas { get; init; } = [];
|
||||
|
||||
public IReadOnlyList<string> BlockedSpotters { get; init; } = [];
|
||||
|
||||
public IReadOnlyList<string> BlockedCalls { get; init; } = [];
|
||||
|
||||
public bool ShowBeacons { get; init; } = true;
|
||||
|
||||
public bool RemoveBustedSpots { get; init; }
|
||||
|
||||
public bool OnlyInCallHistory { get; init; }
|
||||
|
||||
/// Spotters whose lines the telnet window paints, so the ones worth
|
||||
/// believing stand out. N1MM takes up to six.
|
||||
public IReadOnlyList<string> PreferredSpotters { get; init; } = [];
|
||||
|
||||
public SpotFilter ToFilter(
|
||||
BandPlan plan,
|
||||
StoredStation station,
|
||||
CountryFile? countries,
|
||||
CallDatabase? calls,
|
||||
CallHistory? history) => new()
|
||||
{
|
||||
Bands = [.. Bands.Select(Core.Bands.Named).OfType<Band>()],
|
||||
Modes = [.. Modes.Select(ModeOf).OfType<ModeCategory>()],
|
||||
Plan = plan,
|
||||
Countries = countries,
|
||||
MyCountry = station.CountryPrefix,
|
||||
MyContinent = station.Continent,
|
||||
MyCountryOnly = MyCountryOnly,
|
||||
MyContinentOnly = MyContinentOnly,
|
||||
CallAreas = CallAreas,
|
||||
BlockedSpotters = BlockedSpotters,
|
||||
BlockedCalls = BlockedCalls,
|
||||
ShowBeacons = ShowBeacons,
|
||||
Calls = calls,
|
||||
RemoveBustedSpots = RemoveBustedSpots,
|
||||
History = history,
|
||||
OnlyInCallHistory = OnlyInCallHistory,
|
||||
};
|
||||
|
||||
private static ModeCategory? ModeOf(string name) => name.ToUpperInvariant() switch
|
||||
{
|
||||
"CW" => ModeCategory.Cw,
|
||||
"PHONE" => ModeCategory.Phone,
|
||||
"DIGITAL" => ModeCategory.Digital,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
24
src/Nonemm.App/Configuration/StoredTelnetButton.cs
Normal file
24
src/Nonemm.App/Configuration/StoredTelnetButton.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
namespace Nonemm.App.Configuration;
|
||||
|
||||
/// One button on the telnet window. The command holds what N1MM's buttons hold:
|
||||
/// the message macros, several commands separated by semicolons, or `{CONN}` and
|
||||
/// the name of a favourite to connect to it.
|
||||
public sealed record StoredTelnetButton
|
||||
{
|
||||
/// What the operator gets before changing anything: the commands a node
|
||||
/// answers on any of the four cluster programs.
|
||||
public static readonly IReadOnlyList<StoredTelnetButton> Default =
|
||||
[
|
||||
new() { Label = "Sh/DX", Command = "sh/dx" },
|
||||
new() { Label = "Sh/DX/20", Command = "sh/dx/20" },
|
||||
new() { Label = "WWV", Command = "sh/wwv" },
|
||||
new() { Label = "Users", Command = "sh/users" },
|
||||
new() { Label = "Nodes", Command = "sh/c/n" },
|
||||
new() { Label = "Help", Command = "help" },
|
||||
new() { Label = "Bye", Command = "bye" },
|
||||
];
|
||||
|
||||
public string Label { get; init; } = "";
|
||||
|
||||
public string Command { get; init; } = "";
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using Nonemm.Core.Calls;
|
||||
using Nonemm.Core.Country;
|
||||
using Nonemm.Spotting;
|
||||
|
||||
namespace Nonemm.App.Configuration;
|
||||
|
||||
@@ -13,6 +14,11 @@ public sealed class SupportFileDownloader
|
||||
public const string CountryFileUrl = "https://www.country-files.com/cty/wl_cty.dat";
|
||||
public const string CallDatabaseUrl = "https://www.supercheckpartial.com/MASTER.SCP";
|
||||
|
||||
/// NG3K's list of telnet cluster nodes. N1MM downloads its list from its
|
||||
/// own web service, which asks the operator to opt in to data collection
|
||||
/// and is N1MM's to run; this is a public page anyone may read.
|
||||
public const string ClusterListUrl = "https://www.ng3k.com/misc/cluster.html";
|
||||
|
||||
private readonly HttpClient http;
|
||||
|
||||
public SupportFileDownloader(HttpClient http) => this.http = http;
|
||||
@@ -23,6 +29,9 @@ public sealed class SupportFileDownloader
|
||||
public Task<string> DownloadCallDatabaseAsync(string path, CancellationToken cancellation = default) =>
|
||||
DownloadAsync(CallDatabaseUrl, path, text => CallDatabase.Parse(text).Count, "callsigns", cancellation);
|
||||
|
||||
public Task<string> DownloadClusterListAsync(string path, CancellationToken cancellation = default) =>
|
||||
DownloadAsync(ClusterListUrl, path, text => ClusterList.Parse(text).Count, "cluster nodes", cancellation);
|
||||
|
||||
private async Task<string> DownloadAsync(
|
||||
string url,
|
||||
string path,
|
||||
|
||||
@@ -28,6 +28,10 @@ public sealed class UserPaths
|
||||
|
||||
public string CallDatabaseFile => Path.Combine(SupportFiles, "MASTER.SCP");
|
||||
|
||||
/// The published list of cluster nodes, kept as the page it was downloaded
|
||||
/// from so a later version can read more out of it.
|
||||
public string ClusterListFile => Path.Combine(SupportFiles, "cluster-list.html");
|
||||
|
||||
public void CreateFolders()
|
||||
{
|
||||
Directory.CreateDirectory(Root);
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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>
|
||||
@@ -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 = "";
|
||||
}
|
||||
}
|
||||
68
src/Nonemm.App/Windows/TelnetWindow.Buttons.cs
Normal file
68
src/Nonemm.App/Windows/TelnetWindow.Buttons.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
232
src/Nonemm.App/Windows/TelnetWindow.Clusters.cs
Normal file
232
src/Nonemm.App/Windows/TelnetWindow.Clusters.cs
Normal 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)];
|
||||
}
|
||||
112
src/Nonemm.App/Windows/TelnetWindow.Filters.cs
Normal file
112
src/Nonemm.App/Windows/TelnetWindow.Filters.cs
Normal 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())];
|
||||
}
|
||||
148
src/Nonemm.App/Windows/TelnetWindow.axaml
Normal file
148
src/Nonemm.App/Windows/TelnetWindow.axaml
Normal 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>
|
||||
291
src/Nonemm.App/Windows/TelnetWindow.axaml.cs
Normal file
291
src/Nonemm.App/Windows/TelnetWindow.axaml.cs
Normal 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();
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -2,41 +2,57 @@ using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Spotting;
|
||||
|
||||
/// The stations on the band, in frequency order. A spot ages out after an hour
|
||||
/// because a bandmap is a picture of the last hour, and a stale spot costs a
|
||||
/// move to an empty frequency.
|
||||
/// The stations on the band, in frequency order. A spot ages out after the
|
||||
/// timeout because a bandmap is a picture of the last hour or so, and a stale
|
||||
/// spot costs a move to an empty frequency.
|
||||
///
|
||||
/// Age is counted from when the spot arrived, not from the time written in it.
|
||||
/// A node with a wrong clock, or one replaying its backlog, would otherwise
|
||||
/// empty the bandmap as fast as it filled it.
|
||||
///
|
||||
/// Spots arrive on the cluster's thread and are read on the window's, so every
|
||||
/// method locks. The `Changed` event is raised outside the lock, on whichever
|
||||
/// thread made the change.
|
||||
public sealed class Bandmap
|
||||
{
|
||||
private readonly Dictionary<string, Spot> spots = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, DateTime> arrived = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly TimeSpan lifetime;
|
||||
private readonly Lock guard = new();
|
||||
private readonly Func<DateTime> clock;
|
||||
|
||||
public Bandmap(TimeSpan? lifetime = null, Func<DateTime>? clock = null)
|
||||
{
|
||||
this.lifetime = lifetime ?? TimeSpan.FromHours(1);
|
||||
Lifetime = lifetime ?? TimeSpan.FromHours(1);
|
||||
this.clock = clock ?? (() => DateTime.UtcNow);
|
||||
}
|
||||
|
||||
/// How long a spot stays on the map. N1MM asks for the same number in
|
||||
/// minutes, on the telnet window's Filters tab.
|
||||
public TimeSpan Lifetime { get; set; }
|
||||
|
||||
public event EventHandler? Changed;
|
||||
|
||||
public void Add(Spot spot)
|
||||
{
|
||||
string key = Key(spot);
|
||||
spots[key] = spot;
|
||||
arrived[key] = clock();
|
||||
lock (guard)
|
||||
{
|
||||
spots[key] = spot;
|
||||
arrived[key] = clock();
|
||||
}
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
public void Remove(Callsign call, Band band)
|
||||
{
|
||||
string key = $"{call.Text}|{band.Name}";
|
||||
arrived.Remove(key);
|
||||
if (spots.Remove(key))
|
||||
bool removed;
|
||||
lock (guard)
|
||||
{
|
||||
arrived.Remove(key);
|
||||
removed = spots.Remove(key);
|
||||
}
|
||||
if (removed)
|
||||
{
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
@@ -44,46 +60,64 @@ public sealed class Bandmap
|
||||
|
||||
public void DropOlderThan(DateTime nowUtc)
|
||||
{
|
||||
List<string> stale = spots
|
||||
.Where(pair => nowUtc - arrived.GetValueOrDefault(pair.Key, nowUtc) > lifetime)
|
||||
.Select(pair => pair.Key)
|
||||
.ToList();
|
||||
foreach (string key in stale)
|
||||
int dropped;
|
||||
lock (guard)
|
||||
{
|
||||
spots.Remove(key);
|
||||
arrived.Remove(key);
|
||||
List<string> stale = spots
|
||||
.Where(pair => nowUtc - arrived.GetValueOrDefault(pair.Key, nowUtc) > Lifetime)
|
||||
.Select(pair => pair.Key)
|
||||
.ToList();
|
||||
foreach (string key in stale)
|
||||
{
|
||||
spots.Remove(key);
|
||||
arrived.Remove(key);
|
||||
}
|
||||
dropped = stale.Count;
|
||||
}
|
||||
if (stale.Count > 0)
|
||||
if (dropped > 0)
|
||||
{
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<Spot> On(Band band) =>
|
||||
spots.Values
|
||||
.Where(s => s.Band == band)
|
||||
.OrderBy(s => s.Frequency.Hertz)
|
||||
.ToList();
|
||||
public IReadOnlyList<Spot> On(Band band)
|
||||
{
|
||||
lock (guard)
|
||||
{
|
||||
return spots.Values
|
||||
.Where(s => s.Band == band)
|
||||
.OrderBy(s => s.Frequency.Hertz)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<Spot> All() =>
|
||||
spots.Values.OrderBy(s => s.Frequency.Hertz).ToList();
|
||||
public IReadOnlyList<Spot> All()
|
||||
{
|
||||
lock (guard)
|
||||
{
|
||||
return spots.Values.OrderBy(s => s.Frequency.Hertz).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// The spot nearest the frequency, within the window either side. Used when
|
||||
/// the radio lands on a spot so the entry window can fill the call in.
|
||||
public Spot? Near(Frequency frequency, Frequency window)
|
||||
{
|
||||
Spot? best = null;
|
||||
long bestDistance = long.MaxValue;
|
||||
foreach (Spot spot in spots.Values)
|
||||
lock (guard)
|
||||
{
|
||||
long distance = Math.Abs(spot.Frequency.Hertz - frequency.Hertz);
|
||||
if (distance <= window.Hertz && distance < bestDistance)
|
||||
Spot? best = null;
|
||||
long bestDistance = long.MaxValue;
|
||||
foreach (Spot spot in spots.Values)
|
||||
{
|
||||
best = spot;
|
||||
bestDistance = distance;
|
||||
long distance = Math.Abs(spot.Frequency.Hertz - frequency.Hertz);
|
||||
if (distance <= window.Hertz && distance < bestDistance)
|
||||
{
|
||||
best = spot;
|
||||
bestDistance = distance;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/// One spot per station per band: a station that moves within the band keeps
|
||||
|
||||
@@ -6,27 +6,30 @@ using Nonemm.Spotting.Telnet;
|
||||
namespace Nonemm.Spotting;
|
||||
|
||||
/// A DX cluster node over telnet. Spots go to the bandmap; the rest of the
|
||||
/// node's traffic goes to the packet window as it arrived, because filters are
|
||||
/// node's traffic goes to the telnet window as it arrived, because filters are
|
||||
/// the node's business and its replies are for the operator to read.
|
||||
public sealed class ClusterClient : IDisposable
|
||||
{
|
||||
/// How much traffic is kept for a window that opens after the connection
|
||||
/// did, so it does not start blank.
|
||||
private const int LinesRemembered = 200;
|
||||
|
||||
/// N1MM waits this long for a login prompt and then sends the call anyway.
|
||||
private static readonly TimeSpan LoginDeadline = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// How long to wait for a password prompt before deciding there is none.
|
||||
private static readonly TimeSpan PasswordDeadline = TimeSpan.FromSeconds(3);
|
||||
|
||||
/// Nodes drop a connection that has said nothing for a quarter of an hour.
|
||||
private static readonly TimeSpan KeepAliveInterval = TimeSpan.FromMinutes(4);
|
||||
|
||||
private readonly string host;
|
||||
private readonly int port;
|
||||
private readonly string callsign;
|
||||
private readonly string password;
|
||||
private readonly IReadOnlyList<string> commandsAfterLogin;
|
||||
private readonly bool autoLogon;
|
||||
private readonly TimeSpan keepAliveInterval;
|
||||
private readonly TimeSpan retryInterval;
|
||||
private readonly CancellationTokenSource stopping = new();
|
||||
private readonly LineAssembler lines = new();
|
||||
private readonly Queue<ClusterLine> recent = new();
|
||||
private readonly Lock guard = new();
|
||||
|
||||
private TcpClient? client;
|
||||
private TelnetStream? telnet;
|
||||
@@ -41,13 +44,18 @@ public sealed class ClusterClient : IDisposable
|
||||
string callsign,
|
||||
IReadOnlyList<string>? commandsAfterLogin = null,
|
||||
string password = "",
|
||||
TimeSpan? retryInterval = null)
|
||||
TimeSpan? retryInterval = null,
|
||||
bool autoLogon = true,
|
||||
TimeSpan? keepAliveInterval = null)
|
||||
{
|
||||
this.host = host;
|
||||
this.port = port;
|
||||
Host = host;
|
||||
Port = port;
|
||||
this.callsign = callsign;
|
||||
this.password = password;
|
||||
this.commandsAfterLogin = commandsAfterLogin ?? [];
|
||||
this.autoLogon = autoLogon;
|
||||
// nodes drop a connection that has said nothing for a quarter of an hour
|
||||
this.keepAliveInterval = keepAliveInterval ?? TimeSpan.FromMinutes(4);
|
||||
this.retryInterval = retryInterval ?? TimeSpan.FromSeconds(10);
|
||||
}
|
||||
|
||||
@@ -58,14 +66,34 @@ public sealed class ClusterClient : IDisposable
|
||||
Done,
|
||||
}
|
||||
|
||||
public string Host { get; }
|
||||
|
||||
public int Port { get; }
|
||||
|
||||
public bool IsConnected { get; private set; }
|
||||
|
||||
public event EventHandler<Spot>? SpotArrived;
|
||||
|
||||
public event EventHandler<string>? LineArrived;
|
||||
|
||||
/// What went out to the node, so the telnet window can show the login and
|
||||
/// the commands beside the node's answers.
|
||||
public event EventHandler<string>? LineSent;
|
||||
|
||||
public event EventHandler<bool>? ConnectionChanged;
|
||||
|
||||
/// The traffic so far, oldest first, for a window that has just opened.
|
||||
public IReadOnlyList<ClusterLine> Recent
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (guard)
|
||||
{
|
||||
return [.. recent];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Start() => loop ??= Task.Run(() => RunAsync(stopping.Token));
|
||||
|
||||
public async Task SendAsync(string line, CancellationToken cancellation = default)
|
||||
@@ -77,6 +105,11 @@ public sealed class ClusterClient : IDisposable
|
||||
try
|
||||
{
|
||||
await telnet.WriteLineAsync(line, cancellation).ConfigureAwait(false);
|
||||
if (line.Length > 0)
|
||||
{
|
||||
Remember(line, sent: true);
|
||||
LineSent?.Invoke(this, line);
|
||||
}
|
||||
}
|
||||
catch (Exception e) when (e is IOException or SocketException or ObjectDisposedException)
|
||||
{
|
||||
@@ -100,27 +133,38 @@ public sealed class ClusterClient : IDisposable
|
||||
{
|
||||
stopping.Cancel();
|
||||
client?.Dispose();
|
||||
stopping.Dispose();
|
||||
}
|
||||
|
||||
private async Task RunAsync(CancellationToken cancellation)
|
||||
{
|
||||
while (!cancellation.IsCancellationRequested)
|
||||
if (Host.Length == 0)
|
||||
{
|
||||
try
|
||||
Notice("*** no cluster node is configured");
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
while (!cancellation.IsCancellationRequested)
|
||||
{
|
||||
await SessionAsync(cancellation).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await SessionAsync(cancellation).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception e) when (
|
||||
e is IOException or SocketException or ArgumentException or ObjectDisposedException)
|
||||
{
|
||||
if (cancellation.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Notice($"*** {Host}:{Port} {e.Message}");
|
||||
}
|
||||
SetConnected(false);
|
||||
await Task.Delay(retryInterval, cancellation).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception e) when (e is IOException or SocketException)
|
||||
{
|
||||
LineArrived?.Invoke(this, $"*** {host}:{port} {e.Message}");
|
||||
}
|
||||
SetConnected(false);
|
||||
await Task.Delay(retryInterval, cancellation).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,10 +172,10 @@ public sealed class ClusterClient : IDisposable
|
||||
{
|
||||
client?.Dispose();
|
||||
client = new TcpClient();
|
||||
await client.ConnectAsync(host, port, cancellation).ConfigureAwait(false);
|
||||
await client.ConnectAsync(Host, Port, cancellation).ConfigureAwait(false);
|
||||
telnet = new TelnetStream(client.GetStream());
|
||||
lines.Clear();
|
||||
login = Login.WaitingForCallsign;
|
||||
login = autoLogon ? Login.WaitingForCallsign : Login.Done;
|
||||
deadline = DateTime.UtcNow + LoginDeadline;
|
||||
lastHeard = DateTime.UtcNow;
|
||||
SetConnected(true);
|
||||
@@ -175,6 +219,7 @@ public sealed class ClusterClient : IDisposable
|
||||
|
||||
private async Task TakeLineAsync(string line, CancellationToken cancellation)
|
||||
{
|
||||
Remember(line, sent: false);
|
||||
LineArrived?.Invoke(this, line);
|
||||
Spot? spot = SpotLine.Parse(line, DateTime.UtcNow);
|
||||
if (spot is not null)
|
||||
@@ -234,7 +279,7 @@ public sealed class ClusterClient : IDisposable
|
||||
{
|
||||
await FinishLoginAsync(cancellation).ConfigureAwait(false);
|
||||
}
|
||||
if (login == Login.Done && now - lastHeard > KeepAliveInterval)
|
||||
if (login == Login.Done && now - lastHeard > keepAliveInterval)
|
||||
{
|
||||
lastHeard = now;
|
||||
await SendAsync("", cancellation).ConfigureAwait(false);
|
||||
@@ -242,6 +287,24 @@ public sealed class ClusterClient : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private void Notice(string line)
|
||||
{
|
||||
Remember(line, sent: false);
|
||||
LineArrived?.Invoke(this, line);
|
||||
}
|
||||
|
||||
private void Remember(string text, bool sent)
|
||||
{
|
||||
lock (guard)
|
||||
{
|
||||
recent.Enqueue(new ClusterLine(text, sent));
|
||||
while (recent.Count > LinesRemembered)
|
||||
{
|
||||
recent.Dequeue();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task Ignoring(Task task)
|
||||
{
|
||||
try
|
||||
|
||||
5
src/Nonemm.Spotting/ClusterLine.cs
Normal file
5
src/Nonemm.Spotting/ClusterLine.cs
Normal file
@@ -0,0 +1,5 @@
|
||||
namespace Nonemm.Spotting;
|
||||
|
||||
/// One line of the node's traffic. `WasSent` marks the lines that went out, so
|
||||
/// the telnet window can tell the operator's commands from the node's answers.
|
||||
public sealed record ClusterLine(string Text, bool WasSent);
|
||||
66
src/Nonemm.Spotting/ClusterList.cs
Normal file
66
src/Nonemm.Spotting/ClusterList.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Nonemm.Spotting;
|
||||
|
||||
/// Reads a published page of cluster nodes. N1MM downloads its list from its
|
||||
/// own web service; this reads the public list at NG3K, which is a table with
|
||||
/// one `telnet://host:port` link per node.
|
||||
///
|
||||
/// The reading is deliberately loose: any page that holds `telnet://` links
|
||||
/// gives up its nodes, and the surrounding table only supplies the name and the
|
||||
/// note beside it.
|
||||
public static class ClusterList
|
||||
{
|
||||
/// A node that names no port is a plain telnet node on port 23.
|
||||
private const int DefaultPort = 23;
|
||||
|
||||
private static readonly Regex Rows =
|
||||
new("<tr[^>]*>(.*?)</tr>", RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
||||
|
||||
private static readonly Regex Cells =
|
||||
new("<td[^>]*>(.*?)</td>", RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
||||
|
||||
private static readonly Regex Address =
|
||||
new(@"telnet://([A-Za-z0-9][A-Za-z0-9.\-]*)(?:[:\s]+([0-9]{1,5}))?", RegexOptions.IgnoreCase);
|
||||
|
||||
private static readonly Regex Tags = new("<[^>]*>", RegexOptions.Singleline);
|
||||
|
||||
/// Throws `FormatException` when the page holds no nodes, so a site that
|
||||
/// answers with an apology page cannot replace a good list.
|
||||
public static IReadOnlyList<ClusterNode> Parse(string html)
|
||||
{
|
||||
List<ClusterNode> found = [];
|
||||
HashSet<string> seen = new(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (Match row in Rows.Matches(html))
|
||||
{
|
||||
string[] cells = [.. Cells.Matches(row.Groups[1].Value).Select(c => Text(c.Groups[1].Value))];
|
||||
if (Address.Match(row.Groups[1].Value) is not { Success: true } address)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
string host = address.Groups[1].Value;
|
||||
int port = address.Groups[2].Success
|
||||
? int.Parse(address.Groups[2].Value, System.Globalization.CultureInfo.InvariantCulture)
|
||||
: DefaultPort;
|
||||
if (port is < 1 or > 65535 || !seen.Add($"{host}:{port}"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
found.Add(new ClusterNode(
|
||||
cells.Length > 0 && cells[0].Length > 0 ? cells[0] : host,
|
||||
host,
|
||||
port,
|
||||
cells.Length > 1 ? cells[^1] : ""));
|
||||
}
|
||||
if (found.Count == 0)
|
||||
{
|
||||
throw new FormatException("this page holds no telnet cluster nodes");
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
private static string Text(string html) =>
|
||||
string.Join(' ', WebUtility.HtmlDecode(Tags.Replace(html, " "))
|
||||
.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
|
||||
}
|
||||
9
src/Nonemm.Spotting/ClusterNode.cs
Normal file
9
src/Nonemm.Spotting/ClusterNode.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace Nonemm.Spotting;
|
||||
|
||||
/// One node from a published list of clusters. `Description` is whatever the
|
||||
/// page says about it — where it is, which cluster program it runs, whether it
|
||||
/// carries skimmer spots.
|
||||
public sealed record ClusterNode(string Name, string Host, int Port, string Description = "")
|
||||
{
|
||||
public string Address => $"{Host}:{Port}";
|
||||
}
|
||||
164
src/Nonemm.Spotting/SpotFilter.cs
Normal file
164
src/Nonemm.Spotting/SpotFilter.cs
Normal file
@@ -0,0 +1,164 @@
|
||||
using Nonemm.Core;
|
||||
using Nonemm.Core.Calls;
|
||||
using Nonemm.Core.Country;
|
||||
|
||||
namespace Nonemm.Spotting;
|
||||
|
||||
/// Which cluster spots reach the bandmap. N1MM puts the same choices on the
|
||||
/// telnet window's Filters tab. Nothing is set out of the box, and then every
|
||||
/// spot passes.
|
||||
///
|
||||
/// Only cluster spots are filtered. A station the operator spotted by hand, or
|
||||
/// one taken from the log, is always shown.
|
||||
public sealed record SpotFilter
|
||||
{
|
||||
/// Empty means every band.
|
||||
public IReadOnlyList<Band> Bands { get; init; } = [];
|
||||
|
||||
/// Empty means every mode. The mode comes from the band plan, because a
|
||||
/// spot line does not carry one.
|
||||
public IReadOnlyList<ModeCategory> Modes { get; init; } = [];
|
||||
|
||||
public BandPlan Plan { get; init; } = BandPlan.Default;
|
||||
|
||||
/// Needed for the country and continent filters. Without it those two let
|
||||
/// everything through, the way the rest of the program works without a
|
||||
/// country file.
|
||||
public CountryFile? Countries { get; init; }
|
||||
|
||||
public string MyCountry { get; init; } = "";
|
||||
|
||||
public string MyContinent { get; init; } = "";
|
||||
|
||||
public bool MyCountryOnly { get; init; }
|
||||
|
||||
public bool MyContinentOnly { get; init; }
|
||||
|
||||
/// Prefixes a spotter's call may start with, such as `K1 VE3`.
|
||||
public IReadOnlyList<string> CallAreas { get; init; } = [];
|
||||
|
||||
public IReadOnlyList<string> BlockedSpotters { get; init; } = [];
|
||||
|
||||
public IReadOnlyList<string> BlockedCalls { get; init; } = [];
|
||||
|
||||
public bool ShowBeacons { get; init; } = true;
|
||||
|
||||
/// The callsign database, for the busted-spot check. Without it nothing is
|
||||
/// called busted.
|
||||
public CallDatabase? Calls { get; init; }
|
||||
|
||||
/// Drops a spotted call the database has never heard when it is one
|
||||
/// character away from a call the database does know. That is a miscopy,
|
||||
/// not a new station. A call nothing resembles is kept, because that is
|
||||
/// what a new station looks like.
|
||||
public bool RemoveBustedSpots { get; init; }
|
||||
|
||||
/// The call history file for this contest, for the choice below.
|
||||
public CallHistory? History { get; init; }
|
||||
|
||||
/// Keeps only the stations the call history file lists, which for a state
|
||||
/// QSO party or a club contest is the stations worth working.
|
||||
public bool OnlyInCallHistory { get; init; }
|
||||
|
||||
public bool Accepts(Spot spot)
|
||||
{
|
||||
if (spot.Source != SpotSource.Cluster)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (Bands.Count > 0 && (spot.Band is not { } band || !Bands.Contains(band)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (Modes.Count > 0 && (Plan.ModeAt(spot.Frequency) is not { } mode || !Modes.Contains(mode)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (!ShowBeacons && IsBeacon(spot.Comment))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (Holds(BlockedCalls, spot.Call.Text) || Holds(BlockedSpotters, spot.Spotter))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (OnlyInCallHistory && History is not null && History.Find(spot.Call.Text) is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (RemoveBustedSpots && LooksBusted(spot.Call.Text))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return AcceptsSpotter(spot.Spotter);
|
||||
}
|
||||
|
||||
/// The three "include spots only originating in" choices are inclusive: a
|
||||
/// spot passes when it matches any of the ones that are on, and all spots
|
||||
/// pass while none of them is. Without a country file the country and
|
||||
/// continent choices cannot be judged, so they are left out.
|
||||
private bool AcceptsSpotter(string spotter)
|
||||
{
|
||||
bool byCallArea = CallAreas.Count > 0;
|
||||
if (Countries is not { } countries || !(MyCountryOnly || MyContinentOnly))
|
||||
{
|
||||
return !byCallArea || StartsWithAny(spotter, CallAreas);
|
||||
}
|
||||
if (byCallArea && StartsWithAny(spotter, CallAreas))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (countries.Find(spotter) is not { } home)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return (MyCountryOnly && Same(home.Entity.PrimaryPrefix, MyCountry))
|
||||
|| (MyContinentOnly && Same(home.Continent, MyContinent));
|
||||
}
|
||||
|
||||
private bool LooksBusted(string call)
|
||||
{
|
||||
if (Calls is not { Count: > 0 } database || database.Holds(call))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return OneCharacterOff(call).Any(database.Holds);
|
||||
}
|
||||
|
||||
/// Every call one character away: one deleted, one changed, one inserted.
|
||||
private static IEnumerable<string> OneCharacterOff(string call)
|
||||
{
|
||||
const string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/";
|
||||
for (int at = 0; at < call.Length; at++)
|
||||
{
|
||||
yield return call.Remove(at, 1);
|
||||
foreach (char letter in alphabet)
|
||||
{
|
||||
if (letter != call[at])
|
||||
{
|
||||
yield return string.Concat(call.AsSpan(0, at), letter.ToString(), call.AsSpan(at + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (int at = 0; at <= call.Length; at++)
|
||||
{
|
||||
foreach (char letter in alphabet)
|
||||
{
|
||||
yield return string.Concat(call.AsSpan(0, at), letter.ToString(), call.AsSpan(at));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsBeacon(string comment) =>
|
||||
comment.Split(' ', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Any(word => Same(word, "BCN") || Same(word, "BEACON"));
|
||||
|
||||
private static bool Holds(IReadOnlyList<string> calls, string call) =>
|
||||
calls.Any(one => Same(one, call));
|
||||
|
||||
private static bool StartsWithAny(string call, IReadOnlyList<string> prefixes) =>
|
||||
prefixes.Any(prefix =>
|
||||
prefix.Length > 0 && call.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static bool Same(string a, string b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
22
src/Nonemm.Spotting/SpotJitter.cs
Normal file
22
src/Nonemm.Spotting/SpotJitter.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Spotting;
|
||||
|
||||
/// N1MM's "randomise incoming spot frequencies". A CW spot is moved thirty or
|
||||
/// sixty hertz either way, so the operator has to find the station by ear
|
||||
/// rather than land exactly on it. Some contest categories are entered that
|
||||
/// way on purpose.
|
||||
public static class SpotJitter
|
||||
{
|
||||
private static readonly int[] Offsets = [-60, -30, 30, 60];
|
||||
|
||||
/// Phone and digital spots are left alone: they are wide enough that thirty
|
||||
/// hertz changes nothing.
|
||||
public static Spot Shifted(Spot spot, BandPlan plan, Random random) =>
|
||||
plan.ModeAt(spot.Frequency) == ModeCategory.Cw
|
||||
? spot with
|
||||
{
|
||||
Frequency = Frequency.FromHertz(spot.Frequency.Hertz + Offsets[random.Next(Offsets.Length)]),
|
||||
}
|
||||
: spot;
|
||||
}
|
||||
Reference in New Issue
Block a user