diff --git a/src/Nonemm.App/AppSession.cs b/src/Nonemm.App/AppSession.cs index a3da437..9a26178 100644 --- a/src/Nonemm.App/AppSession.cs +++ b/src/Nonemm.App/AppSession.cs @@ -914,7 +914,7 @@ public sealed partial class AppSession : IDisposable public void ConnectCluster() { cluster?.Dispose(); - SetSolar(""); + SetSolar(null); cluster = new ClusterClient( Settings.ClusterHost, Settings.ClusterPort, @@ -924,20 +924,20 @@ 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.SolarArrived += (_, report) => SetSolar(report); cluster.ConnectionChanged += (_, _) => Changed?.Invoke(this, EventArgs.Empty); cluster.Start(); } - /// The latest solar weather the cluster sent, or empty when it has sent + /// The latest solar weather the cluster sent, or null when it has sent /// none on this connection. - public string Solar { get; private set; } = ""; + public SolarReport? Solar { get; private set; } public event EventHandler? SolarChanged; - private void SetSolar(string text) + private void SetSolar(SolarReport? report) { - Solar = text; + Solar = report; SolarChanged?.Invoke(this, EventArgs.Empty); } @@ -945,7 +945,7 @@ public sealed partial class AppSession : IDisposable { cluster?.Dispose(); cluster = null; - SetSolar(""); + SetSolar(null); Changed?.Invoke(this, EventArgs.Empty); } diff --git a/src/Nonemm.App/Windows/BandmapWindow.axaml.cs b/src/Nonemm.App/Windows/BandmapWindow.axaml.cs index 94225a8..0123748 100644 --- a/src/Nonemm.App/Windows/BandmapWindow.axaml.cs +++ b/src/Nonemm.App/Windows/BandmapWindow.axaml.cs @@ -1,3 +1,4 @@ +using Avalonia.Controls; using Nonemm.App.Configuration; using Avalonia.Media; using Avalonia.Threading; @@ -84,11 +85,12 @@ public sealed partial class BandmapWindow : RefreshableWindow } /// The status bar holds the solar weather the cluster sent, and is hidden - /// while there is none. + /// while there is none. The line the node sent is its tool tip. private void ShowSolar() { - SolarText.Text = session.Solar; - SolarBar.IsVisible = session.Solar.Length > 0; + SolarText.Text = session.Solar?.Text ?? ""; + ToolTip.SetTip(SolarBar, session.Solar?.Original); + SolarBar.IsVisible = session.Solar is not null; } private void SaveView() diff --git a/src/Nonemm.Spotting/ClusterClient.cs b/src/Nonemm.Spotting/ClusterClient.cs index 16bae39..ef9b48d 100644 --- a/src/Nonemm.Spotting/ClusterClient.cs +++ b/src/Nonemm.Spotting/ClusterClient.cs @@ -81,8 +81,8 @@ public sealed class ClusterClient : IDisposable public event EventHandler? LineArrived; - /// The node sent solar weather, as the text to show. - public event EventHandler? SolarArrived; + /// The node sent solar weather. + 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. diff --git a/src/Nonemm.Spotting/SolarReader.cs b/src/Nonemm.Spotting/SolarReader.cs index 25d13fa..09114ab 100644 --- a/src/Nonemm.Spotting/SolarReader.cs +++ b/src/Nonemm.Spotting/SolarReader.cs @@ -2,19 +2,22 @@ using System.Text.RegularExpressions; namespace Nonemm.Spotting; +/// Solar weather as the bandmap shows it: `Text` is the short form and +/// `Original` is the line the node sent. +public sealed record SolarReport(string Text, string Original); + /// 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. +/// above a table of records, newest first. Only the first record is used. 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) + /// The report 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 table cancels the header. + public SolarReport? Read(string line) { string text = Collapse(line); if (text.Length == 0) @@ -22,7 +25,6 @@ public sealed partial class SolarReader return null; } string upper = text.ToUpperInvariant(); - bool isFromNode = upper.Contains(" DE ", StringComparison.Ordinal); if (upper.StartsWith("DX", StringComparison.Ordinal)) { afterHeader = false; @@ -33,28 +35,59 @@ public sealed partial class SolarReader || upper.Contains("WWV DE", StringComparison.Ordinal) || upper.Contains("WCY DE", StringComparison.Ordinal)) { - return text; + afterHeader = false; + return Announcement(text); } if (upper.StartsWith("DATE HOUR SFI", StringComparison.Ordinal)) { afterHeader = true; return null; } - if (afterHeader && !isFromNode) + if (!afterHeader) { - return "SFI/A/K: " + text; + return null; } - if (isFromNode) - { - afterHeader = false; - } - return null; + // the first record under the header is the newest; the rest are older + afterHeader = false; + return Record(text); } + /// `24-Oct-2026 1200Z 140 5 2 No Storms -> No Storms VE7CC` as the three + /// indices. + private static SolarReport? Record(string text) + { + Match row = Row().Match(text); + return row.Success + ? new SolarReport( + Indices(row.Groups["sfi"].Value, row.Groups["a"].Value, row.Groups["k"].Value), + text) + : null; + } + + /// The indices out of `SFI=140, A=5, K=2`. A name that is not there is left + /// out, and a line with none of them is not solar weather. + private static SolarReport? Announcement(string text) + { + string sfi = Index("SFI").Match(text).Groups[1].Value; + string a = Index("A").Match(text).Groups[1].Value; + string k = Index("K").Match(text).Groups[1].Value; + return sfi.Length + a.Length + k.Length == 0 ? null : new SolarReport(Indices(sfi, a, k), text); + } + + private static string Indices(string sfi, string a, string k) => + string.Join(" ", new[] { ("SFI", sfi), ("A", a), ("K", k) } + .Where(i => i.Item2.Length > 0) + .Select(i => $"{i.Item1} {i.Item2}")); + + private static Regex Index(string name) => new($@"\b{name}=(\d+)", RegexOptions.IgnoreCase); + /// 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(); + + [GeneratedRegex(@"^\d{1,2}-[A-Za-z]{3}-\d{2,4} \d{4}Z? (?\d+) (?\d+) (?\d+)\b")] + private static partial Regex Row(); } diff --git a/tests/Nonemm.Spotting.Tests/SolarReaderTests.cs b/tests/Nonemm.Spotting.Tests/SolarReaderTests.cs index 690b130..081261a 100644 --- a/tests/Nonemm.Spotting.Tests/SolarReaderTests.cs +++ b/tests/Nonemm.Spotting.Tests/SolarReaderTests.cs @@ -3,21 +3,43 @@ 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)); + [InlineData("WWV de VE7CC <18Z> : SFI=140, A=5, K=2, No Storms -> No Storms\a\a", "SFI 140 A 5 K 2")] + [InlineData("WCY de DK0WCY-1 <19> : K=2 expK=2 A=5 R=0 SFI=140 SA=qui", "SFI 140 A 5 K 2")] + [InlineData("WWV: SFI=140 A=5 K=2", "SFI 140 A 5 K 2")] + public void AnnouncementLinesShowTheirIndices(string line, string text) + { + SolarReport? report = new SolarReader().Read(line); + + Assert.Equal(text, report?.Text); + Assert.Equal(line.TrimEnd('\a'), report?.Original); + } [Fact] - public void TheLineUnderAShWwvHeaderIsLabelled() + public void TheNewestRecordUnderAShWwvHeaderIsShownWithoutDateOrTime() { 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")); + Assert.Null(reader.Read(" Date Hour SFI A K Forecast Logger")); + SolarReport? newest = reader.Read("24-Oct-2026 1200Z 140 5 2 No Storms -> No Storms VE7CC"); + + Assert.Equal("SFI 140 A 5 K 2", newest?.Text); + Assert.Equal("24-Oct-2026 1200Z 140 5 2 No Storms -> No Storms VE7CC", newest?.Original); } + [Fact] + public void TheOlderRecordsUnderTheHeaderAreIgnored() + { + SolarReader reader = new(); + reader.Read("Date Hour SFI A K Forecast Logger"); + reader.Read("24-Oct-2026 1200Z 140 5 2 No Storms -> No Storms VE7CC"); + + Assert.Null(reader.Read("24-Oct-2026 0900Z 138 6 3 No Storms -> No Storms VE7CC")); + } + + [Fact] + public void AnAnnouncementWithoutIndicesIsNotSolarWeather() => + Assert.Null(new SolarReader().Read("WWV de VE7CC <18Z> : no data")); + [Fact] public void ASpotCancelsTheHeader() { @@ -25,7 +47,7 @@ public class SolarReaderTests 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")); + Assert.Null(reader.Read("24-Oct-2026 1200Z 140 5 2 No Storms -> No Storms VE7CC")); } [Fact]