From 6f2f39768cde969dbca43766320d8265d85937c8 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Thu, 3 Sep 2026 15:26:42 +0000 Subject: [PATCH] Show the other computers, and apply the ten-minute rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three pieces on top of the link: **The network status window**, on the entry window's Window menu. A row per station, this computer included the way N1MM shows it: number, address, operator, band, mode, run, transmit, pass frequency, the last message type, how long ago it arrived, the counts each way and the echo round trip. A station that broadcast the wrong version is named under the table in red — that is the one fault where everything looks connected and nothing arrives. The box at the bottom sends a line of chat, and Echo asks every station whether it is there. **Where this station is** goes out once a second when it has changed. The frequency, the mode and run have a dozen places they can change from — the radio moving, a band button, a QSY typed into the callsign box — so it is read and compared rather than announced from each of them. **The ten-minute rule.** `BandChangeRules` already counted changes and the stay on a band, and the entry window already showed the countdown, but only a user-defined contest carried a rule, so every built-in contest allowed anything. `ForCategory` gives a multi-operator entry with one or two transmitters a ten-minute stay, which is N1MM's fallback in `ContestInstance.BandChangeTimerDuration` for every contest that does not name its own. Its per-contest table is a few hundred cases in a decompiled hash switch and is not repeated. Config ▸ Edit Networked-Computer Names now covers both networks: the 12060 broadcast to other programs and the 12070 link, with the station number, the port, the version to claim and stations named by address for a network where a broadcast does not reach. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PdAYHcdRktqKry7nk414TU --- docs/unfinished.md | 50 ++++ src/Nonemm.App/AppSession.cs | 101 ++++++++ src/Nonemm.App/Configuration/Settings.cs | 24 ++ src/Nonemm.App/Dialogs/NetworkDialog.axaml | 18 +- src/Nonemm.App/Dialogs/NetworkDialog.axaml.cs | 21 ++ src/Nonemm.App/Windows/EntryWindow.Menu.cs | 16 +- src/Nonemm.App/Windows/EntryWindow.axaml | 1 + .../Windows/NetworkStatusWindow.axaml | 23 ++ .../Windows/NetworkStatusWindow.axaml.cs | 236 ++++++++++++++++++ src/Nonemm.Contests/BandChangeRules.cs | 42 ++++ src/Nonemm.Contests/Contest.cs | 7 +- .../BandChangeRulesTests.cs | 52 ++++ 12 files changed, 588 insertions(+), 3 deletions(-) create mode 100644 src/Nonemm.App/Windows/NetworkStatusWindow.axaml create mode 100644 src/Nonemm.App/Windows/NetworkStatusWindow.axaml.cs diff --git a/docs/unfinished.md b/docs/unfinished.md index 8b6392a..c2aaa11 100644 --- a/docs/unfinished.md +++ b/docs/unfinished.md @@ -16,6 +16,7 @@ is still sitting there undiscovered. | `OtrspBox` | a `MemoryStream`, checking the bytes | no real SO2R box. Command forms are from N1MM's `N1MMPort.cs`. | | `ClusterClient` | a node fake over a real socket, sending the telnet negotiation, the login prompt and spot lines | no live cluster node. Which nodes send bare CR, and which send option negotiation, is guessed from N1MM's code. | | `StationNetwork` | the message format, round-tripped | no second station, and no N1MM on the same network. | +| `StationLink` | the bytes of every message, and two links talking over loopback | no real N1MM has ever been on the other end. The version check is the thing to try first: N1MM turns away a station whose version is not its own, and this program has to be told what to claim. | | `CwDaemonSender` | the UDP messages, and a fake daemon that answers the `h` reply request | no `cwdaemon`, no radio keyed. | | `MmttyEngine` and the Wine bridge | MMTTY 1.70 under Wine 10 on a machine with a sound card: started, keyed, a message transmitted and decoded back off the air, and stopped | no radio. No FSK through EXTFSK, no PTT on a serial port, and 2Tone has never been run. Keying was wrong until 2026-09-01: `SetMmttyPTT` stops a transmission and does not start one, which is why nothing ever went out before then. `docs/digital-bridge.md` | | `WinkeyerSender` | the status-byte reader, on its own | no test of the serial side, and no WinKeyer. The host-mode open sequence is from the WinKeyer datasheet; the status bits are from N1MM's `Winkey.cs`. | @@ -348,6 +349,55 @@ keyed. Whether MMTTY holds it there with an empty buffer has not been measured: the probe run that says it does was on a machine with no sound card, where MMTTY does not really transmit. +**The link between the logging computers.** N1MM has two networks and this +program now has both. Port 12060 carries XML to other programs — a spot tool, a +score poster — which is `StationNetwork`. Computer to computer, N1MM uses port +12070 and a different protocol: `StationLink`. + +That protocol is a UDP broadcast to find the other computers and a TCP +connection to each of them, opened both ways. A message reads + +``` +DATA__07%SHACK-PC%QSO%2026-09-03 12:34:56%DL1ABC%…~__DATA +``` + +which is the sending station's number, its computer name, the message type and +the fields of that type. `%` and `~` cannot appear in a field, so N1MM writes +`!` in their place. What is read and sent: contacts, edits, deletes, resyncs, +`IAM`, the echo pair, chat, the pass frequency, transmit on and off, and where a +station is. The rest of N1MM's forty-odd types — the score and sked windows, the +log check, the spot lists, the serial-number pool — are passed over, which +N1MM's own reader also does for a type it does not know. + +**The version has to match.** N1MM compares the version in the beacon with its +own and, when they differ, puts up "Software versions must match. Update N1MM+." +and drops the station. So the version this program broadcasts is a setting — +Config ▸ Edit Networked-Computer Names — and it has to be the version of the +N1MM copies beside it. Help ▸ About in N1MM says which. A station that +broadcasts something else is kept in the list and the network status window +names it, because that is the one fault where everything looks connected and +nothing arrives. + +Where a station is goes out once a second when it has changed, rather than from +each of the dozen places it can change from. In a multi-single entry that is +what the other operators watch. + +**The ten-minute rule.** `BandChangeRules` counts band changes and the stay on a +band, and the entry window shows the countdown. What was missing was the rule +itself: only a user-defined contest carried one, so every built-in contest +allowed anything. `BandChangeRules.ForCategory` now gives a multi-operator entry +with one or two transmitters a ten-minute stay, which is N1MM's fallback for +every contest that does not name a number of its own, and a contest that caps +the changes per hour overrides `Contest.BandChangesFor`. N1MM's per-contest table +is not repeated: it is a few hundred cases in a decompiled hash switch, and the +contests this station enters are the ones worth reading out of it one at a time. + +What the link does not do yet: N1MM's resync, where a station that has been +away asks another for the contacts it missed, and the log check that compares +two stations' logs row by row. Both are message types this program can already +frame; neither is written. There is also no guard against two stations taking +the same station number. + **Voice keying.** `MessageSender` was written to cover a voice keyer playing a recording, and nothing implements it. Each operator's recordings folder is stored with the rest of his settings and waits on the same thing. No DVK support either, so the second radio diff --git a/src/Nonemm.App/AppSession.cs b/src/Nonemm.App/AppSession.cs index fa226d9..0474233 100644 --- a/src/Nonemm.App/AppSession.cs +++ b/src/Nonemm.App/AppSession.cs @@ -25,6 +25,12 @@ public sealed class AppSession : IDisposable private int activeRadio; private ClusterClient? cluster; private StationNetwork? network; + private StationLink? link; + + /// What was last said to the other stations about where this one is, so it + /// is said again only when it changes. + private (Frequency Where, string Mode, bool Running, int Radio) announced; + private DispatcherTimer? announcing; private MessageSender? keyer; private AlternatingCq? alternating; private MmttyEngine? digital; @@ -124,6 +130,10 @@ public sealed class AppSession : IDisposable public StationNetwork? Network => network; + /// The link to the other logging computers, or null when the operator has + /// not turned it on. The network status window reads it. + public StationLink? Link => link; + public MessageSender? Keyer => keyer; /// The digital modem, started by the digital window rather than at startup: @@ -221,6 +231,12 @@ public sealed class AppSession : IDisposable Logging.Edited += (_, change) => _ = network?.SendEditAsync( change.Qso, Settings.Station.Callsign, change.OldCall, change.OldTimestampUtc); Logging.Deleted += (_, qso) => _ = network?.SendDeleteAsync(qso, Settings.Station.Callsign); + // and the same three to the other logging computers, which is a + // different protocol on a different port + Logging.Logged += (_, qso) => _ = link?.SendLoggedAsync(qso); + Logging.Edited += (_, change) => + _ = link?.SendEditedAsync(change.Qso, change.OldCall, change.OldTimestampUtc); + Logging.Deleted += (_, qso) => _ = link?.SendDeletedAsync(qso); Check = new CheckWindowSources(positions[0], Calls, Bandmap); Available = new AvailableStations(positions[0], Bandmap); Save(Settings with { ContestNumber = contestNumber }); @@ -234,7 +250,9 @@ public sealed class AppSession : IDisposable /// multi-operator entry, following what the settings now say. public void ApplyNetworkSettings() { + announcing?.Stop(); network?.Dispose(); + link?.Dispose(); network = null; if (!Settings.NetworkEnabled) { @@ -253,6 +271,87 @@ public sealed class AppSession : IDisposable Changed?.Invoke(this, EventArgs.Empty); } + /// Starts, restarts or stops the link to the other logging computers. It is + /// separate from `ApplyNetworkSettings` because the two are separate + /// networks: one carries XML to other programs, the other carries contacts + /// to the other computers of this entry. + public void ApplyStationLinkSettings() + { + link?.Dispose(); + link = null; + if (!Settings.StationLinkEnabled) + { + Changed?.Invoke(this, EventArgs.Empty); + return; + } + link = new StationLink( + Settings.NetworkStationName.Length > 0 ? Settings.NetworkStationName : Environment.MachineName, + Settings.StationLinkVersion, + Settings.StationNumber, + Settings.StationLinkPort) + { + Operator = Settings.Station.Callsign, + }; + // the socket thread must not touch the log: every other change to it is + // made where the windows read it + link.UpdateArrived += (_, update) => + Dispatcher.UIThread.Post(() => TakeFromNetwork(update)); + foreach (string peer in Settings.StationLinkPeers) + { + if (Peer(peer) is var (name, address, port)) + { + link.AddStation(name, address, port); + } + } + link.Start(); + // where this station is has a dozen places it can change from — the + // radio moving, a band button, a QSY typed into the callsign box, run + // turning on — so it is read once a second and sent when it has + // changed, rather than announced from each of them + announcing?.Stop(); + announced = default; + announcing = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) }; + announcing.Tick += (_, _) => AnnounceWhereIAm(); + announcing.Start(); + Changed?.Invoke(this, EventArgs.Empty); + } + + /// Tells the other stations where this one is, if it has moved. In a + /// multi-single entry this is what the other operators watch: two stations + /// on one band is a contact nobody can make. + private void AnnounceWhereIAm() + { + if (link is null || Position is not { } position) + { + return; + } + (Frequency, string, bool, int) now = + (position.Frequency, position.Mode.Name, position.IsRunning, position.RadioNumber); + if (now == announced) + { + return; + } + announced = now; + _ = link.SendBandAsync(position.Frequency, position.Mode, position.IsRunning, position.RadioNumber); + } + + /// A station named by hand, written `RUN-PC@192.168.1.5` with `:port` on + /// the end when that station is not on the usual one. Null for anything + /// else, because a line the operator has half typed is not an address. + private (string Name, string Address, int Port)? Peer(string text) + { + string[] parts = text.Split('@'); + if (parts.Length != 2 || parts[0].Trim().Length == 0) + { + return null; + } + string[] host = parts[1].Split(':'); + return ( + parts[0].Trim(), + host[0].Trim(), + host.Length > 1 && int.TryParse(host[1], out int given) ? given : Settings.StationLinkPort); + } + /// What another station did to its log, applied to ours. Nothing goes back /// out to the network, and the contact is scored here from the rules /// instead of trusting what the sender put in the message. @@ -595,7 +694,9 @@ public sealed class AppSession : IDisposable spotFlush.Dispose(); DisposeRadios(); cluster?.Dispose(); + announcing?.Stop(); network?.Dispose(); + link?.Dispose(); alternating?.Dispose(); keyer?.Dispose(); box?.Dispose(); diff --git a/src/Nonemm.App/Configuration/Settings.cs b/src/Nonemm.App/Configuration/Settings.cs index 681501b..3def8cf 100644 --- a/src/Nonemm.App/Configuration/Settings.cs +++ b/src/Nonemm.App/Configuration/Settings.cs @@ -1,6 +1,7 @@ using System.Reflection; using System.Text.Json; using Nonemm.Core; +using Nonemm.Network; using Nonemm.Session; namespace Nonemm.App.Configuration; @@ -118,6 +119,29 @@ public sealed record Settings public IReadOnlyList NetworkPeers { get; init; } = []; + /// 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 + /// want either, or both. + public bool StationLinkEnabled { get; init; } + + public int StationLinkPort { get; init; } = StationBeacon.DefaultPort; + + /// Which station of the entry this computer is. It goes into every message + /// and lets a contact say which position made it. + public int StationNumber { get; init; } = 1; + + /// The version this program claims to be on the network. N1MM refuses a + /// station whose version is not its own, so to work beside N1MM this has to + /// be the version those copies are running. The default is the version of + /// the N1MM this program was written against. + public string StationLinkVersion { get; init; } = "1.0.11364"; + + /// Stations named by hand, for a network where a broadcast does not reach + /// every computer. Each is a name and an address — `RUN-PC@192.168.1.5`, + /// with `:port` on the end when that station is not on the usual port. + public IReadOnlyList StationLinkPeers { get; init; } = []; + /// 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; } = ""; diff --git a/src/Nonemm.App/Dialogs/NetworkDialog.axaml b/src/Nonemm.App/Dialogs/NetworkDialog.axaml index d309403..95f51de 100644 --- a/src/Nonemm.App/Dialogs/NetworkDialog.axaml +++ b/src/Nonemm.App/Dialogs/NetworkDialog.axaml @@ -14,7 +14,23 @@ - + + + + + + + + + + + + + + +