Add the digital interface, running MMTTY under Wine
MMTTY and 2Tone have no socket or pipe interface: N1MM hosts XMMT.ocx and exchanges window messages with the engine. A Linux process cannot load that control, so bridge/nonemm-mmtty-bridge.exe hosts it under Wine and passes lines over its standard input and output. docs/digital-bridge.md states the protocol and what the control needs. The window is N1MM's: receive pane with coloured callsigns, grab list, call stacking, twenty-four macro buttons and the engine controls. One left click copies what is under it — a callsign to the callsign box, anything else to the exchange box the contest keeps for that kind of value. Config > Digital registers XMMT.ocx in the Wine prefix on its own, making the prefix first if it is not there. The engine, the bridge and the control start at the copies shipped beside the program. The bridge reads the control's own events. OnTranslateMessage carries only the messages the control has no event for, so nothing was ever decoded through it. hamlib's data mode names read as digital modes now, and a mode typed into the callsign box changes mode the way a frequency changes band, so a station with no radio can reach RTTY at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD
This commit is contained in:
@@ -1,14 +1,29 @@
|
||||
namespace Nonemm.Session;
|
||||
|
||||
/// The order stacked calls come off, which is what N1MM's digital window calls
|
||||
/// its call stacking modes.
|
||||
public enum StackOrder
|
||||
{
|
||||
/// Multipliers first, then the order they arrived in.
|
||||
MultipliersFirst,
|
||||
|
||||
/// The order they arrived in.
|
||||
FirstIn,
|
||||
|
||||
/// Newest first.
|
||||
LastIn,
|
||||
|
||||
/// Nothing is stacked.
|
||||
Disabled,
|
||||
}
|
||||
|
||||
/// The calls waiting to be worked while running. A station calling CQ hears
|
||||
/// more than one answer, and this holds the ones it has not got to yet, so the
|
||||
/// operator takes them one at a time instead of asking each to call again.
|
||||
/// N1MM's single-operator call stacking.
|
||||
///
|
||||
/// A call that would bring a multiplier goes to the front and the rest to the
|
||||
/// back, which is the order N1MM uses on CW and phone. N1MM's digital window
|
||||
/// offers plain first-in-first-out and last-in-first-out as well; there is no
|
||||
/// digital window here, so every mode gets that one order.
|
||||
/// The order calls come off is `Order`: multipliers first is what N1MM uses on
|
||||
/// CW and phone, and the digital window offers the other three.
|
||||
public sealed class CallStack
|
||||
{
|
||||
/// N1MM stacks nothing shorter than this.
|
||||
@@ -16,6 +31,10 @@ public sealed class CallStack
|
||||
|
||||
private readonly List<string> calls = [];
|
||||
|
||||
/// How calls come off. The digital window sets this from its call stacking
|
||||
/// menu; everything else leaves it alone.
|
||||
public StackOrder Order { get; set; } = StackOrder.MultipliersFirst;
|
||||
|
||||
/// The calls in the order they come off, so the front is next.
|
||||
public IReadOnlyList<string> Calls => calls;
|
||||
|
||||
@@ -27,16 +46,22 @@ public sealed class CallStack
|
||||
public string Top => calls.Count > 0 ? calls[0] : "";
|
||||
|
||||
/// Puts a call on the stack. A call already there is moved rather than
|
||||
/// repeated, and a multiplier goes to the front.
|
||||
/// repeated, and where it goes is up to `Order`.
|
||||
public void Add(string call, bool isMultiplier)
|
||||
{
|
||||
string wanted = call.Trim().ToUpperInvariant();
|
||||
if (wanted.Length < ShortestCall)
|
||||
if (wanted.Length < ShortestCall || Order == StackOrder.Disabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Remove(wanted);
|
||||
if (isMultiplier)
|
||||
bool toFront = Order switch
|
||||
{
|
||||
StackOrder.LastIn => true,
|
||||
StackOrder.MultipliersFirst => isMultiplier,
|
||||
_ => false,
|
||||
};
|
||||
if (toFront)
|
||||
{
|
||||
calls.Insert(0, wanted);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
using Nonemm.Contests;
|
||||
|
||||
namespace Nonemm.Session;
|
||||
|
||||
/// What a word clicked in the receive pane is worth. N1MM drops a clicked word
|
||||
/// into whichever exchange box has the cursor. That box still comes first here
|
||||
/// when it takes the word, but a word the cursor's box does not take goes to
|
||||
/// the box of the contest's exchange that holds that kind of value, so a serial
|
||||
/// number and a state land where they belong.
|
||||
public static class DigitalElement
|
||||
{
|
||||
private const string Precedences = "QABUMS";
|
||||
|
||||
/// Strips what RTTY carries around a value: the report, a colon, and the Z
|
||||
/// on a four-figure time. N1MM cuts the same three before it takes an
|
||||
/// element from a click.
|
||||
public static string Trim(string word)
|
||||
{
|
||||
string text = DigitalReceiver.Scrub(word).Replace(":", "");
|
||||
if (text.Length > 3 && text.Contains("599"))
|
||||
{
|
||||
text = text.Replace("599", "");
|
||||
}
|
||||
if (text.Length == 5 && text[^1] == 'Z' && text[..4].All(char.IsAsciiDigit))
|
||||
{
|
||||
text = text[..4];
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/// True when the box holds this kind of value. A report box is never a
|
||||
/// target: nobody clicks 599 in, and the program fills it in itself.
|
||||
public static bool Fits(ExchangeField field, string word) => field.Kind switch
|
||||
{
|
||||
ExchangeFieldKind.Report => false,
|
||||
ExchangeFieldKind.Number => IsNumber(word, 1, 6),
|
||||
ExchangeFieldKind.CqZone => IsNumber(word, 1, 2) && InRange(word, 1, 40),
|
||||
ExchangeFieldKind.ItuZone => IsNumber(word, 1, 2) && InRange(word, 1, 90),
|
||||
ExchangeFieldKind.Check => IsNumber(word, 2, 2),
|
||||
ExchangeFieldKind.Power => IsPower(word),
|
||||
ExchangeFieldKind.Precedence => word.Length == 1 && Precedences.Contains(word[0]),
|
||||
ExchangeFieldKind.Grid => IsGrid(word),
|
||||
ExchangeFieldKind.Text => word.Length > 0,
|
||||
_ => ExchangeValues.For(field.Kind).Contains(word, StringComparer.OrdinalIgnoreCase),
|
||||
};
|
||||
|
||||
/// The exchange box the word belongs in, counted the way `EntryFields`
|
||||
/// counts: 0 is the callsign box, 1 and up are the exchange boxes. Null
|
||||
/// when the contest has no box that takes it.
|
||||
public static int? FieldFor(EntryFields fields, string word)
|
||||
{
|
||||
if (word.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
// the box the cursor is in wins when it takes the word: that is where
|
||||
// N1MM puts it, and the operator has already said which box it is
|
||||
if (fields.Focus > 0 && Fits(fields.Exchange[fields.Focus - 1], word))
|
||||
{
|
||||
return fields.Focus;
|
||||
}
|
||||
// then the box that only holds this kind of value, then a number box,
|
||||
// then plain text, so a check does not land in the serial number box
|
||||
// and a section does not land in the name box
|
||||
return Pick(fields, word, ListedOrShaped)
|
||||
?? Pick(fields, word, kind => kind == ExchangeFieldKind.Number)
|
||||
?? Pick(fields, word, kind => kind == ExchangeFieldKind.Text);
|
||||
}
|
||||
|
||||
private static bool ListedOrShaped(ExchangeFieldKind kind) =>
|
||||
kind is not (ExchangeFieldKind.Number or ExchangeFieldKind.Text or ExchangeFieldKind.Report);
|
||||
|
||||
private static int? Pick(EntryFields fields, string word, Func<ExchangeFieldKind, bool> wanted)
|
||||
{
|
||||
int? filled = null;
|
||||
for (int at = 0; at < fields.Exchange.Count; at++)
|
||||
{
|
||||
ExchangeField field = fields.Exchange[at];
|
||||
if (!wanted(field.Kind) || !Fits(field, word))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (fields[at + 1].Trim().Length == 0)
|
||||
{
|
||||
return at + 1;
|
||||
}
|
||||
// every box that takes it is full, so the first one is written over
|
||||
filled ??= at + 1;
|
||||
}
|
||||
return filled;
|
||||
}
|
||||
|
||||
private static bool IsNumber(string word, int shortest, int longest) =>
|
||||
word.Length >= shortest && word.Length <= longest && word.All(char.IsAsciiDigit);
|
||||
|
||||
private static bool InRange(string word, int lowest, int highest) =>
|
||||
int.TryParse(word, out int value) && value >= lowest && value <= highest;
|
||||
|
||||
/// A power is the figures, with or without the W or KW after them.
|
||||
private static bool IsPower(string word)
|
||||
{
|
||||
string figures = word.EndsWith("KW", StringComparison.Ordinal)
|
||||
? word[..^2]
|
||||
: word.EndsWith('W') ? word[..^1] : word;
|
||||
return IsNumber(figures, 1, 4);
|
||||
}
|
||||
|
||||
/// Four or six characters: two letters, two figures, and a letter pair on a
|
||||
/// six-character locator.
|
||||
private static bool IsGrid(string word) =>
|
||||
word.Length is 4 or 6
|
||||
&& char.IsAsciiLetter(word[0]) && char.IsAsciiLetter(word[1])
|
||||
&& char.IsAsciiDigit(word[2]) && char.IsAsciiDigit(word[3])
|
||||
&& (word.Length == 4 || (char.IsAsciiLetter(word[4]) && char.IsAsciiLetter(word[5])));
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace Nonemm.Session;
|
||||
|
||||
/// The digital window's macro buttons: three rows of eight, each a label and a
|
||||
/// message, held as the same `label,message` lines as an N1MM function key
|
||||
/// file. N1MM keeps its digital macros in its admin database rather than in a
|
||||
/// file; the lines here say the same thing in the form the rest of this program
|
||||
/// already reads.
|
||||
public sealed class DigitalMacros
|
||||
{
|
||||
public const int ButtonCount = 24;
|
||||
|
||||
private readonly IReadOnlyList<FunctionKey> buttons;
|
||||
|
||||
private DigitalMacros(IReadOnlyList<FunctionKey> buttons)
|
||||
{
|
||||
this.buttons = buttons;
|
||||
}
|
||||
|
||||
public IReadOnlyList<FunctionKey> Buttons => buttons;
|
||||
|
||||
public FunctionKey this[int index] =>
|
||||
index >= 0 && index < buttons.Count ? buttons[index] : FunctionKey.Empty;
|
||||
|
||||
public static DigitalMacros Parse(string text)
|
||||
{
|
||||
List<FunctionKey> found = [];
|
||||
foreach (string line in text.Replace("\r", "").Split('\n'))
|
||||
{
|
||||
if (line.StartsWith('#'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
int comma = line.IndexOf(',');
|
||||
found.Add(comma < 0
|
||||
? new FunctionKey("", line.Trim())
|
||||
: new FunctionKey(line[..comma].Replace("&&", "&").Trim(), line[(comma + 1)..].Trim()));
|
||||
}
|
||||
while (found.Count < ButtonCount)
|
||||
{
|
||||
found.Add(FunctionKey.Empty);
|
||||
}
|
||||
return new DigitalMacros(found.Take(ButtonCount).ToList());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using System.Text;
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Session;
|
||||
|
||||
/// The decoded text as it arrives, and the callsigns in it. Characters come
|
||||
/// from the modem one at a time; a word ends at a space or a line break, and a
|
||||
/// word shaped like a callsign is reported so the window can colour it and put
|
||||
/// it in the grab list. N1MM's `DigitalProcessing.RxComData` does the same.
|
||||
public sealed class DigitalReceiver
|
||||
{
|
||||
/// How much decoded text is kept. N1MM keeps a scrollback of its own; this
|
||||
/// is enough for a long run and keeps the pane from growing without end.
|
||||
public const int Scrollback = 20_000;
|
||||
|
||||
private readonly StringBuilder text = new();
|
||||
private readonly StringBuilder word = new();
|
||||
|
||||
public string Text => text.ToString();
|
||||
|
||||
/// Text that has just arrived, for a window that appends rather than
|
||||
/// redraws.
|
||||
public event EventHandler<string>? Added;
|
||||
|
||||
/// A word shaped like a callsign, uppercased and cut back to the call.
|
||||
public event EventHandler<string>? CallHeard;
|
||||
|
||||
public void Receive(string arrived)
|
||||
{
|
||||
if (arrived.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
text.Append(arrived);
|
||||
if (text.Length > Scrollback)
|
||||
{
|
||||
text.Remove(0, text.Length - Scrollback);
|
||||
}
|
||||
Added?.Invoke(this, arrived);
|
||||
foreach (char c in arrived)
|
||||
{
|
||||
if (c is ' ' or '\r' or '\n')
|
||||
{
|
||||
Finish();
|
||||
continue;
|
||||
}
|
||||
word.Append(c);
|
||||
}
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
text.Clear();
|
||||
word.Clear();
|
||||
}
|
||||
|
||||
/// The word around a position in the text, which is what the mouse points
|
||||
/// at. Empty when the position is on a space.
|
||||
public static string WordAt(string text, int position)
|
||||
{
|
||||
if (position < 0 || position >= text.Length || IsBreak(text[position]))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
int start = position;
|
||||
while (start > 0 && !IsBreak(text[start - 1]))
|
||||
{
|
||||
start--;
|
||||
}
|
||||
int end = position;
|
||||
while (end + 1 < text.Length && !IsBreak(text[end + 1]))
|
||||
{
|
||||
end++;
|
||||
}
|
||||
return Scrub(text[start..(end + 1)]);
|
||||
}
|
||||
|
||||
/// N1MM's `ScrubSelectedData`: what the operator clicked, without the line
|
||||
/// breaks and in upper case.
|
||||
public static string Scrub(string word) =>
|
||||
word.Replace("\r", "").Replace("\n", "").Trim().ToUpperInvariant();
|
||||
|
||||
/// True when the word could be a callsign. RTTY prints noise, so a word is
|
||||
/// only offered as a call when it is shaped like one.
|
||||
public static bool IsCallsign(string word) =>
|
||||
word.Length > 0 && Callsign.Parse(word).IsPlausible;
|
||||
|
||||
/// N1MM's `Trimcall`. RTTY runs words together, so a long word with a digit
|
||||
/// in it is cut to three characters past the last digit, which is as long
|
||||
/// as a callsign gets after its number. A word with a `/` is left alone: a
|
||||
/// portable call is long for a reason.
|
||||
public static string TrimToCall(string word)
|
||||
{
|
||||
if (word.Contains('/') || word.Length < 7)
|
||||
{
|
||||
return word;
|
||||
}
|
||||
int lastDigit = -1;
|
||||
for (int at = 0; at < word.Length; at++)
|
||||
{
|
||||
if (char.IsAsciiDigit(word[at]))
|
||||
{
|
||||
lastDigit = at;
|
||||
}
|
||||
}
|
||||
return lastDigit < 0 ? word : word[..Math.Min(word.Length, lastDigit + 4)];
|
||||
}
|
||||
|
||||
private static bool IsBreak(char c) => c is ' ' or '\r' or '\n' or '\t';
|
||||
|
||||
private void Finish()
|
||||
{
|
||||
string found = Scrub(word.ToString());
|
||||
word.Clear();
|
||||
if (found.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
string call = TrimToCall(found);
|
||||
if (IsCallsign(call))
|
||||
{
|
||||
CallHeard?.Invoke(this, call);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
namespace Nonemm.Session;
|
||||
|
||||
/// Which calls the grab list keeps.
|
||||
public enum GrabFilter
|
||||
{
|
||||
/// Every call heard.
|
||||
Everything,
|
||||
|
||||
/// Nothing already in the log on this band and mode. N1MM's "no dupes".
|
||||
NotDupes,
|
||||
|
||||
/// Only calls the callsign database knows or the log has seen before,
|
||||
/// which throws away most of what noise decodes as. N1MM's "master and
|
||||
/// previous calls".
|
||||
KnownCalls,
|
||||
}
|
||||
|
||||
/// The callsigns heard on the air, newest first, waiting to be grabbed into
|
||||
/// the entry window. N1MM's `Calltext` list beside the digital window's macro
|
||||
/// buttons.
|
||||
///
|
||||
/// What is left out is N1MM's: our own call, the call already being typed, a
|
||||
/// call that is only part of our own, and — depending on the filter — dupes and
|
||||
/// calls nothing has ever heard of.
|
||||
public sealed class GrabList
|
||||
{
|
||||
private readonly List<string> calls = [];
|
||||
|
||||
public GrabList(int capacity = 30)
|
||||
{
|
||||
Capacity = capacity;
|
||||
}
|
||||
|
||||
/// How many calls are kept. The oldest falls off the end.
|
||||
public int Capacity { get; set; }
|
||||
|
||||
public bool NewestFirst { get; set; } = true;
|
||||
|
||||
public IReadOnlyList<string> Calls => calls;
|
||||
|
||||
public int Count => calls.Count;
|
||||
|
||||
/// Puts a call in the list, or moves it if it is already there. False when
|
||||
/// the call was not wanted.
|
||||
public bool Add(string call)
|
||||
{
|
||||
string wanted = call.Trim().ToUpperInvariant();
|
||||
if (wanted.Length < 3)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
calls.Remove(wanted);
|
||||
calls.Insert(NewestFirst ? 0 : calls.Count, wanted);
|
||||
while (calls.Count > Capacity)
|
||||
{
|
||||
calls.RemoveAt(NewestFirst ? calls.Count - 1 : 0);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Remove(string call) =>
|
||||
calls.RemoveAll(c => string.Equals(c, call.Trim(), StringComparison.OrdinalIgnoreCase)) > 0;
|
||||
|
||||
public void Clear() => calls.Clear();
|
||||
|
||||
/// Whether a heard call belongs in the list at all.
|
||||
public static bool Wanted(string call, string myCall, string beingTyped) =>
|
||||
call.Length >= 3
|
||||
&& !string.Equals(call, myCall, StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(call, beingTyped.Trim(), StringComparison.OrdinalIgnoreCase)
|
||||
&& !(myCall.Length > 0 && myCall.Contains(call, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
@@ -66,4 +66,11 @@ public enum MessageCommand
|
||||
|
||||
/// Empties the call stack.
|
||||
ClearStack,
|
||||
|
||||
/// N1MM's `{TX}`: the digital engine starts transmitting.
|
||||
StartTransmit,
|
||||
|
||||
/// N1MM's `{RX}`: the engine goes back to receive once the buffer is
|
||||
/// empty.
|
||||
ReturnToReceive,
|
||||
}
|
||||
|
||||
@@ -105,6 +105,9 @@ public static class MessageExpander
|
||||
"RRMHZ" => Megahertz(session, Radio(session, other, 2)),
|
||||
"TIMESTAMP" => DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture),
|
||||
"TIME2" => DateTime.UtcNow.ToString("HHmm", CultureInfo.InvariantCulture),
|
||||
// the digital window's carriage return, which starts a new line on
|
||||
// the other station's screen
|
||||
"ENTER" => "\r",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
|
||||
@@ -104,6 +104,8 @@ public sealed record MessagePlan(
|
||||
// N1MM runs the same code for both
|
||||
"LOGTHENPOP" or "LOGTHENNEXT" => new MessageAction(MessageCommand.LogThenPop),
|
||||
"CLRSTACK" => new MessageAction(MessageCommand.ClearStack),
|
||||
"TX" => new MessageAction(MessageCommand.StartTransmit),
|
||||
"RX" => new MessageAction(MessageCommand.ReturnToReceive),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Session;
|
||||
|
||||
/// Reads a mode typed into the callsign box, which is how an operator changes
|
||||
/// mode without touching a radio. N1MM takes a mode there the same way it takes
|
||||
/// a frequency, and it is the only way to reach a mode with no radio connected.
|
||||
public static class ModeEntry
|
||||
{
|
||||
/// Null when the text names no mode. `SSB`, `PH` and `PHONE` take the
|
||||
/// sideband the frequency calls for, which is what the band panel does.
|
||||
public static Mode? Parse(string text, Frequency where)
|
||||
{
|
||||
string wanted = text.Trim().ToUpperInvariant();
|
||||
return wanted switch
|
||||
{
|
||||
"" => null,
|
||||
"SSB" or "PH" or "PHONE" => Modes.ForSideband(where),
|
||||
_ => Modes.Parse(wanted),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,9 @@ public sealed class OperatingPosition
|
||||
/// The frequency typed into the callsign box, if that is what it holds.
|
||||
public Frequency? PendingQsy() => FrequencyEntry.Parse(Entry.Call);
|
||||
|
||||
/// The mode typed into the callsign box, if that is what it holds.
|
||||
public Mode? PendingMode() => ModeEntry.Parse(Entry.Call, Frequency);
|
||||
|
||||
/// What working this call on the band and mode we are on would bring,
|
||||
/// judged from the call alone. A call that has not been worked has sent no
|
||||
/// exchange, so a contest scored on what the other station sends can be
|
||||
|
||||
Reference in New Issue
Block a user