diff --git a/docs/unfinished.md b/docs/unfinished.md
index 21bb1f1..a6be3c2 100644
--- a/docs/unfinished.md
+++ b/docs/unfinished.md
@@ -405,9 +405,18 @@ by hand stays closed until it is opened again, because the next beacon would
otherwise open it within ten seconds. The Resynchronize and Rescore tabs work
on a period — the contest, since a time, or the last few hours. Options holds
what there is something behind: Log QSOs at all Stations, the message trace, and
-Run1/Run2. Left out are the transmit interlocks — this program does not block a
-transmitter, and a station that claimed it did would be waited on for nothing —
-and the two telnet options, which wait on the master relaying cluster spots.
+Run1/Run2. Left out are the two telnet options, which wait on the master relaying cluster
+spots.
+
+**Two signals at once.** N1MM blocks the transmitter when the entry is about to
+break its own class. This program does not touch the transmitter — a logger that
+decides for itself when the operator may not send loses a contact every time it
+is wrong — so the entry window puts a red box across its top instead, naming the
+other station and the band. `TransmitRules` holds what is allowed: one signal in
+a one-transmitter entry, two on different bands in a two-transmitter entry, and
+never two on one band whatever the class. A station that has not said which band
+it is on counts as somewhere else, because a guess there puts the box in front of
+an operator doing nothing wrong.
**What the entry agrees on.** Four things have to match across the stations of
an entry, and each is now checked rather than assumed:
diff --git a/src/Nonemm.App/Windows/EntryWindow.Macros.cs b/src/Nonemm.App/Windows/EntryWindow.Macros.cs
index 94496c3..34c8ab2 100644
--- a/src/Nonemm.App/Windows/EntryWindow.Macros.cs
+++ b/src/Nonemm.App/Windows/EntryWindow.Macros.cs
@@ -185,6 +185,8 @@ public sealed partial class EntryWindow
{
try
{
+ // what the other operators read in their network status window
+ sendingWhat = text.Length > 40 ? text[..40] : text;
sending = true;
Refresh();
await keyer.SendAsync(text);
diff --git a/src/Nonemm.App/Windows/EntryWindow.axaml b/src/Nonemm.App/Windows/EntryWindow.axaml
index 2f7b08a..11f3fdf 100644
--- a/src/Nonemm.App/Windows/EntryWindow.axaml
+++ b/src/Nonemm.App/Windows/EntryWindow.axaml
@@ -127,6 +127,15 @@
+
+
+
+
+
others = link.Stations
+ .Where(s => !s.IsMine && s.IsTransmitting)
+ .Select(s => new Transmitter(s.ComputerName, s.Band, s.Mode?.Category))
+ .ToList();
+ return TransmitRules.Violation(
+ Logging.Log.Entry,
+ new Transmitter(link.ComputerName, Bands.ForFrequency(Logging.Frequency), Logging.Mode.Category),
+ others);
}
/// The countdown N1MM puts in its info window: how long the station still
diff --git a/src/Nonemm.Contests/TransmitRules.cs b/src/Nonemm.Contests/TransmitRules.cs
new file mode 100644
index 0000000..0375a00
--- /dev/null
+++ b/src/Nonemm.Contests/TransmitRules.cs
@@ -0,0 +1,96 @@
+using Nonemm.Core;
+
+namespace Nonemm.Contests;
+
+/// One station of the entry with its key down, as the others see it.
+public sealed record Transmitter(string ComputerName, Band? Band, ModeCategory? Mode);
+
+/// How many signals a multi-operator entry may have on the air at once, and on
+/// which bands.
+///
+/// N1MM blocks the transmitter when the entry is about to break these. This
+/// program does not touch the transmitter — a logger that decides for itself
+/// when the operator may not send is a logger that loses a contact when it is
+/// wrong — so it says what is happening and leaves it to the operator.
+///
+/// | Entry | What is allowed |
+/// |---|---|
+/// | one transmitter | one signal on the air |
+/// | two transmitters | two signals, on different bands |
+/// | anything else | as many as the sponsor allows, one per band |
+///
+/// Two signals on one band is wrong in every multi-operator class, which is why
+/// it is checked whatever the category says. An entry that is neither one nor
+/// two transmitters — multi-unlimited, and a single operator with two radios —
+/// is only held to that.
+public static class TransmitRules
+{
+ /// What is wrong with this station transmitting now, or null when nothing
+ /// is. `others` is every other station of the entry that has its key down.
+ public static string? Violation(
+ ContestEntry entry,
+ Transmitter me,
+ IReadOnlyList others)
+ {
+ if (others.Count == 0)
+ {
+ return null;
+ }
+ if (others.FirstOrDefault(o => SameBand(o, me) && o.Mode == me.Mode) is { } sameMode)
+ {
+ return $"{sameMode.ComputerName} is transmitting on {Where(me)} as well";
+ }
+ if (others.FirstOrDefault(o => SameBand(o, me)) is { } sameBand)
+ {
+ return $"{sameBand.ComputerName} is transmitting on {me.Band?.Name} as well";
+ }
+ int allowed = Transmitters(entry);
+ if (allowed > 0 && others.Count + 1 > allowed)
+ {
+ string signals = $"{others.Count + 1} signals";
+ string on = string.Join(", ", others.Select(Where).Prepend(Where(me)));
+ return allowed == 1
+ ? $"{signals} on the air — a one-transmitter entry may have one: {on}"
+ : $"{signals} on the air — a two-transmitter entry may have two: {on}";
+ }
+ return null;
+ }
+
+ /// How many signals the entry may have at once, or zero when the category
+ /// does not say. N1MM reads the number off the operator category on
+ /// Cabrillo 2.0 and off the transmitter category after it, and so does
+ /// this.
+ private static int Transmitters(ContestEntry entry)
+ {
+ if (!entry.OperatorCategory.StartsWith("MULTI", StringComparison.OrdinalIgnoreCase))
+ {
+ return 0;
+ }
+ if (Is(entry, "MULTI-ONE", "ONE"))
+ {
+ return 1;
+ }
+ return Is(entry, "MULTI-TWO", "TWO") ? 2 : 0;
+ }
+
+ private static bool Is(ContestEntry entry, string operatorCategory, string transmitterCategory) =>
+ entry.OperatorCategory.Equals(operatorCategory, StringComparison.OrdinalIgnoreCase)
+ || entry.TransmitterCategory.Equals(transmitterCategory, StringComparison.OrdinalIgnoreCase);
+
+ /// Two stations are on the same band only when both say which band they are
+ /// on. A station that has not said counts as somewhere else: a guess here
+ /// puts a red box in front of an operator who is doing nothing wrong.
+ private static bool SameBand(Transmitter one, Transmitter other) =>
+ one.Band is not null && other.Band is not null && one.Band == other.Band;
+
+ private static string Where(Transmitter station) =>
+ $"{station.Band?.Name ?? "somewhere"} {Named(station.Mode)}".TrimEnd();
+
+ private static string Named(ModeCategory? mode) => mode switch
+ {
+ ModeCategory.Cw => "CW",
+ ModeCategory.Phone => "phone",
+ ModeCategory.Digital => "digital",
+ _ => "",
+ };
+}
diff --git a/tests/Nonemm.Contests.Tests/TransmitRulesTests.cs b/tests/Nonemm.Contests.Tests/TransmitRulesTests.cs
new file mode 100644
index 0000000..693e779
--- /dev/null
+++ b/tests/Nonemm.Contests.Tests/TransmitRulesTests.cs
@@ -0,0 +1,120 @@
+using Nonemm.Core;
+
+namespace Nonemm.Contests.Tests;
+
+/// How many signals a multi-operator entry may have on the air, and what the
+/// entry window says when there is one too many.
+public class TransmitRulesTests
+{
+ private static readonly ContestEntry MultiOne = new()
+ {
+ OperatorCategory = "MULTI-ONE",
+ TransmitterCategory = "ONE",
+ };
+
+ private static readonly ContestEntry MultiTwo = new()
+ {
+ OperatorCategory = "MULTI-TWO",
+ TransmitterCategory = "TWO",
+ };
+
+ private static readonly ContestEntry Unlimited = new()
+ {
+ OperatorCategory = "MULTI-UNLIMITED",
+ TransmitterCategory = "UNLIMITED",
+ };
+
+ private static Transmitter On(string name, Band band, ModeCategory mode = ModeCategory.Cw) =>
+ new(name, band, mode);
+
+ [Fact]
+ public void OneSignalIsAlwaysAllowed() =>
+ Assert.Null(TransmitRules.Violation(MultiOne, On("RUN-PC", Bands.Band20M), []));
+
+ /// Two signals on one band and mode is wrong in every class.
+ [Fact]
+ public void TwoStationsOnOneBandAndModeIsNamed()
+ {
+ string? why = TransmitRules.Violation(
+ Unlimited,
+ On("RUN-PC", Bands.Band20M),
+ [On("MULT-PC", Bands.Band20M)]);
+
+ Assert.Equal("MULT-PC is transmitting on 20M CW as well", why);
+ }
+
+ [Fact]
+ public void TwoStationsOnOneBandInDifferentModesIsNamed()
+ {
+ string? why = TransmitRules.Violation(
+ Unlimited,
+ On("RUN-PC", Bands.Band20M),
+ [On("MULT-PC", Bands.Band20M, ModeCategory.Phone)]);
+
+ Assert.Equal("MULT-PC is transmitting on 20M as well", why);
+ }
+
+ /// A one-transmitter entry may have one signal, wherever the other station
+ /// is.
+ [Fact]
+ public void ASecondSignalBreaksAOneTransmitterEntry()
+ {
+ string? why = TransmitRules.Violation(
+ MultiOne,
+ On("RUN-PC", Bands.Band20M),
+ [On("MULT-PC", Bands.Band40M)]);
+
+ Assert.NotNull(why);
+ Assert.Contains("one-transmitter entry", why);
+ Assert.Contains("20M CW", why);
+ Assert.Contains("40M CW", why);
+ }
+
+ [Fact]
+ public void TwoSignalsOnDifferentBandsAreFineForTwoTransmitters() =>
+ Assert.Null(TransmitRules.Violation(
+ MultiTwo,
+ On("RUN-PC", Bands.Band20M),
+ [On("MULT-PC", Bands.Band40M)]));
+
+ [Fact]
+ public void AThirdSignalBreaksATwoTransmitterEntry()
+ {
+ string? why = TransmitRules.Violation(
+ MultiTwo,
+ On("RUN-PC", Bands.Band20M),
+ [On("MULT-PC", Bands.Band40M), On("MULT2-PC", Bands.Band15M)]);
+
+ Assert.NotNull(why);
+ Assert.Contains("two-transmitter entry", why);
+ }
+
+ /// A class that does not cap the transmitters is held to the one rule that
+ /// applies to everybody.
+ [Fact]
+ public void AnUnlimitedEntryMayHaveASignalPerBand() =>
+ Assert.Null(TransmitRules.Violation(
+ Unlimited,
+ On("RUN-PC", Bands.Band20M),
+ [On("MULT-PC", Bands.Band40M), On("MULT2-PC", Bands.Band15M)]));
+
+ /// A station that has not said where it is counts as somewhere else. A
+ /// guess here puts a red box in front of an operator doing nothing wrong.
+ [Fact]
+ public void AStationWithNoBandIsNotCountedAsOnThisOne() =>
+ Assert.Null(TransmitRules.Violation(
+ Unlimited,
+ On("RUN-PC", Bands.Band20M),
+ [new Transmitter("MULT-PC", null, null)]));
+
+ /// A single operator with two radios is nobody's multi-operator entry, but
+ /// two signals on one band is still two signals on one band.
+ [Fact]
+ public void ASingleOperatorIsOnlyHeldToTheOneBandRule()
+ {
+ ContestEntry single = new() { OperatorCategory = "SINGLE-OP", TransmitterCategory = "ONE" };
+
+ Assert.Null(TransmitRules.Violation(single, On("RUN-PC", Bands.Band20M), [On("SO2R-PC", Bands.Band40M)]));
+ Assert.NotNull(TransmitRules.Violation(single, On("RUN-PC", Bands.Band20M), [On("SO2R-PC", Bands.Band20M)]));
+ }
+}