using System.Globalization; using System.Text; using Nonemm.Contests; using Nonemm.Core; using Nonemm.Core.Calls; namespace Nonemm.Session; /// Fills in a function key message. The macro names and what they stand for are /// N1MM's, from its published function-key documentation, so a message file /// written for either program says the same thing. /// /// Only the text macros are here. N1MM also has action macros — `{WIPE}`, /// `{LOG}`, `{RUN}`, the CAT and SO2R families — which run a program command /// rather than standing for text; they expand to nothing, so a message that /// holds one still sends the right characters. public static class MessageExpander { /// Cut numbers: a contest operator sends T for zero and N for nine because /// they are shorter. private const string CutDigits = "T12345678N"; /// `other` is the radio the operator is not on, for the macros that pass a /// station to the other band. Null when the station has one radio, and then /// those macros stand for nothing. public static string Expand(string template, RadioPosition session, RadioPosition? other = null) { StringBuilder text = new(); int at = 0; while (at < template.Length) { char c = template[at]; if (c == '{') { int close = template.IndexOf('}', at + 1); if (close < 0) { text.Append(template[at..]); break; } text.Append(TextMacro(template[(at + 1)..close], session, other) ?? ""); at = close + 1; continue; } // the single-character macros, which take no braces if (c is '*' or '!' or '#') { text.Append(TextMacro(c, session)); at++; continue; } text.Append(c); at++; } return text.ToString(); } /// The macros that need no braces: `*`, `!` and `#`. public static string TextMacro(char macro, RadioPosition session) => macro switch { '*' => session.Me.Callsign, '!' => TheirCall(session), _ => SerialNumber(session), }; /// The number for this contact, or the one just logged when nothing is /// typed in the callsign box, so a repeat after logging sends the same /// number again. private static string SerialNumber(RadioPosition session) => session.Entry.Call.Trim().Length == 0 && LastLogged(session) is { } last ? last.SentNumber.ToString(CultureInfo.InvariantCulture) : session.SentNumber.ToString(CultureInfo.InvariantCulture); /// What the macro stands for, or null when it is not a text macro — an /// action macro, or a name this program does not know. public static string? TextMacro(string name, RadioPosition session, RadioPosition? other) => name.ToUpperInvariant() switch { "MYCALL" => session.Me.Callsign, "CALL" => TheirCall(session), "LASTCALL" => LastLogged(session)?.Call.Text ?? "", "PREVNR" => LastLogged(session) is { } last ? last.SentNumber.ToString("000", CultureInfo.InvariantCulture) : "", "EXCH" => session.Instance.SentExchange, "SENTRST" => session.DefaultReport(), "SENTRSTCUT" => Cut(session.DefaultReport()), "GRID" => session.Me.GridSquare, "GRIDSQUARE" => Theirs(session, ExchangeSlot.GridSquare, known => known.GridSquare), "GRIDBEARING" => Bearing(session, reverse: false), "REVGRIDBEARING" => Bearing(session, reverse: true), // N1MM's own documentation spells this one KMGRIGDISTANCE; the // program answers to KMGRIDDISTANCE, so that is what is read here "KMGRIDDISTANCE" => Distance(session), "NAME" => Name(session), "NAMEANDSPACE" => Name(session) is { Length: > 0 } theirName ? theirName + " " : "", "CHNAME" => session.Session.History.Find(session.Entry.Call)?.Name ?? "", "FREQ" => FrequencyText(session, session.Frequency, round: false), "FREQROUND" => FrequencyText(session, session.Frequency, round: true), "OTHERFREQ" => other is null ? "" : FrequencyText(session, other.Frequency, round: false), "OTHERFREQROUND" => other is null ? "" : FrequencyText(session, other.Frequency, round: true), "OTHERMHZ" => other is null ? "" : Megahertz(session, other.Frequency), "OTHERBAND" => other is null ? "" : Bands.ForFrequency(other.Frequency)?.Name ?? "", "LRMHZ" => Megahertz(session, Radio(session, other, 1)), "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), _ => null, }; /// The call being worked, or the last one logged when the box is empty, /// which is what N1MM sends for both `{CALL}` and `!`. private static string TheirCall(RadioPosition session) => session.Entry.Call.Trim() is { Length: > 0 } typed ? typed : LastLogged(session)?.Call.Text ?? ""; private static Qso? LastLogged(RadioPosition session) => session.Log.Qsos.Count > 0 ? session.Log.Qsos[^1] : null; private static string Name(RadioPosition session) => Theirs(session, ExchangeSlot.Name, known => known.Name); /// What the operator has typed in that exchange box, or what the call /// history file says when the contest has no such box or it is empty. private static string Theirs( RadioPosition session, ExchangeSlot slot, Func fromHistory) { string typed = session.Entry.ValueOf(slot).Trim(); if (typed.Length > 0) { return typed; } CallHistoryEntry? known = session.Session.History.Find(session.Entry.Call); return known is null ? "" : fromHistory(known); } /// Kilohertz to one decimal, and on CW the decimal point is sent as R. private static string FrequencyText(RadioPosition session, Frequency frequency, bool round) { if (round) { return Math.Round(frequency.Kilohertz).ToString("0", CultureInfo.InvariantCulture); } return CwDecimal(session, frequency.Kilohertz.ToString("0.0", CultureInfo.InvariantCulture)); } /// The band in megahertz, as N1MM sends it: 14, or 3R5 on CW. private static string Megahertz(RadioPosition session, Frequency frequency) => Bands.ForFrequency(frequency) is { } band ? CwDecimal(session, band.MegahertzLabel.ToString("0.###", CultureInfo.InvariantCulture)) : ""; private static Frequency Radio(RadioPosition session, RadioPosition? other, int number) => session.RadioNumber == number ? session.Frequency : other?.RadioNumber == number ? other.Frequency : Frequency.Zero; private static string CwDecimal(RadioPosition session, string text) => session.Mode.Category == ModeCategory.Cw ? text.Replace('.', 'R') : text; private static string Bearing(RadioPosition session, bool reverse) { if (!Grids(session, out GridSquare mine, out GridSquare theirs)) { return ""; } double bearing = reverse ? theirs.BearingTo(mine) : mine.BearingTo(theirs); return Math.Round(bearing).ToString("0", CultureInfo.InvariantCulture); } private static string Distance(RadioPosition session) => Grids(session, out GridSquare mine, out GridSquare theirs) ? Math.Round(mine.DistanceTo(theirs)).ToString("0", CultureInfo.InvariantCulture) : ""; private static bool Grids(RadioPosition session, out GridSquare mine, out GridSquare theirs) { theirs = default; return GridSquare.TryParse(session.Me.GridSquare, out mine) && GridSquare.TryParse(Theirs(session, ExchangeSlot.GridSquare, known => known.GridSquare), out theirs); } private static string Cut(string digits) => new(digits.Select(d => char.IsAsciiDigit(d) ? CutDigits[d - '0'] : d).ToArray()); }