Speak telnet properly to the cluster node
The old client read the socket with a StreamReader, so the option negotiation a node sends on connect landed in the text as control bytes and made a mess of the first lines. TelnetStream now answers it: it agrees to ECHO and SUPPRESS-GO-AHEAD, refuses everything else, swallows subnegotiations, and tracks what it already answered so the two ends do not reply to each other for ever. A command split across two reads is still understood, because a socket hands over whatever has turned up rather than whole messages. LineAssembler keeps the tail that has no line ending yet. Nodes write their login prompt as "login: " with nothing after it, so the old ReadLineAsync sat waiting for a line that never came and only got through because the greeting happened to mention a matching word. Login now follows N1MM: it looks for LOGON, ENTER CALL, LOG IN and the rest, and sends the callsign anyway after ten seconds if no prompt turns up. A password is sent when the node asks for one and the operator configured one. The commands go out after that, not straight after the call. A connection that has heard nothing for four minutes gets a blank line so the node does not drop it as idle. The old prompt check matched "call" anywhere in a line, which any spot comment could trigger. SendSpotAsync sends the node's dx command. Alt+P, or Edit / Spot It, spots the call being typed at the current frequency, or the last contact logged when nothing is typed, and puts it on our own bandmap without waiting for it to come back round from the node. The spot parser now takes a line with no colon after the spotter, and finds the time when DXSpider has put the spotter's grid after it. The cluster tests run over a real socket against a node fake that sends the negotiation, the prompt and spot lines. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -237,7 +237,8 @@ public sealed class AppSession : IDisposable
|
||||
Settings.ClusterHost,
|
||||
Settings.ClusterPort,
|
||||
Settings.Station.Callsign,
|
||||
Settings.ClusterCommands);
|
||||
Settings.ClusterCommands,
|
||||
Settings.ClusterPassword);
|
||||
cluster.SpotArrived += (_, spot) => Bandmap.Add(spot);
|
||||
cluster.ConnectionChanged += (_, _) => Changed?.Invoke(this, EventArgs.Empty);
|
||||
cluster.Start();
|
||||
|
||||
@@ -16,6 +16,9 @@ public sealed record Settings
|
||||
|
||||
public int ClusterPort { get; init; } = 7373;
|
||||
|
||||
/// Only the few nodes that ask for one; most take the callsign alone.
|
||||
public string ClusterPassword { get; init; } = "";
|
||||
|
||||
public IReadOnlyList<string> ClusterCommands { get; init; } = [];
|
||||
|
||||
public string RigctldHost { get; init; } = "127.0.0.1";
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
<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" />
|
||||
|
||||
@@ -16,6 +16,7 @@ public sealed partial class ClusterDialog : Window
|
||||
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;
|
||||
}
|
||||
@@ -25,6 +26,7 @@ public sealed partial class ClusterDialog : Window
|
||||
{
|
||||
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())
|
||||
|
||||
@@ -6,6 +6,7 @@ using Nonemm.App.Dialogs;
|
||||
using Nonemm.Core;
|
||||
using Nonemm.Formats.Adif;
|
||||
using Nonemm.Formats.Cabrillo;
|
||||
using Nonemm.Spotting;
|
||||
using Nonemm.Storage;
|
||||
|
||||
namespace Nonemm.App.Windows;
|
||||
@@ -186,6 +187,43 @@ public sealed partial class EntryWindow
|
||||
await new EditContactDialog(Logging, Logging.Log.Qsos[^1].Id).ShowDialog(this);
|
||||
}
|
||||
|
||||
/// Puts the call being typed on the cluster, or the last one logged when
|
||||
/// nothing is typed. That is what N1MM's Spot It button does.
|
||||
private async void OnSpotIt(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (Logging is null)
|
||||
{
|
||||
Status("no contest is open");
|
||||
return;
|
||||
}
|
||||
if (session.Cluster is not { IsConnected: true } cluster)
|
||||
{
|
||||
Status("not connected to a cluster node");
|
||||
return;
|
||||
}
|
||||
string typed = Logging.Entry.Call.Trim();
|
||||
Callsign call;
|
||||
Frequency where;
|
||||
if (typed.Length > 0)
|
||||
{
|
||||
call = Callsign.Parse(typed);
|
||||
where = Logging.Frequency;
|
||||
}
|
||||
else if (Logging.Log.Qsos.Count > 0)
|
||||
{
|
||||
call = Logging.Log.Qsos[^1].Call;
|
||||
where = Logging.Log.Qsos[^1].Frequency;
|
||||
}
|
||||
else
|
||||
{
|
||||
Status("nothing to spot");
|
||||
return;
|
||||
}
|
||||
await cluster.SendSpotAsync(where, call);
|
||||
session.Bandmap.Add(new Spot(call, where, DateTime.UtcNow, SpotSource.Operator));
|
||||
Status($"{call.Text} spotted on {where.Kilohertz:0.0}");
|
||||
}
|
||||
|
||||
private void OnShowLog(object? sender, RoutedEventArgs e) => Show(() => new LogWindow(session));
|
||||
|
||||
private void OnShowCheck(object? sender, RoutedEventArgs e) => Show(() => new CheckWindow(session, () => Logging?.Entry.Call ?? ""));
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
</MenuItem>
|
||||
<MenuItem Header="_Edit">
|
||||
<MenuItem Header="Edit _Last Contact…" Click="OnEditLastContact" InputGesture="Ctrl+Y" />
|
||||
<MenuItem Header="S_pot It" Click="OnSpotIt" InputGesture="Alt+P" />
|
||||
</MenuItem>
|
||||
<MenuItem Header="_View">
|
||||
<MenuItem Header="_Log" Click="OnShowLog" />
|
||||
|
||||
@@ -183,6 +183,10 @@ public sealed partial class EntryWindow : Window
|
||||
e.Handled = true;
|
||||
OnEditLastContact(this, new RoutedEventArgs());
|
||||
break;
|
||||
case Key.P when e.KeyModifiers.HasFlag(KeyModifiers.Alt):
|
||||
e.Handled = true;
|
||||
OnSpotIt(this, new RoutedEventArgs());
|
||||
break;
|
||||
case Key.OemQuestion when e.KeyModifiers.HasFlag(KeyModifiers.Control):
|
||||
e.Handled = true;
|
||||
ToggleRun();
|
||||
|
||||
Reference in New Issue
Block a user