From d9f9d880f2b8c6f4e811e5890918390fef8043c1 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Fri, 28 Aug 2026 07:33:55 +0000 Subject: [PATCH] Give the telnet window the rest of what N1MM's has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The packet window showed traffic and took a command line, and everything else about the cluster lived in a dialog under Config. It is now the window N1MM has, with the same five tabs. Telnet shows the traffic with spot lines in green, what went out in blue and lines from a preferred spotter in bold. Double clicking a spot line, or "Jump to this spot", puts the radio there with the call in the entry window. Scrolling stops while the pointer is over the traffic. The client keeps the last two hundred lines, so a window opened mid-contest is not blank. Clusters keeps the operator's nodes with their ports, passwords and after-login commands, connects and disconnects, and holds the logon settings. Download fetches the published list of telnet nodes from NG3K — around fifty, with the sysop's call and a note about each — and clicking one fills the boxes in. N1MM downloads its list from its own web service, which asks the operator to opt in to data collection and is N1MM's to run, so this reads a public page instead. ClusterList takes any page with telnet:// links in a table; a page it cannot read leaves the stored list alone. Filters decide which spots reach the bandmap: bands, modes, beacons, busted calls, stations outside the call history file, blacklisted spotters and calls, spots from outside your country, continent or a list of prefixes, and how long a spot stays on the map. A busted spot is a call the callsign database has never heard that is one character away from one it knows; a call nothing resembles is kept, because that is what a new station looks like. Nothing is filtered out of the traffic itself — the operator sees everything the node sends. Buttons edits the twelve command buttons. A button takes what N1MM's takes: the message macros, several commands separated by semicolons, or {CONN} and the name of a favourite, which connects to that node instead of sending anything. The label takes the macros too. Right-clicking a button opens the editor. Config ▸ Cluster now opens this window on the Clusters tab rather than a dialog of its own, which is where N1MM keeps those settings. Three things that could take the program down while a cluster was connected: Settings.Load read a null where the property is not nullable. A file that names a key with a null value — one written before the property existed and then edited — put that null straight through, because the property's own default only runs when the key is missing. Opening the telnet window then threw on the first list it touched. Every null is now put back to the default the property declares, walking into the stored records and the lists of them. Bandmap was written from the cluster's thread and read from the window's, so a dictionary could be modified while a window enumerated it. Every method locks now. ClusterClient disposed its token source while its own loop still used it, and the retry delay sat outside the catch, so a disconnect faulted the loop task. Along the way the message macros were checked against N1MM's function-key documentation, and several were wrong. {LOGGEDCALL} is N1MM's {LASTCALL}, the serial is #, and there is no {MYZONE}; {NAME} and {GRIDSQUARE} stand for the other station's name and grid, not ours; {OTHERMHZ} is the radio the operator is not on. The single-character macros * and ! were missing. Added from the same table: {LASTCALL}, {PREVNR}, {NAMEANDSPACE}, {CHNAME}, {GRID}, the two grid bearings and the grid distance, {FREQ}, {FREQROUND}, the other-radio frequencies, {TIMESTAMP} and {TIME2}. Frequencies are formatted the way N1MM formats them, with R for the decimal point on CW. The macros that pass a station to the other band take the second radio as a new argument, and stand for nothing at a one-radio station. Left out, and written down: saving spots to a database, which N1MM keeps in its admin database rather than in the log file the two programs share; the special-calls list; the two-character busted check, which is a few hundred thousand lookups per spot against a few hundred for one character; and N1MM's action macros, which need a different shape than an expander that returns a string. Co-Authored-By: Claude Opus 5 --- Nonemm.slnx | 1 + README.md | 102 +++++- docs/unfinished.md | 37 +++ src/Nonemm.App/AppSession.cs | 41 ++- src/Nonemm.App/Configuration/Settings.cs | 82 ++++- .../Configuration/StoredClusterNode.cs | 24 ++ .../Configuration/StoredSpotFilter.cs | 70 +++++ .../Configuration/StoredTelnetButton.cs | 24 ++ .../Configuration/SupportFileDownloader.cs | 9 + src/Nonemm.App/Configuration/UserPaths.cs | 4 + src/Nonemm.App/Dialogs/ClusterDialog.axaml | 23 -- src/Nonemm.App/Dialogs/ClusterDialog.axaml.cs | 39 --- src/Nonemm.App/Windows/EntryWindow.Menu.cs | 35 +-- src/Nonemm.App/Windows/EntryWindow.axaml | 2 +- src/Nonemm.App/Windows/EntryWindow.axaml.cs | 2 +- src/Nonemm.App/Windows/PacketWindow.axaml | 17 - src/Nonemm.App/Windows/PacketWindow.axaml.cs | 61 ---- .../Windows/TelnetWindow.Buttons.cs | 68 ++++ .../Windows/TelnetWindow.Clusters.cs | 232 ++++++++++++++ .../Windows/TelnetWindow.Filters.cs | 112 +++++++ src/Nonemm.App/Windows/TelnetWindow.axaml | 148 +++++++++ src/Nonemm.App/Windows/TelnetWindow.axaml.cs | 291 ++++++++++++++++++ src/Nonemm.Core/BandPlan.cs | 17 + src/Nonemm.Core/Calls/CallDatabase.cs | 10 +- src/Nonemm.Session/MessageExpander.cs | 160 ++++++++-- src/Nonemm.Spotting/Bandmap.cs | 98 ++++-- src/Nonemm.Spotting/ClusterClient.cs | 115 +++++-- src/Nonemm.Spotting/ClusterLine.cs | 5 + src/Nonemm.Spotting/ClusterList.cs | 66 ++++ src/Nonemm.Spotting/ClusterNode.cs | 9 + src/Nonemm.Spotting/SpotFilter.cs | 164 ++++++++++ src/Nonemm.Spotting/SpotJitter.cs | 22 ++ .../Nonemm.App.Tests/Nonemm.App.Tests.csproj | 25 ++ tests/Nonemm.App.Tests/SettingsTests.cs | 67 ++++ tests/Nonemm.Core.Tests/BandPlanTests.cs | 26 ++ .../MessageExpanderTests.cs | 80 ++++- .../ClusterClientTests.cs | 15 + .../Nonemm.Spotting.Tests/ClusterListTests.cs | 48 +++ .../Nonemm.Spotting.Tests/SpotFilterTests.cs | 171 ++++++++++ .../Nonemm.Spotting.Tests/SpotJitterTests.cs | 28 ++ 40 files changed, 2278 insertions(+), 272 deletions(-) create mode 100644 src/Nonemm.App/Configuration/StoredClusterNode.cs create mode 100644 src/Nonemm.App/Configuration/StoredSpotFilter.cs create mode 100644 src/Nonemm.App/Configuration/StoredTelnetButton.cs delete mode 100644 src/Nonemm.App/Dialogs/ClusterDialog.axaml delete mode 100644 src/Nonemm.App/Dialogs/ClusterDialog.axaml.cs delete mode 100644 src/Nonemm.App/Windows/PacketWindow.axaml delete mode 100644 src/Nonemm.App/Windows/PacketWindow.axaml.cs create mode 100644 src/Nonemm.App/Windows/TelnetWindow.Buttons.cs create mode 100644 src/Nonemm.App/Windows/TelnetWindow.Clusters.cs create mode 100644 src/Nonemm.App/Windows/TelnetWindow.Filters.cs create mode 100644 src/Nonemm.App/Windows/TelnetWindow.axaml create mode 100644 src/Nonemm.App/Windows/TelnetWindow.axaml.cs create mode 100644 src/Nonemm.Spotting/ClusterLine.cs create mode 100644 src/Nonemm.Spotting/ClusterList.cs create mode 100644 src/Nonemm.Spotting/ClusterNode.cs create mode 100644 src/Nonemm.Spotting/SpotFilter.cs create mode 100644 src/Nonemm.Spotting/SpotJitter.cs create mode 100644 tests/Nonemm.App.Tests/Nonemm.App.Tests.csproj create mode 100644 tests/Nonemm.App.Tests/SettingsTests.cs create mode 100644 tests/Nonemm.Spotting.Tests/ClusterListTests.cs create mode 100644 tests/Nonemm.Spotting.Tests/SpotFilterTests.cs create mode 100644 tests/Nonemm.Spotting.Tests/SpotJitterTests.cs diff --git a/Nonemm.slnx b/Nonemm.slnx index d7f39d1..839928a 100644 --- a/Nonemm.slnx +++ b/Nonemm.slnx @@ -21,5 +21,6 @@ + diff --git a/README.md b/README.md index aa76f7a..e8da5cc 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ To work a contest: **Config → Station** for your callsign and zones, then **File → New Database** and **File → New Contest**. In the entry window, type a callsign and press **space** to move to the exchange, then **Enter** to log. Type a frequency into the callsign box and press Enter to change band. **View** -opens the log, check, bandmap, score and packet windows. +opens the log, check, bandmap, score and telnet windows. Everything that judges a station — the bar under the callsign box, a row in the check window, a spot on the bandmap, a line in the log — is coloured by the same @@ -44,10 +44,10 @@ scorer: red for a dupe, green for a new multiplier, blue for points. | Contests | CQ WW (CW, SSB, RTTY), CQ WPX (CW, SSB, RTTY), WAE (CW, SSB, RTTY), ARRL DX, IARU HF, Sweepstakes, RTTY Roundup, NAQP, general logging, and user-defined `.udc` contests | | Log | N1MM `.s3db`, Cabrillo 3.0 out, ADIF in and out | | While typing | dupe check, multiplier check, points, country and zone from the country file, exchange filled from a call history file | -| Windows | entry, log, check, bandmap, available mults and Qs, score summary, packet | +| Windows | entry, log, check, bandmap, available mults and Qs, score summary, telnet | | Editing | double-click a cell in the log, or open the whole contact with Ctrl+Y; Delete removes it. All of it goes out to the other stations | | Radio | one or two radios over hamlib `rigctld`, split, reconnecting on its own | -| Cluster | DX cluster over telnet, spots feeding the bandmap, Alt+P to spot a station | +| Cluster | DX cluster over telnet: a telnet window with the node's traffic, the published list of nodes, command buttons and spot filters, spots feeding the bandmap, Alt+P to spot a station | | Bandmap | drawn like N1MM's: a frequency scale with the receiver on it and callsigns beside it, joined by leader lines | | Network | contacts shared with the other stations of a multi-operator entry, in N1MM's own contact message | | Keying | CW through `cwdaemon` or a WinKeyer, with N1MM's message macros | @@ -92,9 +92,30 @@ mode stay where they were last typed. **Config → Keyer and messages** picks `cwdaemon` (a UDP port, usually 6789) or a WinKeyer (a serial port), sets the speed, and edits the twelve function key -messages for CW and for phone. The macros are N1MM's: `{MYCALL}`, `{CALL}`, -`{EXCH}`, `{SENTRST}`, `{SENTNR}`, `#` for the serial number, and `{SENTRSTCUT}` -for cut numbers. Escape stops sending. +messages for CW and for phone. Escape stops sending. + +The macros are N1MM's, spelled the way N1MM's function-key documentation spells +them, so a `.mc` file written for either program says the same thing. The text +macros that are filled in: + +| | | +|---|---| +| `*`, `{MYCALL}` | your callsign | +| `!`, `{CALL}` | the call being worked, or the last one logged when the box is empty | +| `#` | the serial number for this contact, or the last one when the box is empty | +| `{LASTCALL}`, `{PREVNR}` | the call and the serial of the contact just logged | +| `{EXCH}` | the sent exchange from the contest setup | +| `{SENTRST}`, `{SENTRSTCUT}` | the report you send, plain or in cut numbers | +| `{NAME}`, `{NAMEANDSPACE}`, `{CHNAME}` | the other operator's name, from the entry window or the call history file | +| `{GRID}` / `{GRIDSQUARE}` | your grid / theirs | +| `{GRIDBEARING}`, `{REVGRIDBEARING}`, `{KMGRIDDISTANCE}` | bearing and distance between the two | +| `{FREQ}`, `{FREQROUND}` | this radio's frequency in kilohertz, with `R` for the decimal point on CW | +| `{OTHERFREQ}`, `{OTHERFREQROUND}`, `{OTHERMHZ}`, `{OTHERBAND}`, `{LRMHZ}`, `{RRMHZ}` | the other radio, for passing a station | +| `{TIMESTAMP}`, `{TIME2}` | the time now | + +N1MM's action macros — `{WIPE}`, `{LOG}`, `{RUN}`, the CAT and SO2R families — +are not run; they stand for nothing, so a message that holds one still sends the +right characters. ### Editing the log @@ -318,9 +339,57 @@ always 0, so 2190M and 630M are told apart by their edges alone. ### The DX cluster -**Config → Cluster** takes the node's address, a password for the few nodes that -ask for one, and the commands to send after login. Filters are the node's -business, so whatever goes in the command box is sent as typed and left alone. +**View → Telnet**, or **Config → Cluster**, opens the telnet window. It has four +tabs, the ones N1MM has: + +- **Telnet** shows the node's traffic as it arrived, spot lines in green and + what went out in blue. Type a command at the bottom, or press one of the + buttons; up and down walk back through what has been typed. Double clicking a + spot line, or "Jump to this spot" on the right-click menu, puts the radio + there with the call in the entry window. Scrolling stops while the pointer is + over the traffic, so a line can be read while the node keeps sending. +- **Clusters** keeps a list of nodes with their names, ports, passwords and the + commands to send after login. Connect and Disconnect are there, with automatic + logon, the call to log on with, and the keep-alive interval. **Download** under + it fetches the published list of nodes from + [NG3K](https://www.ng3k.com/misc/cluster.html) — around fifty nodes with the + sysop's call, the address and a note about each — and clicking one fills the + boxes in. N1MM downloads its list from its own web service, which asks the + operator to opt in to data collection; this reads a public page instead. +- **Filters** says which spots reach the bandmap: bands, modes, beacons, busted + calls, stations outside the call history file, blacklisted spotters and calls, + whether to take spots only from your country, your continent or a list of + prefixes, and how long a spot stays on the bandmap. It also holds the preferred + spotters and the switch that randomises incoming CW spot frequencies. Nothing + is filtered out of the traffic itself; the operator sees everything the node + sends. +- **Buttons** edits the twelve command buttons: a label and its commands, or + back to the defaults. +- **Spot comment** is what goes out with the spots you send. + +The buttons come from the settings; without any, they are `sh/dx`, `sh/dx/20`, +`sh/wwv`, `sh/users`, `sh/c/n`, `help` and `bye`. A button holds what N1MM's +buttons hold: several commands separated by semicolons, the function-key message +macros — `sh/dx {MYCALL}` sends the station callsign — or `{CONN}` and the name +of a favourite, which connects to that node instead of sending anything. The +label takes the macros too, expanded once when the buttons load, so a label of +`{MYCALL}` reads as the callsign. Right-clicking a button opens the editor, and +its tooltip shows the command behind it. What is typed on the command line goes +out as typed, again as N1MM sends it. The one place this differs from N1MM: the +commands of one button go out together rather than a quarter of a second apart. + +Three of the filters are worth spelling out: + +- **Busted spots.** A spotted call the callsign database has never heard, but + which is one character away from a call it does know, is a miscopy and is + dropped. A call nothing resembles is kept, because that is what a new station + looks like. Without `MASTER.SCP` nothing is called busted. +- **Preferred spotters.** Spot lines from those spotters are written in bold, so + the spotters worth believing stand out. They are matched on the start of the + call, so `W3LPL` also covers `W3LPL-#`. Nothing is filtered by this. +- **Randomised frequencies.** Each incoming CW spot is moved thirty or sixty + hertz either way, which is what N1MM does, so the operator has to find the + station by ear. Phone and digital spots are left alone. The client speaks telnet properly: it answers the option negotiation instead of letting the control bytes turn up in the first lines of text, and it reads the @@ -328,12 +397,17 @@ login prompt out of a partial line, because nodes write `login: ` with no line ending. If no prompt arrives within ten seconds the callsign goes out anyway, which is what N1MM does. A connection that has heard nothing for four minutes gets a blank line, so the node does not drop it as idle. A dropped connection is -retried. +retried. The last two hundred lines are kept, so a window opened mid-contest is +not blank. Alt+P, or **Edit → Spot It**, puts the call being typed on the cluster at the -current frequency; with nothing typed it spots the last contact logged. The spot -also goes straight onto our own bandmap rather than waiting to come back round -from the node. +current frequency, with the comment from the Spot comment tab — which takes the +message macros too; with nothing +typed it spots the last contact logged. The spot also goes straight onto our own +bandmap rather than waiting to come back round from the node. + +Filters are applied to the spots, not to the node: it is still worth filtering +at the node as well, because that is less traffic on the wire. ### Networked stations @@ -371,7 +445,7 @@ covered by plain unit tests. Working: logging a contest end to end, live dupe and multiplier checking, eight built-in contests plus user-defined ones, Cabrillo and ADIF export, ADIF import, -the log, check, graphical bandmap, score and packet windows, editing and deleting logged +the log, check, graphical bandmap, score and telnet windows, editing and deleting logged contacts, radio control, DX cluster spots, contacts shared between networked stations, and CW keying. diff --git a/docs/unfinished.md b/docs/unfinished.md index e0ae10b..f875d95 100644 --- a/docs/unfinished.md +++ b/docs/unfinished.md @@ -59,6 +59,43 @@ and is listed as a US call now, and China was not split by call area yet. The counts a callsign it cannot place — `D1M` — as a multiplier with an empty value, and we do not. +**Telnet window: what N1MM has and this does not.** Left out: the special-calls +list, and saving received spots to a database — N1MM keeps those spots in its +admin database, which is not the log file the two programs share, so there is +nothing to be compatible with and nothing asks to read them back. The band plan +tab is not repeated either, because Config ▸ Sub bands already edits the same +numbers. + +The node list is downloaded from the public NG3K page rather than from N1MM's +own web service, which asks the operator to opt in to data collection and is +N1MM's to run. A page that changes shape would stop the download working; the +reading only needs `telnet://` links in a table, and the old list stays in place +when a download brings back something unreadable. + +**The action macros do nothing.** The text macros are N1MM's, spelled as its +function-key documentation spells them, and they are listed in the README. What +is missing is the other half of N1MM's table: the macros that run a program +command rather than standing for text — `{WIPE}`, `{LOG}`, `{ENTER}`, `{RUN}`, +`{S&P}`, `{SPOTME}`, `{TOGGLE}`, `{END}`, the `{CAT…}` and `{OTRSP…}` families, +the audio, rotator and call-stacking macros. They expand to nothing, so a +message that holds one sends the right characters and takes no action. + +They cannot be added to `MessageExpander` as it stands: it turns a template into +a string, and an action macro has to reach the entry window, the keyer or the +SO2R box, some of it after the message has been sent (that is what N1MM's +`{END}` is for). That wants a different shape — a list of steps rather than a +string. + +Two more text macros are out for want of the data behind them: `{LASTEXCH}`, +which N1MM only fills for ROPOCO and LZ Open, and `{OPERATOR}`, because there is +no operator separate from the station callsign here. `@`, which voices the +receive frequency from recorded letter files, waits on voice keying. + +**The busted-spot check is one character wide.** N1MM offers two. Every call one +character away from the spotted one is looked up in the callsign database, which +is a few hundred lookups per spot; two characters away is a few hundred thousand, +which is too much work per spot for the little it would add. + **Digital modes.** A contact can be logged as RTTY or another digital mode, and the contest rules score it, but there is no digital window: no decoding, no transmitting, no interface to fldigi, MMTTY or similar. diff --git a/src/Nonemm.App/AppSession.cs b/src/Nonemm.App/AppSession.cs index 6322344..8191331 100644 --- a/src/Nonemm.App/AppSession.cs +++ b/src/Nonemm.App/AppSession.cs @@ -27,6 +27,12 @@ public sealed class AppSession : IDisposable private AlternatingCq? alternating; private So2rBox? box; + /// Which cluster spots reach the bandmap. Rebuilt whenever the settings or + /// the support files change. + private SpotFilter spotFilter = new(); + + private readonly Random jitter = new(); + public AppSession(UserPaths paths, Settings settings) { Paths = paths; @@ -38,6 +44,7 @@ public sealed class AppSession : IDisposable Registry = ContestRegistry.FromFolder(paths.UserDefinedContests, out IReadOnlyList problems); UserDefinedContestProblems = problems; BandPlan = settings.ToBandPlan(); + ApplySpotSettings(); } public UserPaths Paths { get; } @@ -64,6 +71,11 @@ public sealed class AppSession : IDisposable /// The contest in progress: one log and one score, however many radios. public ContestSession? Logging { get; private set; } + /// The radio the operator is not on, or null at a one-radio station. The + /// message macros that pass a station to the other band need it. + public RadioPosition? Other(RadioPosition position) => + positions.FirstOrDefault(p => p.RadioNumber != position.RadioNumber); + /// One per radio, in radio-number order. There is always at least one, so /// the program works with no radio connected. public IReadOnlyList Positions => positions; @@ -111,6 +123,7 @@ public sealed class AppSession : IDisposable { Settings = settings; BandPlan = settings.ToBandPlan(); + ApplySpotSettings(); settings.Save(Paths.SettingsFile); Changed?.Invoke(this, EventArgs.Empty); } @@ -358,7 +371,7 @@ public sealed class AppSession : IDisposable } MoveToRadio(radioNumber); await PointTransmitAtAsync(radioNumber).ConfigureAwait(false); - await keyer.SendAsync(MessageExpander.Expand(template, position)).ConfigureAwait(false); + await keyer.SendAsync(MessageExpander.Expand(template, position, Other(position))).ConfigureAwait(false); } /// Puts both radios in the headphones, or goes back to one. An operator @@ -433,10 +446,12 @@ public sealed class AppSession : IDisposable cluster = new ClusterClient( Settings.ClusterHost, Settings.ClusterPort, - Settings.Station.Callsign, + Settings.ClusterLogonCall.Length > 0 ? Settings.ClusterLogonCall : Settings.Station.Callsign, Settings.ClusterCommands, - Settings.ClusterPassword); - cluster.SpotArrived += (_, spot) => Bandmap.Add(spot); + Settings.ClusterPassword, + autoLogon: Settings.ClusterAutoLogon, + keepAliveInterval: TimeSpan.FromMinutes(Math.Max(1, Settings.ClusterKeepAliveMinutes))); + cluster.SpotArrived += (_, spot) => TakeSpot(spot); cluster.ConnectionChanged += (_, _) => Changed?.Invoke(this, EventArgs.Empty); cluster.Start(); } @@ -448,12 +463,30 @@ public sealed class AppSession : IDisposable Changed?.Invoke(this, EventArgs.Empty); } + /// A spot the node sent, kept or dropped by the filters on the telnet + /// window. + private void TakeSpot(Spot spot) + { + if (!spotFilter.Accepts(spot)) + { + return; + } + Bandmap.Add(Settings.RandomizeSpots ? SpotJitter.Shifted(spot, BandPlan, jitter) : spot); + } + + private void ApplySpotSettings() + { + spotFilter = Settings.SpotFilter.ToFilter(BandPlan, Settings.Station, Countries, Calls, History); + Bandmap.Lifetime = TimeSpan.FromMinutes(Math.Max(1, Settings.SpotTimeoutMinutes)); + } + public void ReloadSupportFiles() { Countries = LoadCountryFile(Paths.CountryFile); Calls = LoadCallDatabase(Paths.CallDatabaseFile); History = LoadCallHistory(Settings.CallHistoryFile); Registry = ContestRegistry.FromFolder(Paths.UserDefinedContests, out _); + ApplySpotSettings(); if (Logging is not null) { OpenContest(Logging.Instance.ContestNumber); diff --git a/src/Nonemm.App/Configuration/Settings.cs b/src/Nonemm.App/Configuration/Settings.cs index 9be36ff..7e8ec83 100644 --- a/src/Nonemm.App/Configuration/Settings.cs +++ b/src/Nonemm.App/Configuration/Settings.cs @@ -1,3 +1,4 @@ +using System.Reflection; using System.Text.Json; using Nonemm.Core; @@ -21,6 +22,37 @@ public sealed record Settings public IReadOnlyList ClusterCommands { get; init; } = []; + /// The nodes the operator keeps, listed on the telnet window's Clusters + /// tab. Connecting to one copies it into `ClusterHost` and the rest. + public IReadOnlyList ClusterNodes { get; init; } = []; + + /// Off for a node that wants the call typed in by hand. + public bool ClusterAutoLogon { get; init; } = true; + + /// The call sent at login, or empty for the station callsign. + public string ClusterLogonCall { get; init; } = ""; + + /// How often to send an empty line to a node that has said nothing. Nodes + /// drop a connection that has been quiet for a quarter of an hour. + public int ClusterKeepAliveMinutes { get; init; } = 4; + + /// The buttons along the bottom of the telnet window. Empty means the ones + /// in `StoredTelnetButton.Default`. + public IReadOnlyList TelnetButtons { get; init; } = []; + + /// Which cluster spots reach the bandmap. + public StoredSpotFilter SpotFilter { get; init; } = new(); + + /// How long a spot stays on the bandmap. N1MM's own default is 60. + public int SpotTimeoutMinutes { get; init; } = 60; + + /// Moves each incoming CW spot by a few tens of hertz, so the operator has + /// to find the station by ear. N1MM calls it randomising. + public bool RandomizeSpots { get; init; } + + /// Sent with a spot the operator puts on the cluster with Alt+P. + public string SpotComment { get; init; } = ""; + /// One entry per radio, in radio-number order. A second radio makes the /// station SO2R. public IReadOnlyList Radios { get; init; } = []; @@ -103,8 +135,9 @@ public sealed record Settings } try { - return Migrated( - JsonSerializer.Deserialize(File.ReadAllText(path), Json) ?? new Settings()); + Settings read = JsonSerializer.Deserialize(File.ReadAllText(path), Json) ?? new Settings(); + FillNulls(read); + return Migrated(read); } catch (JsonException) { @@ -131,6 +164,51 @@ public sealed record Settings public void Save(string path) => File.WriteAllText(path, JsonSerializer.Serialize(this, Json)); + /// A settings file can hold a null where the property is not nullable: a + /// file written before the property existed and then edited, or one an + /// older version wrote. JSON puts the null in and the property's own + /// default never runs, so the program reads a null it does not expect. + /// Every null is put back to the default the property declares, top to + /// bottom, so nothing downstream has to check. + private static void FillNulls(object target) + { + Type type = target.GetType(); + object fresh = Activator.CreateInstance(type)!; + foreach (PropertyInfo property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (!property.CanWrite || property.GetIndexParameters().Length > 0) + { + continue; + } + object? value = property.GetValue(target); + if (value is null) + { + property.SetValue(target, property.GetValue(fresh)); + } + else if (value is System.Collections.IEnumerable items and not string) + { + foreach (object? item in items) + { + FillOurOwn(item); + } + } + else + { + FillOurOwn(value); + } + } + } + + /// Only the types in this folder are walked into. A string, a number or a + /// framework type has nothing to fill in. + private static void FillOurOwn(object? value) + { + if (value is not null && value.GetType().Namespace == typeof(Settings).Namespace) + { + FillNulls(value); + } + } + /// Carries the single radio an older settings file holds into the list. private static Settings Migrated(Settings settings) => settings.Radios.Count > 0 || settings.RigctldHost.Length == 0 diff --git a/src/Nonemm.App/Configuration/StoredClusterNode.cs b/src/Nonemm.App/Configuration/StoredClusterNode.cs new file mode 100644 index 0000000..39b6a4c --- /dev/null +++ b/src/Nonemm.App/Configuration/StoredClusterNode.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace Nonemm.App.Configuration; + +/// One cluster node in the operator's list. N1MM keeps the same list in its +/// admin database and calls it Favorites. +public sealed record StoredClusterNode +{ + public string Name { get; init; } = ""; + + public string Host { get; init; } = ""; + + public int Port { get; init; } = 7373; + + /// Only the few nodes that ask for one; most take the callsign alone. + public string Password { get; init; } = ""; + + /// Sent to this node after login, one command per entry. + public IReadOnlyList Commands { get; init; } = []; + + /// How the node reads in the favourites list. + [JsonIgnore] + public string Label => Name.Length > 0 ? $"{Name} — {Host}:{Port}" : $"{Host}:{Port}"; +} diff --git a/src/Nonemm.App/Configuration/StoredSpotFilter.cs b/src/Nonemm.App/Configuration/StoredSpotFilter.cs new file mode 100644 index 0000000..a3c2c52 --- /dev/null +++ b/src/Nonemm.App/Configuration/StoredSpotFilter.cs @@ -0,0 +1,70 @@ +using Nonemm.Core; +using Nonemm.Core.Calls; +using Nonemm.Core.Country; +using Nonemm.Spotting; + +namespace Nonemm.App.Configuration; + +/// The telnet window's filter settings as they are stored: band and mode names +/// rather than the types, so the file keeps working when those change. +public sealed record StoredSpotFilter +{ + /// Band names as `Bands` spells them. Empty means every band. + public IReadOnlyList Bands { get; init; } = []; + + /// `CW`, `PHONE` or `DIGITAL`. Empty means every mode. + public IReadOnlyList Modes { get; init; } = []; + + public bool MyCountryOnly { get; init; } + + public bool MyContinentOnly { get; init; } + + public IReadOnlyList CallAreas { get; init; } = []; + + public IReadOnlyList BlockedSpotters { get; init; } = []; + + public IReadOnlyList BlockedCalls { get; init; } = []; + + public bool ShowBeacons { get; init; } = true; + + public bool RemoveBustedSpots { get; init; } + + public bool OnlyInCallHistory { get; init; } + + /// Spotters whose lines the telnet window paints, so the ones worth + /// believing stand out. N1MM takes up to six. + public IReadOnlyList PreferredSpotters { get; init; } = []; + + public SpotFilter ToFilter( + BandPlan plan, + StoredStation station, + CountryFile? countries, + CallDatabase? calls, + CallHistory? history) => new() + { + Bands = [.. Bands.Select(Core.Bands.Named).OfType()], + Modes = [.. Modes.Select(ModeOf).OfType()], + Plan = plan, + Countries = countries, + MyCountry = station.CountryPrefix, + MyContinent = station.Continent, + MyCountryOnly = MyCountryOnly, + MyContinentOnly = MyContinentOnly, + CallAreas = CallAreas, + BlockedSpotters = BlockedSpotters, + BlockedCalls = BlockedCalls, + ShowBeacons = ShowBeacons, + Calls = calls, + RemoveBustedSpots = RemoveBustedSpots, + History = history, + OnlyInCallHistory = OnlyInCallHistory, + }; + + private static ModeCategory? ModeOf(string name) => name.ToUpperInvariant() switch + { + "CW" => ModeCategory.Cw, + "PHONE" => ModeCategory.Phone, + "DIGITAL" => ModeCategory.Digital, + _ => null, + }; +} diff --git a/src/Nonemm.App/Configuration/StoredTelnetButton.cs b/src/Nonemm.App/Configuration/StoredTelnetButton.cs new file mode 100644 index 0000000..141cf68 --- /dev/null +++ b/src/Nonemm.App/Configuration/StoredTelnetButton.cs @@ -0,0 +1,24 @@ +namespace Nonemm.App.Configuration; + +/// One button on the telnet window. The command holds what N1MM's buttons hold: +/// the message macros, several commands separated by semicolons, or `{CONN}` and +/// the name of a favourite to connect to it. +public sealed record StoredTelnetButton +{ + /// What the operator gets before changing anything: the commands a node + /// answers on any of the four cluster programs. + public static readonly IReadOnlyList Default = + [ + new() { Label = "Sh/DX", Command = "sh/dx" }, + new() { Label = "Sh/DX/20", Command = "sh/dx/20" }, + new() { Label = "WWV", Command = "sh/wwv" }, + new() { Label = "Users", Command = "sh/users" }, + new() { Label = "Nodes", Command = "sh/c/n" }, + new() { Label = "Help", Command = "help" }, + new() { Label = "Bye", Command = "bye" }, + ]; + + public string Label { get; init; } = ""; + + public string Command { get; init; } = ""; +} diff --git a/src/Nonemm.App/Configuration/SupportFileDownloader.cs b/src/Nonemm.App/Configuration/SupportFileDownloader.cs index 16697ee..62a3771 100644 --- a/src/Nonemm.App/Configuration/SupportFileDownloader.cs +++ b/src/Nonemm.App/Configuration/SupportFileDownloader.cs @@ -1,5 +1,6 @@ using Nonemm.Core.Calls; using Nonemm.Core.Country; +using Nonemm.Spotting; namespace Nonemm.App.Configuration; @@ -13,6 +14,11 @@ public sealed class SupportFileDownloader public const string CountryFileUrl = "https://www.country-files.com/cty/wl_cty.dat"; public const string CallDatabaseUrl = "https://www.supercheckpartial.com/MASTER.SCP"; + /// NG3K's list of telnet cluster nodes. N1MM downloads its list from its + /// own web service, which asks the operator to opt in to data collection + /// and is N1MM's to run; this is a public page anyone may read. + public const string ClusterListUrl = "https://www.ng3k.com/misc/cluster.html"; + private readonly HttpClient http; public SupportFileDownloader(HttpClient http) => this.http = http; @@ -23,6 +29,9 @@ public sealed class SupportFileDownloader public Task DownloadCallDatabaseAsync(string path, CancellationToken cancellation = default) => DownloadAsync(CallDatabaseUrl, path, text => CallDatabase.Parse(text).Count, "callsigns", cancellation); + public Task DownloadClusterListAsync(string path, CancellationToken cancellation = default) => + DownloadAsync(ClusterListUrl, path, text => ClusterList.Parse(text).Count, "cluster nodes", cancellation); + private async Task DownloadAsync( string url, string path, diff --git a/src/Nonemm.App/Configuration/UserPaths.cs b/src/Nonemm.App/Configuration/UserPaths.cs index a388ade..fb8a493 100644 --- a/src/Nonemm.App/Configuration/UserPaths.cs +++ b/src/Nonemm.App/Configuration/UserPaths.cs @@ -28,6 +28,10 @@ public sealed class UserPaths public string CallDatabaseFile => Path.Combine(SupportFiles, "MASTER.SCP"); + /// The published list of cluster nodes, kept as the page it was downloaded + /// from so a later version can read more out of it. + public string ClusterListFile => Path.Combine(SupportFiles, "cluster-list.html"); + public void CreateFolders() { Directory.CreateDirectory(Root); diff --git a/src/Nonemm.App/Dialogs/ClusterDialog.axaml b/src/Nonemm.App/Dialogs/ClusterDialog.axaml deleted file mode 100644 index 0a725ac..0000000 --- a/src/Nonemm.App/Dialogs/ClusterDialog.axaml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - -