From 65178b2d890bfa892d24f644143734c48dd75291 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Sat, 24 Oct 2026 03:42:19 +0000 Subject: [PATCH] Add the Broadcast Data dialog and send N1MM's score breakdown Co-Authored-By: Claude Sonnet 5.5 --- README.md | 27 ++- docs/unfinished.md | 23 ++ src/Nonemm.App/AppSession.Broadcast.cs | 218 ++++++++++++++++++ src/Nonemm.App/AppSession.cs | 16 +- src/Nonemm.App/Configuration/Settings.cs | 29 +++ .../Dialogs/BroadcastDataDialog.axaml | 35 +++ .../Dialogs/BroadcastDataDialog.axaml.cs | 82 +++++++ src/Nonemm.App/Dialogs/NetworkDialog.axaml | 5 +- src/Nonemm.App/Dialogs/NetworkDialog.axaml.cs | 8 - src/Nonemm.App/Windows/EntryWindow.Menu.cs | 13 ++ src/Nonemm.App/Windows/EntryWindow.axaml | 1 + src/Nonemm.App/Windows/EntryWindow.axaml.cs | 8 + src/Nonemm.Contests/N1mmMultiplierNames.cs | 62 +++++ src/Nonemm.Contests/ScoreBreakdown.cs | 72 ++++++ src/Nonemm.Network/BroadcastChannel.cs | 11 + src/Nonemm.Network/BroadcastKind.cs | 14 ++ src/Nonemm.Network/BroadcastMessages.cs | 206 +++++++++++++++++ src/Nonemm.Network/BroadcastTargets.cs | 50 ++++ src/Nonemm.Network/ContactMessage.cs | 5 + src/Nonemm.Network/DataBroadcaster.cs | 70 ++++++ src/Nonemm.Network/ScoreLabels.cs | 100 ++++++++ src/Nonemm.Network/StationNetwork.cs | 5 + .../ScoreBreakdownTests.cs | 72 ++++++ tests/Nonemm.Network.Tests/BroadcastTests.cs | 214 +++++++++++++++++ 24 files changed, 1325 insertions(+), 21 deletions(-) create mode 100644 src/Nonemm.App/AppSession.Broadcast.cs create mode 100644 src/Nonemm.App/Dialogs/BroadcastDataDialog.axaml create mode 100644 src/Nonemm.App/Dialogs/BroadcastDataDialog.axaml.cs create mode 100644 src/Nonemm.Contests/N1mmMultiplierNames.cs create mode 100644 src/Nonemm.Contests/ScoreBreakdown.cs create mode 100644 src/Nonemm.Network/BroadcastChannel.cs create mode 100644 src/Nonemm.Network/BroadcastKind.cs create mode 100644 src/Nonemm.Network/BroadcastMessages.cs create mode 100644 src/Nonemm.Network/BroadcastTargets.cs create mode 100644 src/Nonemm.Network/DataBroadcaster.cs create mode 100644 src/Nonemm.Network/ScoreLabels.cs create mode 100644 tests/Nonemm.Contests.Tests/ScoreBreakdownTests.cs create mode 100644 tests/Nonemm.Network.Tests/BroadcastTests.cs diff --git a/README.md b/README.md index 3d4f947..22f4980 100644 --- a/README.md +++ b/README.md @@ -1030,11 +1030,28 @@ at the node as well, because that is less traffic on the wire. ### Networked stations -**Config → Network** names this station and lists the others. Each contact is -sent to them as it is logged, in N1MM's `contactinfo` message, so an N1MM -station on the same network sees them too. Editing a contact sends -`contactreplace` and deleting one sends `contactdelete`, the same as N1MM. With -no addresses listed the messages are broadcast. A contact that arrives is scored +**Config → Edit Networked-Computer Names** names this station and sets the +port it listens on. **Config → Configure Broadcast Data** is N1MM's Broadcast +Data tab: seven kinds of data, each with an on/off box and a list of +`address:port` pairs separated by spaces. An empty list sends to `127.0.0.1` on +N1MM's port for that kind: 12060, except the score on 12050 and the rotor on +12040. + +| Row | What goes out | When | +| --- | --- | --- | +| Application Info | `AppInfo`: database, contest number and name | a contest is opened | +| Radio | `RadioInfo`: frequency, mode, run/search, split, connection | something in it changes | +| Contacts | `contactinfo`, `contactreplace`, `contactdelete` | a contact is logged, edited or deleted | +| Contacts, All Computers | the contacts the other logging computers make | one arrives on port 12070 | +| Spots | `spot` with N1MM's status (`new qso`, `dupe`, `single mult`, `double mult`) | a spot reaches the bandmap | +| Rotor | nothing yet: this program has no rotor control | | +| Score | `dynamicresults`: a row per band and mode, and a total, in N1MM's layout | every ten seconds | +| External Callsign Lookup | `lookupinfo`: the contact being typed | Tab leaves the callsign box on a new call | + +Contacts with no address listed are broadcast to the whole network, and each +contact is sent in N1MM's `contactinfo` message, so an N1MM station on the same +network sees them too. Editing a contact sends `contactreplace` and deleting +one sends `contactdelete`, the same as N1MM. A contact that arrives is scored again here from the rules rather than trusted. An incoming edit or delete is matched by contact id first. N1MM does not know diff --git a/docs/unfinished.md b/docs/unfinished.md index d7740c7..649745e 100644 --- a/docs/unfinished.md +++ b/docs/unfinished.md @@ -738,6 +738,29 @@ legend. It has no tooltips, no zoom and no 3-D, all of which N1MM's chart control gives it for free. A column label that will not fit under its column is turned on its side, and one that will not fit even then is left out. +## Broadcast data + +What the Broadcast Data dialog saves but does not send yet, or sends in less +detail than N1MM: + +- **Rotor.** The row is saved. Nothing sends `N1MMRotor` messages, because this + program has no rotor control. +- **Spots.** Only `add` goes out. N1MM also sends `delete` when a spot leaves the + bandmap. Spots that expire are not reported. +- **Score.** The rows, their order, the band and mode labels and the layout + follow N1MM's query and writer. The row set and order were checked against the + query on `om5m.s3db` for 62 contests. Points and multiplier flags come from our + own scoring, so they carry the differences listed in `n1mm-interop.md`. The + multiplier types come from a table of N1MM's names (`N1mmMultiplierNames`) + for the built-in contests; a user-defined contest uses the names its rules + give. N1MM writes text values unescaped and we escape them. The bands' wavelength + labels were read from N1MM's code and not compared with its output. +- **Radio.** `RadioName`, `Antenna`, `Rotors`, `FocusEntry`, `EntryWindowHwnd` + and the aux antenna fields are sent as 0 or empty, and `IsTransmitting` is + always `False`. +- **Contacts, All Computers.** Only logged contacts are passed on, not edits or + deletes. + ## 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.Broadcast.cs b/src/Nonemm.App/AppSession.Broadcast.cs new file mode 100644 index 0000000..0f705bd --- /dev/null +++ b/src/Nonemm.App/AppSession.Broadcast.cs @@ -0,0 +1,218 @@ +using Avalonia.Threading; +using Nonemm.Contests; +using Nonemm.Core; +using Nonemm.Network; +using Nonemm.Rig; +using Nonemm.Session; +using Nonemm.Spotting; +using Nonemm.Storage; + +namespace Nonemm.App; + +/// The data N1MM sends to other programs besides the contacts: what the +/// Broadcast Data tab turns on. +public sealed partial class AppSession +{ + /// N1MM sends the score from the timer of the first entry window, every ten + /// seconds. + private static readonly TimeSpan ScoreInterval = TimeSpan.FromSeconds(10); + + private DataBroadcaster? data; + private DispatcherTimer? broadcasting; + private readonly Dictionary radiosSent = []; + private DateTime scoreSent; + private string lookupSent = ""; + + /// Starts, restarts or stops the broadcasts, following what the settings + /// now say. Rows whose addresses cannot be read are reported and left off. + public void ApplyBroadcastSettings() + { + broadcasting?.Stop(); + data?.Dispose(); + data = new DataBroadcaster(Settings.BroadcastChannels()); + data.Failed += (_, problem) => Reported?.Invoke(this, problem); + foreach (string problem in data.Problems) + { + Reported?.Invoke(this, problem); + } + radiosSent.Clear(); + scoreSent = DateTime.UtcNow; + lookupSent = ""; + SendAppInfo(); + broadcasting = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) }; + broadcasting.Tick += (_, _) => + { + SendRadios(); + SendScore(); + }; + broadcasting.Start(); + } + + /// Which database and contest this station has open. It goes out when a + /// contest is opened. + private void SendAppInfo() + { + if (data is null || !data.IsSending(BroadcastKind.AppInfo) || Logging is null) + { + return; + } + data.Send( + BroadcastKind.AppInfo, + BroadcastMessages.AppInfo( + Settings.DatabasePath, + Logging.Instance.ContestNumber, + Logging.Contest.Name, + ComputerName, + Settings.Station.Callsign)); + } + + /// Each radio, when something about it has changed since the last time. + private void SendRadios() + { + if (data is null || !data.IsSending(BroadcastKind.Radio)) + { + return; + } + foreach (OperatingPosition position in positions) + { + Radio? radio = radios.FirstOrDefault(r => r.Number == position.RadioNumber); + long transmit = position.TransmitFrequency.Hertz > 0 + ? position.TransmitFrequency.Hertz + : position.Frequency.Hertz; + RadioReport report = new( + position.RadioNumber, + position.Frequency.Hertz, + transmit, + position.Mode.Name, + Settings.Station.Callsign, + Settings.CurrentOperator, + position.IsRunning, + ActiveRadioNumber, + position.TransmitFrequency.Hertz > 0, + ActiveRadioNumber, + false, + "", + radio?.IsConnected ?? false); + if (!radiosSent.TryGetValue(position.RadioNumber, out RadioReport? last) || last != report) + { + radiosSent[position.RadioNumber] = report; + data.Send(BroadcastKind.Radio, BroadcastMessages.Radio(report, ComputerName)); + } + } + } + + private void SendSpots(IEnumerable spots) + { + if (data is null || !data.IsSending(BroadcastKind.Spots)) + { + return; + } + foreach (Spot spot in spots.Where(s => s.IsStation)) + { + data.Send( + BroadcastKind.Spots, + BroadcastMessages.Spot( + new SpotReport( + spot.Call.Text, + spot.Frequency.Hertz, + spot.Spotter, + spot.AtUtc, + SpotMode(spot.Frequency), + spot.Comment, + SpotStatus(spot)), + "add", + ComputerName)); + } + } + + /// The colour the bandmap would give the station, as N1MM names it. + private string SpotStatus(Spot spot) => JudgeSpot(spot) switch + { + { IsDupe: true } => "dupe", + { NewMultipliers.Count: 1 } => "single mult", + { NewMultipliers.Count: > 1 } => "double mult", + _ => "new qso", + }; + + private string SpotMode(Frequency frequency) => BandPlan.ModeAt(frequency) switch + { + ModeCategory.Phone => frequency.Megahertz < 10 ? "LSB" : "USB", + ModeCategory.Digital => "RTTY", + _ => "CW", + }; + + private void SendScore() + { + if (data is null + || !data.IsSending(BroadcastKind.Score) + || Logging is null + || DateTime.UtcNow - scoreSent < ScoreInterval) + { + return; + } + scoreSent = DateTime.UtcNow; + ContestInstance instance = Logging.Instance; + StationInfo me = Settings.Station.ToStationInfo(); + IReadOnlyList multiplierNames = N1mmMultiplierNames.For(Logging.Contest, me); + List rows = ScoreBreakdown.For(Logging.Log.Qsos, Logging.Contest) + .Select(r => new ScoreReportRow(r.Band, r.Mode, r.Qsos, r.Multipliers, r.Points)) + .ToList(); + data.Send( + BroadcastKind.Score, + BroadcastMessages.Score(new ScoreReport( + ScoreContestName(instance), + Settings.Station.Callsign, + instance.Operators, + instance.PowerCategory, + instance.AssistedCategory, + instance.TransmitterCategory, + instance.OperatorCategory, + instance.BandCategory, + instance.ModeCategory, + instance.OverlayCategory, + instance.StationCategory, + Settings.Station.Club, + Settings.Station.CountryPrefix, + Settings.Station.CqZone, + Settings.Station.ItuZone, + Settings.Station.ArrlSection, + Settings.Station.State.Length > 0 ? Settings.Station.State : Settings.Station.Province, + Settings.Station.GridSquare, + multiplierNames, + rows, + Logging.Log.TotalScore, + DateTime.UtcNow))); + } + + /// The state QSO parties carry the state in front of the Cabrillo name. + private string ScoreContestName(ContestInstance instance) => + Logging!.Contest.Name == "QSOPARTY" + ? $"{instance.SubType}-{Logging.Contest.CabrilloName}" + : Logging.Contest.CabrilloName; + + /// The callsign being typed, for a program that looks it up. It goes out + /// when the operator leaves the callsign box with a call that has not been + /// sent yet. + public void SendLookup(OperatingPosition position) + { + string call = position.Entry.Call.Trim(); + if (data is null || !data.IsSending(BroadcastKind.Lookup) || call.Length == 0 || call == lookupSent) + { + return; + } + lookupSent = call; + data.Send( + BroadcastKind.Lookup, + ContactMessage.WriteLookup(position.InProgress(), Settings.Station.Callsign, ComputerName)); + } + + /// A contact another logging computer made, passed on to the programs that + /// read the contact broadcast. Edits and deletes are not passed on. + private void ForwardToPrograms(ContactUpdate update) + { + if (Settings.BroadcastAllComputers && update is ContactLogged logged) + { + _ = network?.SendForwardedAsync(logged.Qso, Settings.Station.Callsign); + } + } +} diff --git a/src/Nonemm.App/AppSession.cs b/src/Nonemm.App/AppSession.cs index 5f27f27..a574cb5 100644 --- a/src/Nonemm.App/AppSession.cs +++ b/src/Nonemm.App/AppSession.cs @@ -18,7 +18,7 @@ namespace Nonemm.App; /// Everything the running program owns: the log store, the contest in progress, /// the bandmap, the radio and the cluster. The windows read from this and one /// `Changed` event tells them all to redraw. -public sealed class AppSession : IDisposable +public sealed partial class AppSession : IDisposable { /// How often this station tells the others how it is doing. N1MM sends its /// status on the same sort of interval; every second would read the log @@ -350,6 +350,7 @@ public sealed class AppSession : IDisposable Available = new AvailableStations(positions[0], Bandmap); Save(Settings with { ContestNumber = contestNumber }); ContestChanged?.Invoke(this, EventArgs.Empty); + SendAppInfo(); } /// A frequency on the band a number was taken on. The message carries a @@ -412,6 +413,7 @@ public sealed class AppSession : IDisposable // the socket thread must not touch the log: every other change to it is // made where the windows read it link.UpdateArrived += (_, update) => QueueFromNetwork(update); + link.UpdateArrived += (_, update) => ForwardToPrograms(update); // the resync reads the log rather than being handed it, and another // station can ask for a rescore when its resync is done link.Log = new SessionContactLog(this); @@ -967,13 +969,17 @@ public sealed class AppSession : IDisposable if (keeping.Count > 0) { Bandmap.AddRange(keeping); + SendSpots(keeping); } } /// Whether the station has already been worked, which decides whether a /// spot is moved about: N1MM leaves a dupe where it is. - private bool IsDupe(Spot spot) => - Logging is not null && Logging.Log.Judge(new Qso + private bool IsDupe(Spot spot) => JudgeSpot(spot) is { IsDupe: true }; + + /// What logging the station of a spot would do, or null with no contest open. + private Verdict? JudgeSpot(Spot spot) => + Logging?.Log.Judge(new Qso { Id = "", TimestampUtc = spot.AtUtc, @@ -981,7 +987,7 @@ public sealed class AppSession : IDisposable Frequency = spot.Frequency, Mode = Position?.Mode ?? Modes.Cw, ContestName = Logging.Contest.Name, - }) is { IsDupe: true }; + }); private void ApplySpotSettings() { @@ -1010,6 +1016,8 @@ public sealed class AppSession : IDisposable DisposeRadios(); cluster?.Dispose(); announcing?.Stop(); + broadcasting?.Stop(); + data?.Dispose(); network?.Dispose(); link?.Dispose(); alternating?.Dispose(); diff --git a/src/Nonemm.App/Configuration/Settings.cs b/src/Nonemm.App/Configuration/Settings.cs index 2856346..a747227 100644 --- a/src/Nonemm.App/Configuration/Settings.cs +++ b/src/Nonemm.App/Configuration/Settings.cs @@ -130,6 +130,35 @@ public sealed record Settings public IReadOnlyList NetworkPeers { get; init; } = []; + /// Also send the contacts the other logging computers make to the programs + /// that read the contact broadcast. N1MM's All Computers. + public bool BroadcastAllComputers { get; init; } + + /// The other rows of Config > Broadcast Data. The contacts row is + /// `NetworkEnabled` and `NetworkPeers`. + public BroadcastChannel BroadcastAppInfo { get; init; } = new(); + + public BroadcastChannel BroadcastRadio { get; init; } = new(); + + public BroadcastChannel BroadcastSpots { get; init; } = new(); + + public BroadcastChannel BroadcastRotor { get; init; } = new(); + + public BroadcastChannel BroadcastScore { get; init; } = new(); + + public BroadcastChannel BroadcastLookup { get; init; } = new(); + + public IReadOnlyDictionary BroadcastChannels() => + new Dictionary + { + [BroadcastKind.AppInfo] = BroadcastAppInfo, + [BroadcastKind.Radio] = BroadcastRadio, + [BroadcastKind.Spots] = BroadcastSpots, + [BroadcastKind.Rotor] = BroadcastRotor, + [BroadcastKind.Score] = BroadcastScore, + [BroadcastKind.Lookup] = BroadcastLookup, + }; + /// The link to the other logging computers of a multi-operator entry, on /// N1MM's port 12070. It is not the same thing as `NetworkEnabled`, which /// is the XML broadcast on 12060 that other programs read; a station can diff --git a/src/Nonemm.App/Dialogs/BroadcastDataDialog.axaml b/src/Nonemm.App/Dialogs/BroadcastDataDialog.axaml new file mode 100644 index 0000000..e33e71f --- /dev/null +++ b/src/Nonemm.App/Dialogs/BroadcastDataDialog.axaml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + +