Show only the newest SFI, A and K in the solar weather bar

For sh/wwv, only the first record under the header is used, since the
table lists the newest first. The date, time, forecast and logger are
dropped, and the status bar reads "SFI 140  A 5  K 2". The line the node
sent is the tool tip. Announcement lines give the same three values.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 12:17:44 +00:00
co-authored by Claude Sonnet 5.5
parent e55159ba49
commit cf39b5af9c
5 changed files with 93 additions and 36 deletions
+7 -7
View File
@@ -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);
}
@@ -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()
+2 -2
View File
@@ -81,8 +81,8 @@ public sealed class ClusterClient : IDisposable
public event EventHandler<string>? LineArrived;
/// The node sent solar weather, as the text to show.
public event EventHandler<string>? SolarArrived;
/// The node sent solar weather.
public event EventHandler<SolarReport>? SolarArrived;
/// What went out to the node, so the telnet window can show the login and
/// the commands beside the node's answers.
+48 -15
View File
@@ -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? (?<sfi>\d+) (?<a>\d+) (?<k>\d+)\b")]
private static partial Regex Row();
}