From 072c04883ecb8588745836dfb7d5e12a445ce282 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Sat, 24 Oct 2026 05:16:27 +0000 Subject: [PATCH] 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 --- docs/unfinished.md | 9 +++ src/Nonemm.App/AppSession.MirrorRadio.cs | 80 +++++++++++++++++++ src/Nonemm.App/AppSession.cs | 1 + src/Nonemm.App/Configuration/Settings.cs | 21 ++++- src/Nonemm.App/Dialogs/RadioDialog.axaml | 15 ++++ src/Nonemm.App/Dialogs/RadioDialog.axaml.cs | 48 ++++++++--- src/Nonemm.App/Windows/EntryWindow.Menu.cs | 1 + src/Nonemm.App/Windows/EntryWindow.axaml.cs | 4 + src/Nonemm.Network/BroadcastMessages.cs | 42 ++++++++++ src/Nonemm.Network/RadioBroadcast.cs | 4 + src/Nonemm.Network/RadioMirror.cs | 57 +++++++++++++ tests/Nonemm.Network.Tests/BroadcastTests.cs | 26 ++++++ .../Nonemm.Network.Tests/RadioMirrorTests.cs | 36 +++++++++ 13 files changed, 333 insertions(+), 11 deletions(-) create mode 100644 src/Nonemm.App/AppSession.MirrorRadio.cs create mode 100644 src/Nonemm.Network/RadioBroadcast.cs create mode 100644 src/Nonemm.Network/RadioMirror.cs create mode 100644 tests/Nonemm.Network.Tests/RadioMirrorTests.cs diff --git a/docs/unfinished.md b/docs/unfinished.md index 649745e..2cb1539 100644 --- a/docs/unfinished.md +++ b/docs/unfinished.md @@ -761,6 +761,15 @@ detail than N1MM: - **Contacts, All Computers.** Only logged contacts are passed on, not edits or 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 **The band plan covers 160M to 2M only.** 60M and everything above 2M get no diff --git a/src/Nonemm.App/AppSession.MirrorRadio.cs b/src/Nonemm.App/AppSession.MirrorRadio.cs new file mode 100644 index 0000000..9ece128 --- /dev/null +++ b/src/Nonemm.App/AppSession.MirrorRadio.cs @@ -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), + }; +} diff --git a/src/Nonemm.App/AppSession.cs b/src/Nonemm.App/AppSession.cs index a574cb5..2978ce1 100644 --- a/src/Nonemm.App/AppSession.cs +++ b/src/Nonemm.App/AppSession.cs @@ -1018,6 +1018,7 @@ public sealed partial class AppSession : IDisposable announcing?.Stop(); broadcasting?.Stop(); data?.Dispose(); + mirror?.Dispose(); network?.Dispose(); link?.Dispose(); alternating?.Dispose(); diff --git a/src/Nonemm.App/Configuration/Settings.cs b/src/Nonemm.App/Configuration/Settings.cs index a747227..f51a41e 100644 --- a/src/Nonemm.App/Configuration/Settings.cs +++ b/src/Nonemm.App/Configuration/Settings.cs @@ -148,6 +148,23 @@ public sealed record Settings 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 BroadcastChannels() => new Dictionary { @@ -387,7 +404,7 @@ public sealed record Settings /// afterwards. N1MM's "don't drop call". 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. 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. 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. public bool DigitalClickSendsSpace { get; init; } diff --git a/src/Nonemm.App/Dialogs/RadioDialog.axaml b/src/Nonemm.App/Dialogs/RadioDialog.axaml index 242bb9d..ea928ca 100644 --- a/src/Nonemm.App/Dialogs/RadioDialog.axaml +++ b/src/Nonemm.App/Dialogs/RadioDialog.axaml @@ -28,6 +28,21 @@ + + + + + + + + + + + + + +