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")); +}