diff --git a/src/Nonemm.App/AppSession.cs b/src/Nonemm.App/AppSession.cs
index 30fc872..a3da437 100644
--- a/src/Nonemm.App/AppSession.cs
+++ b/src/Nonemm.App/AppSession.cs
@@ -914,6 +914,7 @@ public sealed partial class AppSession : IDisposable
public void ConnectCluster()
{
cluster?.Dispose();
+ SetSolar("");
cluster = new ClusterClient(
Settings.ClusterHost,
Settings.ClusterPort,
@@ -923,14 +924,28 @@ public sealed partial class AppSession : IDisposable
autoLogon: Settings.ClusterAutoLogon,
keepAliveInterval: TimeSpan.FromMinutes(Math.Max(1, Settings.ClusterKeepAliveMinutes)));
cluster.SpotArrived += (_, spot) => TakeSpot(spot);
+ cluster.SolarArrived += (_, text) => SetSolar(text);
cluster.ConnectionChanged += (_, _) => Changed?.Invoke(this, EventArgs.Empty);
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()
{
cluster?.Dispose();
cluster = null;
+ SetSolar("");
Changed?.Invoke(this, EventArgs.Empty);
}
diff --git a/src/Nonemm.App/Windows/BandmapWindow.axaml b/src/Nonemm.App/Windows/BandmapWindow.axaml
index 8f32399..d0457de 100644
--- a/src/Nonemm.App/Windows/BandmapWindow.axaml
+++ b/src/Nonemm.App/Windows/BandmapWindow.axaml
@@ -8,6 +8,10 @@
+
+
+
diff --git a/src/Nonemm.App/Windows/BandmapWindow.axaml.cs b/src/Nonemm.App/Windows/BandmapWindow.axaml.cs
index f7eaf1f..94225a8 100644
--- a/src/Nonemm.App/Windows/BandmapWindow.axaml.cs
+++ b/src/Nonemm.App/Windows/BandmapWindow.axaml.cs
@@ -76,9 +76,21 @@ public sealed partial class BandmapWindow : RefreshableWindow
};
View.FrequencyPicked += (_, frequency) => tune(frequency, "");
session.Bandmap.Changed += (_, _) => Dispatcher.UIThread.Post(Refresh);
+ EventHandler solarChanged = (_, _) => Dispatcher.UIThread.Post(ShowSolar);
+ session.SolarChanged += solarChanged;
+ Closed += (_, _) => session.SolarChanged -= solarChanged;
+ ShowSolar();
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()
{
session.Save(session.Settings with
diff --git a/src/Nonemm.Spotting/ClusterClient.cs b/src/Nonemm.Spotting/ClusterClient.cs
index c162b0f..16bae39 100644
--- a/src/Nonemm.Spotting/ClusterClient.cs
+++ b/src/Nonemm.Spotting/ClusterClient.cs
@@ -29,6 +29,7 @@ public sealed class ClusterClient : IDisposable
private readonly CancellationTokenSource stopping = new();
private readonly LineAssembler lines = new();
private readonly Queue recent = new();
+ private readonly SolarReader solar = new();
private readonly Lock guard = new();
private TcpClient? client;
@@ -80,6 +81,9 @@ public sealed class ClusterClient : IDisposable
public event EventHandler? LineArrived;
+ /// The node sent solar weather, as the text to show.
+ public event EventHandler? SolarArrived;
+
/// 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? LineSent;
@@ -237,6 +241,10 @@ public sealed class ClusterClient : IDisposable
Kind = kind;
ConnectionChanged?.Invoke(this, IsConnected);
}
+ if (solar.Read(line) is { } weather)
+ {
+ SolarArrived?.Invoke(this, weather);
+ }
Spot? spot = SpotLine.Parse(line, DateTime.UtcNow);
if (spot is not null)
{
diff --git a/src/Nonemm.Spotting/SolarReader.cs b/src/Nonemm.Spotting/SolarReader.cs
new file mode 100644
index 0000000..25d13fa
--- /dev/null
+++ b/src/Nonemm.Spotting/SolarReader.cs
@@ -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();
+}
diff --git a/tests/Nonemm.Spotting.Tests/SolarReaderTests.cs b/tests/Nonemm.Spotting.Tests/SolarReaderTests.cs
new file mode 100644
index 0000000..690b130
--- /dev/null
+++ b/tests/Nonemm.Spotting.Tests/SolarReaderTests.cs
@@ -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"));
+}