Fill in the rest of N1MM's network status window
Seven tabs now, in N1MM's order, and the messages behind them. **Options** holds what there is something behind: Log QSOs at all Stations, which decides whether the contacts the others log are kept here; the message trace, moved off the Messages tab where N1MM does not keep it; and Run1/Run2, which used to be guessed from the radio number and is now the operator's choice, sent in every `STATUS` and every `XMIT`. The transmit interlocks stay out: this program does not block a transmitter, and a station that claimed it did would be waited on for nothing. **Call Stack Targets.** A callsign heard at one station and handed to the position that will work it — `STACKANOTHERCALL` with the call alone, `STACKCALL` with the contact behind it. The target is picked from the tab or from the station list, and what arrives goes on the call stack of the radio the operator is on, sorted by whether it is a multiplier here. **The master** is the station the entry follows for cluster spots and the time. `MASTER` names it to everybody, so the column agrees everywhere. **The clock.** `TIME` carries a station's clock and Force Time Sync sends it. N1MM sets the clock from it when it is running as an administrator and says how far out it is when it is not; this program always does the second, because setting the clock needs root and a contest logger has no business asking for it. Anything over two seconds is called out. **Serial numbers.** Reset Serial Numbers at all Stations sends `RESETQSONRS` and every station starts again at one. The contacts already logged keep the numbers they were sent with. **What the entry agrees on** is now checked rather than assumed: | What | How | |---|---| | the contest | `CONTESTNAME` as soon as a connection opens, and again in every `CHECKSUM` | | the country file | its issue date — the publisher's `=VER20260826` — rides in `STATUS` | | the computer name | longer than N1MM's fifteen characters is named at startup; a second computer answering to this one's name is named when its beacon arrives | | the station number | two stations on the same number are named when the second says so | `CountryFile` reads its issue date, which nothing had asked it for before. Left for later: the master relaying its cluster spots, and the shared serial-number pool where stations claim numbers in advance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdAYHcdRktqKry7nk414TU
This commit is contained in:
@@ -231,6 +231,9 @@ public sealed class AppSession : IDisposable
|
||||
positions.Add(new OperatingPosition(Logging, number));
|
||||
}
|
||||
activeRadio = Math.Min(activeRadio, positions.Count - 1);
|
||||
// the other stations refuse to resynchronise with a station on another
|
||||
// contest, and say so, which is only any use if they are told
|
||||
_ = link?.SendContestNameAsync();
|
||||
Logging.Changed += (_, _) => Changed?.Invoke(this, EventArgs.Empty);
|
||||
Logging.Logged += (_, qso) =>
|
||||
{
|
||||
@@ -319,6 +322,10 @@ public sealed class AppSession : IDisposable
|
||||
link.Log = new SessionContactLog(this);
|
||||
link.RescoreRequested += (_, _) =>
|
||||
Dispatcher.UIThread.Post(() => Rescore(DateTime.MinValue));
|
||||
link.SerialNumbersReset += (_, _) =>
|
||||
Dispatcher.UIThread.Post(ResetSerialNumbers);
|
||||
link.CallStacked += (_, stacked) =>
|
||||
Dispatcher.UIThread.Post(() => TakeStackedCall(stacked));
|
||||
foreach (string peer in Settings.StationLinkPeers)
|
||||
{
|
||||
if (Peer(peer) is var (name, address, port))
|
||||
@@ -372,6 +379,62 @@ public sealed class AppSession : IDisposable
|
||||
statusSent = default;
|
||||
}
|
||||
|
||||
/// True while this station is the second run radio of a two-radio
|
||||
/// position, which is N1MM's Run1/Run2 switch on the network status window.
|
||||
/// It goes out in every `STATUS` and is what the other stations read in
|
||||
/// their `R/M` column.
|
||||
public bool IsRun2
|
||||
{
|
||||
get;
|
||||
set
|
||||
{
|
||||
field = value;
|
||||
statusSent = default;
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts the serial numbers again at one. The contacts already logged keep
|
||||
/// the numbers they were sent with; it is the next number that changes.
|
||||
public void ResetSerialNumbers()
|
||||
{
|
||||
if (Logging is not null)
|
||||
{
|
||||
Logging.SerialNumbersFrom = DateTime.UtcNow;
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
/// A callsign another station handed to this one. It goes on the call stack
|
||||
/// of the radio the operator is on, which is where the entry window takes
|
||||
/// them from.
|
||||
private void TakeStackedCall(StackedCall stacked)
|
||||
{
|
||||
if (Position is not { } position || stacked.Call.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
position.Stack.Add(stacked.Call, IsNewMultiplier(stacked));
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
/// Whether a stacked call is worth a multiplier here, which is what decides
|
||||
/// where it goes in the stack. A call with no contact behind it is judged
|
||||
/// on the callsign alone.
|
||||
private bool IsNewMultiplier(StackedCall stacked) =>
|
||||
Logging is not null
|
||||
&& (stacked.Contact is { } contact
|
||||
? Logging.Log.Judge(contact).NewMultipliers.Count > 0
|
||||
: Logging.Log.Judge(new Qso
|
||||
{
|
||||
Id = "",
|
||||
TimestampUtc = DateTime.UtcNow,
|
||||
Call = Callsign.Parse(stacked.Call),
|
||||
Frequency = Position?.Frequency ?? Frequency.Zero,
|
||||
Mode = Position?.Mode ?? Modes.Cw,
|
||||
ContestName = Logging.Contest.Name,
|
||||
}).NewMultipliers.Count > 0);
|
||||
|
||||
/// Scores the log again and writes the points and multiplier flags from
|
||||
/// `fromUtc` on back to the database. Returns how many rows were written.
|
||||
/// Another station asks for this at the end of a resync, and the rescore
|
||||
@@ -402,9 +465,8 @@ public sealed class AppSession : IDisposable
|
||||
Rates.PerHour(qsos, 100, DateTime.UtcNow, off),
|
||||
Logging?.Instance.OperatorCategory ?? "",
|
||||
Logging?.Instance.TransmitterCategory ?? "",
|
||||
// the second radio of a two-radio position is N1MM's Run2, which is
|
||||
// what its R/M column reads
|
||||
position.RadioNumber == 2));
|
||||
Countries?.Version ?? "",
|
||||
IsRun2));
|
||||
}
|
||||
|
||||
/// A station named by hand, written `RUN-PC@192.168.1.5` with `:port` on
|
||||
@@ -433,6 +495,10 @@ public sealed class AppSession : IDisposable
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!Settings.LogQsosAtAllStations && update is not ContactsCleared)
|
||||
{
|
||||
return;
|
||||
}
|
||||
switch (update)
|
||||
{
|
||||
case ContactLogged logged:
|
||||
|
||||
@@ -142,6 +142,11 @@ public sealed record Settings
|
||||
/// with `:port` on the end when that station is not on the usual port.
|
||||
public IReadOnlyList<string> StationLinkPeers { get; init; } = [];
|
||||
|
||||
/// Whether the contacts the other stations log are kept here. N1MM calls it
|
||||
/// Log QSOs at all Stations and every station of an entry normally has it
|
||||
/// on: a log per station is a log the sponsor has to be sent in pieces.
|
||||
public bool LogQsosAtAllStations { get; init; } = true;
|
||||
|
||||
/// The call history file for this contest, or empty for none. They are
|
||||
/// published per contest, so this is not a fixed name.
|
||||
public string CallHistoryFile { get; init; } = "";
|
||||
|
||||
@@ -21,6 +21,10 @@ internal sealed class SessionContactLog : ContactLog
|
||||
|
||||
public string MyCall => Ask(() => session.Settings.Station.Callsign);
|
||||
|
||||
public string ContestSubType => Ask(() => session.Logging?.Instance.SubType ?? "");
|
||||
|
||||
public string CountryFileVersion => Ask(() => session.Countries?.Version ?? "");
|
||||
|
||||
public IReadOnlyList<Qso> Contacts(string computerName, DateTime fromUtc, TimeSpan length) =>
|
||||
Ask(() => session.Logging is not { } logging
|
||||
? []
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="using:Nonemm.App.Windows"
|
||||
x:Class="Nonemm.App.Windows.NetworkStatusWindow"
|
||||
Title="Network Status" Width="900" Height="360">
|
||||
Title="Network Status" Width="1000" Height="380">
|
||||
<DockPanel>
|
||||
<TextBlock DockPanel.Dock="Bottom" Name="StateText" Margin="8,4" FontSize="11"
|
||||
Opacity="0.75" TextWrapping="Wrap" />
|
||||
<TabControl Name="Tabs">
|
||||
|
||||
<TabItem Header="Stations">
|
||||
<TabItem Header="Stations" Name="StationsTab">
|
||||
<DataGrid Name="Rows" IsReadOnly="True" GridLinesVisibility="Horizontal"
|
||||
CanUserSortColumns="False" FontSize="12" FontFamily="monospace"
|
||||
AutoGenerateColumns="False" x:DataType="local:StationRow">
|
||||
@@ -25,9 +25,11 @@
|
||||
<Separator />
|
||||
<MenuItem Header="Talk" Click="OnTalkTo" />
|
||||
<MenuItem Header="Show Connection Log" Click="OnShowMessages" />
|
||||
<MenuItem Header="Target for Call Stacking" Click="OnStackTarget" />
|
||||
<Separator />
|
||||
<MenuItem Header="Close Connection" Click="OnCloseConnection" />
|
||||
<MenuItem Header="Open Connection" Click="OnOpenConnection" />
|
||||
<MenuItem Header="Set station as master for telnet and time sync" Click="OnSetMaster" />
|
||||
<Separator />
|
||||
<MenuItem Header="Edit Computer Addresses" Click="OnSetup" />
|
||||
<MenuItem Header="Stop Networking" Click="OnStopNetworking" />
|
||||
@@ -45,6 +47,7 @@
|
||||
<DataGridTextColumn Header="Msg" Binding="{Binding Msg}" />
|
||||
<DataGridTextColumn Header="Send" Binding="{Binding Send}" />
|
||||
<DataGridTextColumn Header="Receive" Binding="{Binding Receive}" />
|
||||
<DataGridTextColumn Header="Master" Binding="{Binding Master}" />
|
||||
<DataGridTextColumn Header="Band" Binding="{Binding Band}" />
|
||||
<DataGridTextColumn Header="R/M" Binding="{Binding RunMult}" />
|
||||
<DataGridTextColumn Header="Mode" Binding="{Binding Mode}" />
|
||||
@@ -52,24 +55,7 @@
|
||||
</DataGrid>
|
||||
</TabItem>
|
||||
|
||||
<TabItem Header="Actions">
|
||||
<StackPanel Margin="8" Spacing="8">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBox Name="TalkBox" Width="460" PlaceholderText="a line to the other operators" />
|
||||
<Button Content="Talk" Click="OnTalk" IsDefault="True" />
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Content="Set Your Pass Frequency" Click="OnSetPass" />
|
||||
<Button Content="Ask Every Station If It Is There" Click="OnEcho" />
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Content="Edit Computer Addresses" Click="OnSetup" />
|
||||
<Button Name="NetworkingButton" Content="Stop Networking" Click="OnStopNetworking" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</TabItem>
|
||||
|
||||
<TabItem Header="Resynchronize">
|
||||
<TabItem Header="Resynchronize" Name="ResyncTab">
|
||||
<DockPanel Margin="8">
|
||||
<StackPanel DockPanel.Dock="Top" Spacing="6" Margin="0,0,0,8">
|
||||
<StackPanel Orientation="Horizontal" Spacing="12">
|
||||
@@ -101,7 +87,7 @@
|
||||
</DockPanel>
|
||||
</TabItem>
|
||||
|
||||
<TabItem Header="Rescore">
|
||||
<TabItem Header="Rescore" Name="RescoreTab">
|
||||
<StackPanel Margin="8" Spacing="8">
|
||||
<TextBlock Text="Rescore Log, No Log Resync" FontWeight="Bold" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
@@ -116,10 +102,57 @@
|
||||
</StackPanel>
|
||||
</TabItem>
|
||||
|
||||
<TabItem Header="Messages">
|
||||
<TabItem Header="Options" Name="OptionsTab">
|
||||
<StackPanel Margin="8" Spacing="8">
|
||||
<CheckBox Name="LogEverywhereBox" Content="Log QSOs at all Stations"
|
||||
IsCheckedChanged="OnLogEverywhereChanged" />
|
||||
<CheckBox Name="TraceBox" Content="Trace Networked Computer Messages" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="12">
|
||||
<RadioButton Name="Run1Button" GroupName="RunPosition" Content="Run1"
|
||||
IsCheckedChanged="OnRunPositionChanged" />
|
||||
<RadioButton Name="Run2Button" GroupName="RunPosition" Content="Run2/Mult"
|
||||
IsCheckedChanged="OnRunPositionChanged" />
|
||||
</StackPanel>
|
||||
<TextBlock TextWrapping="Wrap" FontSize="11" Opacity="0.75"
|
||||
Text="N1MM's transmit interlocks are not here: this program does not block a transmitter, so a station that claims it does would be waited on for nothing." />
|
||||
</StackPanel>
|
||||
</TabItem>
|
||||
|
||||
<TabItem Header="Call Stack Targets" Name="StackTargetTab">
|
||||
<StackPanel Margin="8" Spacing="8">
|
||||
<TextBlock Text="Destination Station" FontWeight="Bold" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<ComboBox Name="StackTargetBox" Width="220" SelectionChanged="OnStackTargetPicked" />
|
||||
<Button Content="Send the Call Being Worked" Click="OnSendStackedCall" />
|
||||
</StackPanel>
|
||||
<TextBlock Name="StackText" TextWrapping="Wrap" FontSize="11" Opacity="0.75" />
|
||||
</StackPanel>
|
||||
</TabItem>
|
||||
|
||||
<TabItem Header="Actions" Name="ActionsTab">
|
||||
<StackPanel Margin="8" Spacing="8">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBox Name="TalkBox" Width="460" PlaceholderText="a line to the other operators" />
|
||||
<Button Content="Talk" Click="OnTalk" IsDefault="True" />
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Content="Set Your Pass Frequency" Click="OnSetPass" />
|
||||
<Button Content="Ask Every Station If It Is There" Click="OnEcho" />
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Content="Force Time Sync" Click="OnForceTimeSync" />
|
||||
<Button Content="Reset Serial Numbers at all Stations" Click="OnResetSerialNumbers" />
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Content="Edit Computer Addresses" Click="OnSetup" />
|
||||
<Button Name="NetworkingButton" Content="Stop Networking" Click="OnStopNetworking" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</TabItem>
|
||||
|
||||
<TabItem Header="Messages" Name="MessagesTab">
|
||||
<DockPanel Margin="8">
|
||||
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="0,0,0,6">
|
||||
<CheckBox Name="TraceBox" Content="Trace networked computer messages" />
|
||||
<Button Content="Clear Messages" Click="OnClearMessages" />
|
||||
</StackPanel>
|
||||
<ScrollViewer Name="MessageScroll">
|
||||
|
||||
@@ -54,6 +54,9 @@ public sealed partial class NetworkStatusWindow : RefreshableWindow
|
||||
.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
|
||||
ResyncSinceBox.Text = start;
|
||||
RescoreSinceBox.Text = start;
|
||||
LogEverywhereBox.IsChecked = session.Settings.LogQsosAtAllStations;
|
||||
Run1Button.IsChecked = !session.IsRun2;
|
||||
Run2Button.IsChecked = session.IsRun2;
|
||||
Attach();
|
||||
// the Receive column is a countdown, so it has to redraw with nothing
|
||||
// arriving
|
||||
@@ -91,6 +94,7 @@ public sealed partial class NetworkStatusWindow : RefreshableWindow
|
||||
Rows.SelectedItem = rows.FirstOrDefault(r =>
|
||||
string.Equals(r.Station.ComputerName, selected, StringComparison.OrdinalIgnoreCase));
|
||||
ResyncRows.ItemsSource = rows.Where(r => !r.Station.IsMine).ToList();
|
||||
ShowStackTargets(rows);
|
||||
RescoreWhenResyncDone();
|
||||
ReportEchoes();
|
||||
StateText.Text = State();
|
||||
@@ -268,7 +272,7 @@ public sealed partial class NetworkStatusWindow : RefreshableWindow
|
||||
rescoreAfterResync = rescore ? fromUtc : null;
|
||||
link.StartResyncAll(fromUtc, DateTime.UtcNow, everyStation, rescore);
|
||||
Say($"resync from {fromUtc:yyyy-MM-dd HH:mm}Z");
|
||||
Tabs.SelectedIndex = 2;
|
||||
Tabs.SelectedItem = ResyncTab;
|
||||
}
|
||||
|
||||
private void OnRescoreContest(object? sender, RoutedEventArgs e) => Rescore(DateTime.MinValue);
|
||||
@@ -292,6 +296,117 @@ public sealed partial class NetworkStatusWindow : RefreshableWindow
|
||||
Say($"rescored {written} contacts");
|
||||
}
|
||||
|
||||
/// The stations a call can be handed to, and which one it goes to now.
|
||||
/// Picking one here and picking one from the Stations tab are the same
|
||||
/// thing, so the box follows the station that is marked.
|
||||
private void ShowStackTargets(IReadOnlyList<StationRow> rows)
|
||||
{
|
||||
List<string> names = rows.Where(r => !r.Station.IsMine).Select(r => r.Station.ComputerName).ToList();
|
||||
if (!names.SequenceEqual(StackTargetBox.Items.Cast<string>()))
|
||||
{
|
||||
StackTargetBox.ItemsSource = names;
|
||||
}
|
||||
StackTargetBox.SelectedItem = rows
|
||||
.FirstOrDefault(r => r.Station.IsCallStackTarget)?.Station.ComputerName;
|
||||
}
|
||||
|
||||
private void OnStackTargetPicked(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (link is null || StackTargetBox.SelectedItem is not string name)
|
||||
{
|
||||
return;
|
||||
}
|
||||
link.SetCallStackTarget(link.Stations.FirstOrDefault(s =>
|
||||
string.Equals(s.ComputerName, name, StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
|
||||
/// Marks the station picked in the Stations tab as the one stacked calls
|
||||
/// go to.
|
||||
private void OnStackTarget(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (link is not null && Chosen() is { } station)
|
||||
{
|
||||
link.SetCallStackTarget(station);
|
||||
Say($"stacked calls now go to {station.ComputerName}");
|
||||
Refresh();
|
||||
}
|
||||
}
|
||||
|
||||
/// Hands the callsign in the entry window to the target station's call
|
||||
/// stack. It is what an operator does with a station he has heard and is
|
||||
/// not going to work himself.
|
||||
private async void OnSendStackedCall(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (link is null || session.Position is not { } position)
|
||||
{
|
||||
return;
|
||||
}
|
||||
NetworkedStation? target = link.Stations.FirstOrDefault(s => s.IsCallStackTarget);
|
||||
string call = position.Entry.Call.Trim();
|
||||
if (target is null || call.Length == 0)
|
||||
{
|
||||
StackText.Text = target is null
|
||||
? "no station is the target — pick one"
|
||||
: "nothing is typed in the callsign box";
|
||||
return;
|
||||
}
|
||||
StackText.Text = await link.SendStackedAsync(target, call)
|
||||
? $"sent {call} to {target.ComputerName}"
|
||||
: $"{target.ComputerName} is not connected";
|
||||
Say(StackText.Text);
|
||||
}
|
||||
|
||||
private void OnSetMaster(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (link is not null && Chosen() is { } station)
|
||||
{
|
||||
_ = link.SendMasterAsync(station);
|
||||
Say($"{station.ComputerName} is the master for the cluster and the time");
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends this station's clock to the others. N1MM sets the clock from it
|
||||
/// when it is running as an administrator; this program says how far apart
|
||||
/// the two clocks are and leaves them alone, which is what N1MM does the
|
||||
/// rest of the time.
|
||||
private async void OnForceTimeSync(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (link is not null)
|
||||
{
|
||||
await link.SendTimeAsync();
|
||||
Say("sent this station's clock to the others");
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts the serial numbers again at one, here and at every station.
|
||||
private async void OnResetSerialNumbers(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (link is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
session.ResetSerialNumbers();
|
||||
await link.SendResetSerialNumbersAsync();
|
||||
Say("serial numbers start again at one");
|
||||
}
|
||||
|
||||
private void OnLogEverywhereChanged(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
bool wanted = LogEverywhereBox.IsChecked == true;
|
||||
if (wanted != session.Settings.LogQsosAtAllStations)
|
||||
{
|
||||
session.Save(session.Settings with { LogQsosAtAllStations = wanted });
|
||||
}
|
||||
}
|
||||
|
||||
private void OnRunPositionChanged(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (Run2Button.IsChecked == true != session.IsRun2)
|
||||
{
|
||||
session.IsRun2 = Run2Button.IsChecked == true;
|
||||
}
|
||||
}
|
||||
|
||||
/// The station the operator right-clicked, or null when the row is this
|
||||
/// computer's own: nothing in the menu is worth doing to yourself.
|
||||
private NetworkedStation? Chosen() =>
|
||||
@@ -313,11 +428,11 @@ public sealed partial class NetworkStatusWindow : RefreshableWindow
|
||||
|
||||
private void OnTalkTo(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
Tabs.SelectedIndex = 1;
|
||||
Tabs.SelectedItem = ActionsTab;
|
||||
TalkBox.Focus();
|
||||
}
|
||||
|
||||
private void OnShowMessages(object? sender, RoutedEventArgs e) => Tabs.SelectedIndex = 4;
|
||||
private void OnShowMessages(object? sender, RoutedEventArgs e) => Tabs.SelectedItem = MessagesTab;
|
||||
|
||||
private async void OnEcho(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
|
||||
@@ -71,6 +71,9 @@ public sealed class StationRow
|
||||
}
|
||||
}
|
||||
|
||||
/// The station the whole entry follows for cluster spots and the time.
|
||||
public string Master => station.IsMaster ? "yes" : "";
|
||||
|
||||
public string Band => station.Band?.Name ?? "";
|
||||
|
||||
/// Run1 or Run2, which N1MM heads `R/M`.
|
||||
|
||||
Reference in New Issue
Block a user