Mirror a radio from another computer's broadcast data

Listen for Radio broadcast data from another Nonemm or N1MM computer and
record its frequency, mode and split as this computer's own radio. No
radio is controlled. The settings are in the Configure Radios dialog.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 05:16:27 +00:00
co-authored by Claude Sonnet 5.5
parent 75fc6c02d2
commit 072c04883e
13 changed files with 333 additions and 11 deletions
+9
View File
@@ -761,6 +761,15 @@ detail than N1MM:
- **Contacts, All Computers.** Only logged contacts are passed on, not edits or - **Contacts, All Computers.** Only logged contacts are passed on, not edits or
deletes. deletes.
## Radio mirror
Config ▸ Radio Mirror copies the frequency, mode and split of a radio on another
computer, for a station whose radio port is used up. The listener applies
`RadioInfo` to the entry position of one local radio and commands no radio. Nothing is checked against a real N1MM or two
real stations; the test sends our own message over loopback. A mode name other
than `CW-R` and `FSK` that this program has no mode for leaves the mode
unchanged. `IsTransmitting` is read but not used.
## Known rough edges ## Known rough edges
**The band plan covers 160M to 2M only.** 60M and everything above 2M get no **The band plan covers 160M to 2M only.** 60M and everything above 2M get no
+80
View File
@@ -0,0 +1,80 @@
using System.Net.Sockets;
using Avalonia.Threading;
using Nonemm.Core;
using Nonemm.Network;
using Nonemm.Session;
namespace Nonemm.App;
/// Records the frequency, mode and split of a radio that another computer
/// broadcasts as this station's own, for a station whose radio port is taken.
/// Only the log's idea of the radio changes; no radio is commanded.
/// Config > Configure Radios.
public sealed partial class AppSession
{
private RadioMirror? mirror;
/// Starts, restarts or stops the listener, following what the settings now
/// say. A port that cannot be opened is reported and the listener stays off.
public void ApplyMirrorRadioSettings()
{
mirror?.Dispose();
mirror = null;
if (!Settings.MirrorRadioEnabled)
{
return;
}
try
{
mirror = new RadioMirror(Settings.MirrorRadioPort);
}
catch (SocketException e)
{
Reported?.Invoke(this, $"could not listen for radio data on port {Settings.MirrorRadioPort}: {e.Message}");
return;
}
mirror.Failed += (_, problem) => Reported?.Invoke(this, problem);
mirror.Received += (_, message) => Dispatcher.UIThread.Post(() => Mirror(message));
}
/// Sets the target position to where the message says. This computer's own
/// messages are skipped, because the radio data goes to this port too when
/// the default address is used.
private void Mirror(RadioBroadcast message)
{
RadioReport source = message.Radio;
if (message.StationName == ComputerName
|| source.RadioNumber != Settings.MirrorRadioSource
|| (Settings.MirrorRadioStation.Length > 0
&& !string.Equals(message.StationName, Settings.MirrorRadioStation, StringComparison.OrdinalIgnoreCase)))
{
return;
}
OperatingPosition? position = positions.FirstOrDefault(p => p.RadioNumber == Settings.MirrorRadioTarget);
if (position is null || source.Frequency <= 0)
{
return;
}
Frequency frequency = Frequency.FromHertz(source.Frequency);
Frequency transmit = source.IsSplit ? Frequency.FromHertz(source.TransmitFrequency) : Frequency.Zero;
Mode? mode = MirroredMode(source.Mode);
bool frequencyChanged = frequency != position.Frequency;
bool modeChanged = mode is not null && mode != position.Mode;
bool splitChanged = transmit != position.TransmitFrequency;
if (!frequencyChanged && !modeChanged && !splitChanged)
{
return;
}
position.Tune(frequency, mode, transmit);
}
/// N1MM names reverse CW and FSK differently from our modes. Null for a
/// mode we have no name for, which leaves the mode where it is.
private static Mode? MirroredMode(string name) => name.Trim().ToUpperInvariant() switch
{
"CW-R" or "CWR" => Modes.Cw,
"FSK" or "RTTY-R" or "RTTYR" => Modes.Rtty,
var other => Modes.Parse(other),
};
}
+1
View File
@@ -1018,6 +1018,7 @@ public sealed partial class AppSession : IDisposable
announcing?.Stop(); announcing?.Stop();
broadcasting?.Stop(); broadcasting?.Stop();
data?.Dispose(); data?.Dispose();
mirror?.Dispose();
network?.Dispose(); network?.Dispose();
link?.Dispose(); link?.Dispose();
alternating?.Dispose(); alternating?.Dispose();
+19 -2
View File
@@ -148,6 +148,23 @@ public sealed record Settings
public BroadcastChannel BroadcastLookup { get; init; } = new(); public BroadcastChannel BroadcastLookup { get; init; } = new();
/// Copy the frequency, mode and split of a radio that another computer
/// broadcasts. The data comes one way: this station never sends anything
/// back to it.
public bool MirrorRadioEnabled { get; init; }
/// The UDP port the radio data arrives on.
public int MirrorRadioPort { get; init; } = BroadcastTargets.DefaultPort;
/// Only mirror the computer with this name. Empty follows any computer.
public string MirrorRadioStation { get; init; } = "";
/// The radio number on the computer being mirrored.
public int MirrorRadioSource { get; init; } = 1;
/// The radio on this computer that does the mirroring.
public int MirrorRadioTarget { get; init; } = 1;
public IReadOnlyDictionary<BroadcastKind, BroadcastChannel> BroadcastChannels() => public IReadOnlyDictionary<BroadcastKind, BroadcastChannel> BroadcastChannels() =>
new Dictionary<BroadcastKind, BroadcastChannel> new Dictionary<BroadcastKind, BroadcastChannel>
{ {
@@ -387,7 +404,7 @@ public sealed record Settings
/// afterwards. N1MM's "don't drop call". /// afterwards. N1MM's "don't drop call".
public bool DigitalRightClickKeepsCall { get; init; } public bool DigitalRightClickKeepsCall { get; init; }
/// A grabbed call is followed by a space, which moves to the exchange and /// A grabbed call is mirrored by a space, which moves to the exchange and
/// fills the report in, the way typing one does. /// fills the report in, the way typing one does.
public bool DigitalGrabSendsSpace { get; init; } = true; public bool DigitalGrabSendsSpace { get; init; } = true;
@@ -407,7 +424,7 @@ public sealed record Settings
/// underlines the line being printed to; true scrolls old text off the top. /// underlines the line being printed to; true scrolls old text off the top.
public bool DigitalScrolling { get; init; } = true; public bool DigitalScrolling { get; init; } = true;
/// A click on a callsign is followed by a space, so the cursor moves to /// A click on a callsign is mirrored by a space, so the cursor moves to
/// the exchange the way it does after a grab. /// the exchange the way it does after a grab.
public bool DigitalClickSendsSpace { get; init; } public bool DigitalClickSendsSpace { get; init; }
+15
View File
@@ -28,6 +28,21 @@
<TextBox Name="BoxPortBox" /> <TextBox Name="BoxPortBox" />
<TextBlock TextWrapping="Wrap" FontSize="11" Opacity="0.75" Margin="0,8,0,0" <TextBlock TextWrapping="Wrap" FontSize="11" Opacity="0.75" Margin="0,8,0,0"
Text="With a second radio the station is SO2R: both are read and both show on the bandmap, but only the one you are on drives the entry window. Ctrl+Tab moves you to the other, and Ctrl+Shift+Tab puts both in the headphones. An OTRSP box follows, routing the key and the headphones." /> Text="With a second radio the station is SO2R: both are read and both show on the bandmap, but only the one you are on drives the entry window. Ctrl+Tab moves you to the other, and Ctrl+Shift+Tab puts both in the headphones. An OTRSP box follows, routing the key and the headphones." />
<Separator Margin="0,10,0,4" />
<TextBlock TextWrapping="Wrap" FontSize="11" Opacity="0.75"
Text="Mirror a radio: record the frequency, mode and split of a radio on another Nonemm or N1MM computer as this computer's own, when this computer has no port to the radio. The other computer must send Radio data to this computer (Config > Broadcast Data). Nothing is sent back, and no radio is controlled." />
<CheckBox Name="MirrorEnabledCheck" Content="Mirror a radio from another computer" />
<Grid ColumnDefinitions="200,*" RowDefinitions="Auto,Auto,Auto,Auto" RowSpacing="6">
<TextBlock Text="UDP port to listen on" VerticalAlignment="Center" />
<TextBox Name="MirrorPortBox" Grid.Column="1" />
<TextBlock Grid.Row="1" Text="Computer name (empty: any)" VerticalAlignment="Center" />
<TextBox Name="MirrorStationBox" Grid.Row="1" Grid.Column="1" />
<TextBlock Grid.Row="2" Text="Radio on the other computer" VerticalAlignment="Center" />
<TextBox Name="MirrorSourceBox" Grid.Row="2" Grid.Column="1" />
<TextBlock Grid.Row="3" Text="Radio on this computer" VerticalAlignment="Center" />
<TextBox Name="MirrorTargetBox" Grid.Row="3" Grid.Column="1" />
</Grid>
<TextBlock Name="ProblemText" TextWrapping="Wrap" Foreground="OrangeRed" IsVisible="False" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="6" Margin="0,8,0,0"> <StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="6" Margin="0,8,0,0">
<Button Content="Cancel" Click="OnCancel" /> <Button Content="Cancel" Click="OnCancel" />
<Button Content="Save" Click="OnSave" IsDefault="True" /> <Button Content="Save" Click="OnSave" IsDefault="True" />
+32 -2
View File
@@ -4,7 +4,7 @@ using Nonemm.App.Configuration;
namespace Nonemm.App.Dialogs; namespace Nonemm.App.Dialogs;
/// The radios on the desk, one rigctld each. /// The radios on the desk, one rigctld each, and the radio mirror.
public sealed partial class RadioDialog : Window public sealed partial class RadioDialog : Window
{ {
private const int DefaultPort = 4532; private const int DefaultPort = 4532;
@@ -18,6 +18,11 @@ public sealed partial class RadioDialog : Window
Show(0, HostBox, PortBox, EnabledBox); Show(0, HostBox, PortBox, EnabledBox);
Show(1, SecondHostBox, SecondPortBox, SecondEnabledBox); Show(1, SecondHostBox, SecondPortBox, SecondEnabledBox);
BoxPortBox.Text = settings.So2rBoxPort; BoxPortBox.Text = settings.So2rBoxPort;
MirrorEnabledCheck.IsChecked = settings.MirrorRadioEnabled;
MirrorPortBox.Text = settings.MirrorRadioPort.ToString();
MirrorStationBox.Text = settings.MirrorRadioStation;
MirrorSourceBox.Text = settings.MirrorRadioSource.ToString();
MirrorTargetBox.Text = settings.MirrorRadioTarget.ToString();
} }
private void Show(int at, TextBox host, TextBox port, CheckBox enabled) private void Show(int at, TextBox host, TextBox port, CheckBox enabled)
@@ -30,7 +35,26 @@ public sealed partial class RadioDialog : Window
enabled.IsChecked = radio.IsEnabled; enabled.IsChecked = radio.IsEnabled;
} }
private void OnSave(object? sender, RoutedEventArgs e) => Close(settings with private void OnSave(object? sender, RoutedEventArgs e)
{
List<string> problems = [];
if (!int.TryParse(MirrorPortBox.Text, out int mirrorPort) || mirrorPort is < 1 or > 65535)
{
problems.Add("The mirror port must be a number from 1 to 65535.");
}
bool isSourceValid = int.TryParse(MirrorSourceBox.Text, out int source) && source is 1 or 2;
bool isTargetValid = int.TryParse(MirrorTargetBox.Text, out int target) && target is 1 or 2;
if (!isSourceValid || !isTargetValid)
{
problems.Add("The mirror radio numbers must be 1 or 2.");
}
if (problems.Count > 0)
{
ProblemText.Text = string.Join("\n", problems);
ProblemText.IsVisible = true;
return;
}
Close(settings with
{ {
Radios = Radios =
[ [
@@ -38,7 +62,13 @@ public sealed partial class RadioDialog : Window
Read(SecondHostBox, SecondPortBox, SecondEnabledBox), Read(SecondHostBox, SecondPortBox, SecondEnabledBox),
], ],
So2rBoxPort = (BoxPortBox.Text ?? "").Trim(), So2rBoxPort = (BoxPortBox.Text ?? "").Trim(),
MirrorRadioEnabled = MirrorEnabledCheck.IsChecked == true,
MirrorRadioPort = mirrorPort,
MirrorRadioStation = (MirrorStationBox.Text ?? "").Trim(),
MirrorRadioSource = source,
MirrorRadioTarget = target,
}); });
}
private static StoredRadio Read(TextBox host, TextBox port, CheckBox enabled) => new() private static StoredRadio Read(TextBox host, TextBox port, CheckBox enabled) => new()
{ {
@@ -522,6 +522,7 @@ public sealed partial class EntryWindow
} }
session.Save(updated); session.Save(updated);
session.ConnectRadios(); session.ConnectRadios();
session.ApplyMirrorRadioSettings();
try try
{ {
session.ApplySo2rSettings(); session.ApplySo2rSettings();
@@ -206,6 +206,10 @@ public sealed partial class EntryWindow : Window
{ {
yield return ("data broadcast", session.ApplyBroadcastSettings); yield return ("data broadcast", session.ApplyBroadcastSettings);
} }
if (session.Settings.MirrorRadioEnabled)
{
yield return ("radio mirror", session.ApplyMirrorRadioSettings);
}
// always, whatever the CW keyer is: this is also what builds the voice // always, whatever the CW keyer is: this is also what builds the voice
// keyer, which plays out of the sound card rather than a keying port // keyer, which plays out of the sound card rather than a keying port
yield return ("keyer", session.ApplyKeyerSettings); yield return ("keyer", session.ApplyKeyerSettings);
+42
View File
@@ -118,6 +118,48 @@ public static class BroadcastMessages
new XElement("AuxAntSelectedName", ""), new XElement("AuxAntSelectedName", ""),
new XElement("IsConnected", Flag(radio.IsConnected))).ToString(); new XElement("IsConnected", Flag(radio.IsConnected))).ToString();
/// Null for anything that is not a `RadioInfo` message with a frequency, so
/// the other data on the same port is passed over.
public static RadioBroadcast? ReadRadio(string xml)
{
XElement root;
try
{
root = XElement.Parse(xml);
}
catch (System.Xml.XmlException)
{
return null;
}
if (root.Name.LocalName != "RadioInfo"
|| !long.TryParse(root.Element("Freq")?.Value, CultureInfo.InvariantCulture, out long frequency)
|| !long.TryParse(root.Element("TXFreq")?.Value, CultureInfo.InvariantCulture, out long transmit)
|| !int.TryParse(root.Element("RadioNr")?.Value, CultureInfo.InvariantCulture, out int number))
{
return null;
}
string Text(string name) => root.Element(name)?.Value ?? "";
bool Yes(string name) => bool.TryParse(Text(name), out bool value) && value;
int.TryParse(Text("FocusRadioNr"), CultureInfo.InvariantCulture, out int focus);
int.TryParse(Text("ActiveRadioNr"), CultureInfo.InvariantCulture, out int active);
return new RadioBroadcast(
Text("StationName"),
new RadioReport(
number,
frequency * FrequencyUnit,
transmit * FrequencyUnit,
Text("Mode"),
Text("mycall"),
Text("OpCall"),
Yes("IsRunning"),
focus,
Yes("IsSplit"),
active,
Yes("IsTransmitting"),
Text("RadioName"),
Yes("IsConnected")));
}
/// `action` is `add` or `delete`. N1MM leaves the comment and status off a /// `action` is `add` or `delete`. N1MM leaves the comment and status off a
/// delete. /// delete.
public static string Spot(SpotReport spot, string action, string stationName) public static string Spot(SpotReport spot, string action, string stationName)
+4
View File
@@ -0,0 +1,4 @@
namespace Nonemm.Network;
/// A `RadioInfo` message as received: which computer sent it, and what it says.
public sealed record RadioBroadcast(string StationName, RadioReport Radio);
+57
View File
@@ -0,0 +1,57 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
namespace Nonemm.Network;
/// Listens for the `RadioInfo` messages other copies of this program or N1MM
/// broadcast, so this station can copy their radio when it has no serial port of its own.
public sealed class RadioMirror : IDisposable
{
private readonly UdpClient listener = new() { ExclusiveAddressUse = false };
private readonly CancellationTokenSource stopping = new();
/// Throws `SocketException` when the port is taken by a program that did
/// not allow sharing it.
public RadioMirror(int port)
{
listener.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
listener.Client.Bind(new IPEndPoint(IPAddress.Any, port));
_ = ReadAsync(stopping.Token);
}
/// Raised on a thread of the pool.
public event EventHandler<RadioBroadcast>? Received;
public event EventHandler<string>? Failed;
private async Task ReadAsync(CancellationToken cancellation)
{
while (!cancellation.IsCancellationRequested)
{
try
{
UdpReceiveResult result = await listener.ReceiveAsync(cancellation).ConfigureAwait(false);
if (BroadcastMessages.ReadRadio(Encoding.UTF8.GetString(result.Buffer)) is { } message)
{
Received?.Invoke(this, message);
}
}
catch (Exception e) when (e is OperationCanceledException or ObjectDisposedException)
{
return;
}
catch (SocketException e)
{
Failed?.Invoke(this, $"could not read radio data: {e.Message}");
return;
}
}
}
public void Dispose()
{
stopping.Cancel();
listener.Dispose();
}
}
@@ -47,6 +47,32 @@ public class BroadcastTests
Assert.Equal("RUN-PC", root.Element("StationName")!.Value); Assert.Equal("RUN-PC", root.Element("StationName")!.Value);
} }
[Fact]
public void A_radio_message_is_read_back_in_hertz()
{
string xml = BroadcastMessages.Radio(
new RadioReport(2, 14_025_500, 14_030_000, "USB", "OK1ABC", "OK1ABC", true, 2, true, 2, false, "", true),
"RUN-PC");
RadioBroadcast? read = BroadcastMessages.ReadRadio(xml);
Assert.Equal("RUN-PC", read!.StationName);
Assert.Equal(2, read.Radio.RadioNumber);
Assert.Equal(14_025_500, read.Radio.Frequency);
Assert.Equal(14_030_000, read.Radio.TransmitFrequency);
Assert.Equal("USB", read.Radio.Mode);
Assert.True(read.Radio.IsSplit);
}
[Theory]
[InlineData("not xml")]
[InlineData("<spot><dxcall>DL1ABC</dxcall></spot>")]
[InlineData("<RadioInfo><RadioNr>1</RadioNr></RadioInfo>")]
public void Anything_but_a_radio_message_is_passed_over(string text)
{
Assert.Null(BroadcastMessages.ReadRadio(text));
}
[Fact] [Fact]
public void A_deleted_spot_carries_no_comment_or_status() public void A_deleted_spot_carries_no_comment_or_status()
{ {
@@ -0,0 +1,36 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
namespace Nonemm.Network.Tests;
public class RadioMirrorTests
{
[Fact]
public async Task A_radio_message_sent_to_the_port_is_raised_and_other_data_is_not()
{
int port = FreePort();
using RadioMirror mirror = new(port);
TaskCompletionSource<RadioBroadcast> received = new();
mirror.Received += (_, message) => received.TrySetResult(message);
using UdpClient sender = new();
IPEndPoint target = new(IPAddress.Loopback, port);
byte[] other = Encoding.UTF8.GetBytes("<spot><dxcall>DL1ABC</dxcall></spot>");
await sender.SendAsync(other, target);
byte[] radio = Encoding.UTF8.GetBytes(BroadcastMessages.Radio(
new RadioReport(1, 7_012_300, 7_012_300, "CW", "OK1ABC", "OK1ABC", false, 1, false, 1, false, "", true),
"RUN-PC"));
await sender.SendAsync(radio, target);
RadioBroadcast message = await received.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal("RUN-PC", message.StationName);
Assert.Equal(7_012_300, message.Radio.Frequency);
}
private static int FreePort()
{
using UdpClient probe = new(new IPEndPoint(IPAddress.Loopback, 0));
return ((IPEndPoint)probe.Client.LocalEndPoint!).Port;
}
}