Show the cluster's solar weather in a bandmap status bar
The reader follows N1MM's rules for WWV and WCY lines and the sh/wwv table. The bar is hidden until the cluster has sent some. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -914,6 +914,7 @@ public sealed partial class AppSession : IDisposable
|
|||||||
public void ConnectCluster()
|
public void ConnectCluster()
|
||||||
{
|
{
|
||||||
cluster?.Dispose();
|
cluster?.Dispose();
|
||||||
|
SetSolar("");
|
||||||
cluster = new ClusterClient(
|
cluster = new ClusterClient(
|
||||||
Settings.ClusterHost,
|
Settings.ClusterHost,
|
||||||
Settings.ClusterPort,
|
Settings.ClusterPort,
|
||||||
@@ -923,14 +924,28 @@ public sealed partial class AppSession : IDisposable
|
|||||||
autoLogon: Settings.ClusterAutoLogon,
|
autoLogon: Settings.ClusterAutoLogon,
|
||||||
keepAliveInterval: TimeSpan.FromMinutes(Math.Max(1, Settings.ClusterKeepAliveMinutes)));
|
keepAliveInterval: TimeSpan.FromMinutes(Math.Max(1, Settings.ClusterKeepAliveMinutes)));
|
||||||
cluster.SpotArrived += (_, spot) => TakeSpot(spot);
|
cluster.SpotArrived += (_, spot) => TakeSpot(spot);
|
||||||
|
cluster.SolarArrived += (_, text) => SetSolar(text);
|
||||||
cluster.ConnectionChanged += (_, _) => Changed?.Invoke(this, EventArgs.Empty);
|
cluster.ConnectionChanged += (_, _) => Changed?.Invoke(this, EventArgs.Empty);
|
||||||
cluster.Start();
|
cluster.Start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The latest solar weather the cluster sent, or empty when it has sent
|
||||||
|
/// none on this connection.
|
||||||
|
public string Solar { get; private set; } = "";
|
||||||
|
|
||||||
|
public event EventHandler? SolarChanged;
|
||||||
|
|
||||||
|
private void SetSolar(string text)
|
||||||
|
{
|
||||||
|
Solar = text;
|
||||||
|
SolarChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
public void DisconnectCluster()
|
public void DisconnectCluster()
|
||||||
{
|
{
|
||||||
cluster?.Dispose();
|
cluster?.Dispose();
|
||||||
cluster = null;
|
cluster = null;
|
||||||
|
SetSolar("");
|
||||||
Changed?.Invoke(this, EventArgs.Empty);
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
<ComboBox Name="SpanBox" FontSize="11" Width="96" />
|
<ComboBox Name="SpanBox" FontSize="11" Width="96" />
|
||||||
<CheckBox Name="CentreBox" Content="Centre on VFO" FontSize="11" Margin="8,0,0,0" />
|
<CheckBox Name="CentreBox" Content="Centre on VFO" FontSize="11" Margin="8,0,0,0" />
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
<Border Name="SolarBar" DockPanel.Dock="Bottom" IsVisible="False" Padding="6,2"
|
||||||
|
BorderThickness="0,1,0,0" BorderBrush="Gray">
|
||||||
|
<TextBlock Name="SolarText" FontSize="11" TextWrapping="Wrap" />
|
||||||
|
</Border>
|
||||||
<local:BandmapView Name="View" />
|
<local:BandmapView Name="View" />
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
</local:RefreshableWindow>
|
</local:RefreshableWindow>
|
||||||
|
|||||||
@@ -76,9 +76,21 @@ public sealed partial class BandmapWindow : RefreshableWindow
|
|||||||
};
|
};
|
||||||
View.FrequencyPicked += (_, frequency) => tune(frequency, "");
|
View.FrequencyPicked += (_, frequency) => tune(frequency, "");
|
||||||
session.Bandmap.Changed += (_, _) => Dispatcher.UIThread.Post(Refresh);
|
session.Bandmap.Changed += (_, _) => Dispatcher.UIThread.Post(Refresh);
|
||||||
|
EventHandler solarChanged = (_, _) => Dispatcher.UIThread.Post(ShowSolar);
|
||||||
|
session.SolarChanged += solarChanged;
|
||||||
|
Closed += (_, _) => session.SolarChanged -= solarChanged;
|
||||||
|
ShowSolar();
|
||||||
Refresh();
|
Refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The status bar holds the solar weather the cluster sent, and is hidden
|
||||||
|
/// while there is none.
|
||||||
|
private void ShowSolar()
|
||||||
|
{
|
||||||
|
SolarText.Text = session.Solar;
|
||||||
|
SolarBar.IsVisible = session.Solar.Length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
private void SaveView()
|
private void SaveView()
|
||||||
{
|
{
|
||||||
session.Save(session.Settings with
|
session.Save(session.Settings with
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ public sealed class ClusterClient : IDisposable
|
|||||||
private readonly CancellationTokenSource stopping = new();
|
private readonly CancellationTokenSource stopping = new();
|
||||||
private readonly LineAssembler lines = new();
|
private readonly LineAssembler lines = new();
|
||||||
private readonly Queue<ClusterLine> recent = new();
|
private readonly Queue<ClusterLine> recent = new();
|
||||||
|
private readonly SolarReader solar = new();
|
||||||
private readonly Lock guard = new();
|
private readonly Lock guard = new();
|
||||||
|
|
||||||
private TcpClient? client;
|
private TcpClient? client;
|
||||||
@@ -80,6 +81,9 @@ public sealed class ClusterClient : IDisposable
|
|||||||
|
|
||||||
public event EventHandler<string>? LineArrived;
|
public event EventHandler<string>? LineArrived;
|
||||||
|
|
||||||
|
/// The node sent solar weather, as the text to show.
|
||||||
|
public event EventHandler<string>? SolarArrived;
|
||||||
|
|
||||||
/// What went out to the node, so the telnet window can show the login and
|
/// What went out to the node, so the telnet window can show the login and
|
||||||
/// the commands beside the node's answers.
|
/// the commands beside the node's answers.
|
||||||
public event EventHandler<string>? LineSent;
|
public event EventHandler<string>? LineSent;
|
||||||
@@ -237,6 +241,10 @@ public sealed class ClusterClient : IDisposable
|
|||||||
Kind = kind;
|
Kind = kind;
|
||||||
ConnectionChanged?.Invoke(this, IsConnected);
|
ConnectionChanged?.Invoke(this, IsConnected);
|
||||||
}
|
}
|
||||||
|
if (solar.Read(line) is { } weather)
|
||||||
|
{
|
||||||
|
SolarArrived?.Invoke(this, weather);
|
||||||
|
}
|
||||||
Spot? spot = SpotLine.Parse(line, DateTime.UtcNow);
|
Spot? spot = SpotLine.Parse(line, DateTime.UtcNow);
|
||||||
if (spot is not null)
|
if (spot is not null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Nonemm.Spotting;
|
||||||
|
|
||||||
|
/// Picks the solar weather out of a node's traffic, as N1MM does for its Info
|
||||||
|
/// window. A node sends it as one line, such as `WWV de VE7CC <18Z> : SFI=140,
|
||||||
|
/// A=5, K=2`, or after `sh/wwv` as a header line (`Date Hour SFI A K ...`)
|
||||||
|
/// and a line of numbers under it. The text is kept as the node sent it,
|
||||||
|
/// without the extra blanks.
|
||||||
|
public sealed partial class SolarReader
|
||||||
|
{
|
||||||
|
private bool afterHeader;
|
||||||
|
|
||||||
|
/// The text to show when the line carries solar weather, otherwise null.
|
||||||
|
/// A line has to be passed in even when it is not solar weather, because
|
||||||
|
/// a spot between the header and the numbers cancels the header.
|
||||||
|
public string? Read(string line)
|
||||||
|
{
|
||||||
|
string text = Collapse(line);
|
||||||
|
if (text.Length == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
string upper = text.ToUpperInvariant();
|
||||||
|
bool isFromNode = upper.Contains(" DE ", StringComparison.Ordinal);
|
||||||
|
if (upper.StartsWith("DX", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
afterHeader = false;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (upper.StartsWith("WWV:", StringComparison.Ordinal)
|
||||||
|
|| upper.StartsWith("WCY:", StringComparison.Ordinal)
|
||||||
|
|| upper.Contains("WWV DE", StringComparison.Ordinal)
|
||||||
|
|| upper.Contains("WCY DE", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
if (upper.StartsWith("DATE HOUR SFI", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
afterHeader = true;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (afterHeader && !isFromNode)
|
||||||
|
{
|
||||||
|
return "SFI/A/K: " + text;
|
||||||
|
}
|
||||||
|
if (isFromNode)
|
||||||
|
{
|
||||||
|
afterHeader = false;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Control characters out, runs of blanks down to one.
|
||||||
|
private static string Collapse(string line) =>
|
||||||
|
Blanks().Replace(new string([.. line.Where(c => !char.IsControl(c) || c == '\t')]), " ").Trim();
|
||||||
|
|
||||||
|
[GeneratedRegex(@"\s+")]
|
||||||
|
private static partial Regex Blanks();
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
namespace Nonemm.Spotting.Tests;
|
||||||
|
|
||||||
|
public class SolarReaderTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("WWV de VE7CC <18Z> : SFI=140, A=5, K=2, No Storms -> No Storms\a\a")]
|
||||||
|
[InlineData("WCY de DK0WCY-1 <19> : K=2 expK=2 A=5 R=0 SFI=140 SA=qui GMF=qui Au=no")]
|
||||||
|
[InlineData("WWV: SFI=140 A=5 K=2")]
|
||||||
|
public void AnnouncementLinesAreKeptAsTheNodeSentThem(string line) =>
|
||||||
|
Assert.Equal(line.TrimEnd('\a'), new SolarReader().Read(line));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TheLineUnderAShWwvHeaderIsLabelled()
|
||||||
|
{
|
||||||
|
SolarReader reader = new();
|
||||||
|
|
||||||
|
Assert.Null(reader.Read("Date Hour SFI A K Forecast Logger"));
|
||||||
|
Assert.Equal("SFI/A/K: 24-Oct-2026 1200Z 140 5 2 No Storms", reader.Read("24-Oct-2026 1200Z 140 5 2 No Storms"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ASpotCancelsTheHeader()
|
||||||
|
{
|
||||||
|
SolarReader reader = new();
|
||||||
|
reader.Read("Date Hour SFI A K Forecast Logger");
|
||||||
|
reader.Read("DX de K1TTT: 14025.0 JA1XYZ CW 1205Z");
|
||||||
|
|
||||||
|
Assert.Null(reader.Read("some other line"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OrdinaryLinesAreNotSolarWeather() =>
|
||||||
|
Assert.Null(new SolarReader().Read("DX de K1TTT: 14025.0 JA1XYZ CW 26 dB 1205Z"));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user