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 @@ + + + + + + + + + + + + + +