ESM is how most people run N1MM, and it was not here at all — not in the code,
not even in the list of what is missing. Config ▸ ESM turns it on and it stays
on between runs.
The decision is a table in N1MM's function-key documentation, and it is written
here as one: the state of the callsign and exchange boxes, whether the station
is running or searching, and whether the call and the exchange have gone out
already, give the function keys Enter sends and whether the contact is logged
after them. Esm.Decide is that table and nothing else, so it is tested against
every row rather than by clicking.
Searching: type a call, Enter sends your call, space moves to the exchange, and
once the exchange is filled in Enter sends yours and logs. Running: Enter calls
CQ, a call in the box makes Enter send his call and the exchange, and the next
Enter ends the contact and logs it. A dupe gets QSO B4 while running and nothing
while searching, unless dupes are worked, which is what N1MM recommends and what
this does out of the box. F1 puts a searching station into run mode.
The entry window highlights the keys Enter would send next, so what is about to
happen is on the screen rather than in the operator's head. `=` sends whatever
Enter last sent. Escape and F12 put ESM back to the start of a contact.
Send Corrected Call is in as well: copy SM3AB, send it, fix it to SM3ABC, and
the message that ends the contact goes out as "SM3ABC TU DL1ABC". That is why
N1MM's documentation says to put ! in F5 rather than {CALL}, so the default F5
message is now ! . F6 is QSO B4, which is the message ESM sends a dupe; it used
to repeat the exchange, which no part of the table asks for.
{EXCHSENT} means something again, now that there is an ESM state for it to set.
Worked end to end against a fake cwdaemon: a search-and-pounce contact logged in
four keystrokes, a run contact in three, both with the right text on the keyer
in the right order, and the corrected call in front of the last message.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A function key message was read as text and nothing else, so a message file
written for N1MM lost half of what it said: {WIPE}, {LOG}, {RUN} and the rest
expanded to nothing and the key only sent characters.
Reading a message now produces a plan rather than a string. MessagePlan.Read
walks the template once and sorts what it finds into three parts: the actions
that run before anything goes out, the text that goes on the air, and the
actions that wait until it has gone. N1MM's {END} decides which side an action
falls on, and text after {END} is dropped, because the message is over by then.
So TU {LOG}{END}{WIPE} logs the contact, sends TU, and clears the boxes when the
key has finished sending.
The actions that are carried out: {WIPE}, {LOG}, {RUN}, {S&P}, {SPACE},
{SPOTME}, {STOPTX}, {FREQUP}, {FREQDN}, {PGUP}, {PGDN}, {JUMPRX}, {JUMPRXTX},
{CTRLF1} to {CTRLF12} for sending on the other radio, {TELNET sh/dx} for a
command to the cluster node, and {OTRSP TX2} for one to the SO2R box. The last
one wanted a way to send a command straight to the box, so So2rBox grew one.
The frequency steps are two settings, with N1MM's own two numbers behind them.
Waiting on the keyer is the part worth reading twice. What follows {END} runs
when the keyer says the message has gone out, which is the only honest moment:
timing it from the length of the text is a guess that goes wrong exactly when
the contest is busy. A keyer that does not report completion is taken at its
word as soon as the text is handed over, and one that goes quiet is given two
minutes before the actions run anyway, because never running them is worse.
Running it against a fake cwdaemon and a fake node showed the message text on
the keyer, the telnet command at the node, RUN lighting up before the message
and the boxes clearing after it. That is also what showed the entry window was
not redrawing between the actions and the message.
What is still read and passed over is written down: the CAT, audio, rotator,
stereo and call-stacking families, the digital TNC macros, and the CW keyer
characters < > ~ ] [ + = , which belong to the keyer rather than to the
expander. {EXCHSENT} waits on ESM, which is next.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Ctrl+Z opens the QTC window for the station being worked, which is the key N1MM
puts it on, and the window is laid out the way N1MM lays it out: the header,
ten lines of time, callsign and serial number, an Agn and a Cfm button beside
each, Clear, Close and Cancel underneath, and the same colours — green for a
line that will be saved, red for one still empty, yellow for one that cannot be
read. Enter and Tab move forward and space moves within a line, so a line can be
walked while listening to it.
Sending, the lines are filled from the log and cannot be edited: the contacts
not reported to anybody yet, oldest first, never the station being worked, and
never past the ten any station may have. The rule for what has been reported is
N1MM's — a contact counts as reported when a QTC row carries its call and the
serial we sent it — so the pair is kept in the row the way N1MM keeps it.
Which way traffic goes is not the operator's choice on CW and SSB: Europe
receives and the rest of the world sends. On RTTY it goes both ways, and there
the window carries a switch. That is the one piece of the workflow that is ours
rather than N1MM's, which toggles a QTC mode outside the window instead.
Nothing here transmits. N1MM sends the lines on CW and RTTY and plays recorded
voice messages on SSB; we have no QTC message templates, no voice keyer and no
digital window, so Agn, Cfm and RX Ready move the cursor and the operator sends
by hand.
Running it caught what the tests could not: focus set in the constructor does
not stick, so the first thing typed into a freshly opened window was dropped and
every field after it took the value of the one before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ctrl+B calls CQ on one radio, and when that message has gone out, moves to the
other and calls there. The keyboard, the entry window and the SO2R box follow
each turn. N1MM calls this dueling CQs and puts it on the same key.
The turn is taken when the keyer says the message has ended, not when a timer
guesses it has. So MessageSender grew a Finished event and a ReportsCompletion
flag, and both keyers fill them in: cwdaemon answers the <ESC>h reply request
that now goes out in front of every message, and a WinKeyer clears the busy bit
in the status bytes it sends of its own accord. The status-byte reading is in
WinkeyerStatus, away from the serial port, because that is the half that can be
tested without a keyer on the desk. A keyer that reports nothing refuses to
start alternating CQ rather than keying the second radio over the first.
AlternatingCq itself takes the keyer, a callback that calls CQ on a radio, the
gap and a wait function, so the alternation is tested without sleeping. The gap
is in Config ▸ Keyer and messages and will not go below 100 ms, which is N1MM's
floor too: an SO2R box works relays.
docs/keying.md writes down why cwdaemon does the timing and we do not. N1MM
keys DTR itself with a coarse sleep, a busy-wait and a margin that grows every
time the sleep overshoots, and it raises the thread to TIME_CRITICAL for the
length of the message. The busy-wait ports to Linux; the priority does not,
without CAP_SYS_NICE, and a garbage collection mid-element is audible. A direct
serial keyer stays a reasonable third option, to be taken knowingly.
Running it against a fake daemon that takes 1.5 seconds to play a message: six
CQs went out back to back and the keyboard moved between the two entry windows
each time. Escape stopped it, let the message in flight finish, and started
nothing further.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Available Mults and Qs window: a column per band, the multipliers at the top
of each column and then the stations that are only points. Clicking one puts the
radio there with the call in the entry window, the same as the bandmap does.
Every spot on the bandmap goes through ContestLog.Judge, and what comes back a
dupe is dropped, so the colours and the ordering agree with every other window.
A spot holds a callsign and a frequency and nothing else. For CQ WW that gives
the country multiplier, which is derived from the call, but not the zone, which
is what the other station sends. Where a call history file has an entry for the
call its exchange is filled into the candidate first, so a published CQZone
column brings the zone in and a Sect column makes Sweepstakes work at all.
Filling the zone from the country file instead was rejected: it is wrong for
every large country, and a multiplier that is not there is better than one that
is not real.
A spot has no mode either, so the candidate is judged in the mode the radio is
in. In a mixed-mode contest that means the answer follows the operator.
RadioPosition kept two private mappings — exchange value into a contact, and
call history field into an exchange box — and both are needed here, so they move
to ExchangeSlots and both callers share them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
N1MM's format, so a file published for a contest is read as it is: # comments,
!!Order!! naming the columns of the lines after it, semicolons in preference to
commas so user text can hold a comma, and a default column order for a file
that names none. The directives that change what is stored are acted on;
!!Order!! can appear again part way through, because these files are usually
several files stuck together.
Leaving the callsign box fills what the file knows into the exchange boxes that
are still empty. A box with something in it is left alone: what the station
actually sends beats what somebody published months ago. The zone comes from
the CqZone or the ITUZone column depending on which the contest asks for, and a
file with no Sect falls back to State.
The check window's two missing columns are in. History lists matching calls
from the file. Exchange is not about callsigns at all: it offers the values the
exchange box with the cursor in it can hold, which is what N1MM's exchange pane
does — it searches the contest's list of valid exchanges, not the log.
CheckCandidate.Call is now Text, because an exchange value is not a call.
The section-validating directives are read and passed over rather than acted
on. Acting on them means holding N1MM's section lists and its rules about
retired sections, and getting that wrong throws away good data.
Running it caught what the unit tests could not: the exchange was filled and
scored but the boxes on screen still looked empty, because nothing resynced
them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LoggingSession mixed two things: the contest, which one station has one of,
and what the operator is typing, which belongs to a radio. It is now
ContestSession and RadioPosition. Two positions share one log, one score and
one run of serial numbers, so a station worked on radio 1 is a dupe on radio 2.
Frequency, mode, split, what is typed and whether you are running belong to
each radio on its own.
A second radio opens a second entry window. It has no menu of its own: it is
another view of the same contest, not another program. Only the window for
radio 1 opens the database and the connections.
Ctrl+Tab moves the operator to the other radio and the keyboard follows.
Ctrl+Shift+Tab puts both radios in the headphones.
OtrspBox speaks OTRSP to an SO2R box over a serial port: TX1/TX2 for the key,
RX1/RX2 and RX1S for the headphones, AUXnnn for an output line. It follows the
active radio, and every message points the box at this radio before keying, so
a message cannot go out of the radio the operator has just left. A command that
would change nothing is not sent, because the box works relays; N1MM does the
same. The box takes a Stream, so what it sends is tested without a serial port.
Still missing: alternating CQ, and voice keying on the second radio.
Looked at under Xvfb with two fake radios, one of them split: both windows up,
radio 1 showing 14008.00 into 14020.0, radio 2 on 14030.00, Ctrl+Tab moving the
keyboard between them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Split. RadioState carries a transmit frequency, zero meaning the radio
transmits where it listens, so split is not a second flag that can disagree
with it. The contact stores it in N1MM's QSX column, the entry window shows
14008.00 followed by the transmit frequency, and the bandmap draws a red bar
there. SetSplitAsync sets the frequency before turning split on, or a radio
last split somewhere else transmits there.
Every command now goes out with a + in front, asking rigctld for its extended
answer: named fields ended by an RPRT line. The raw answer is bare values with
no terminator, so the client had to know how many lines each command returns —
there was a `command == "m"` special case for the one that returns two. Add a
third such command and get the count wrong once, and every later answer is read
against the wrong command for the rest of the session. RigctldReply parses the
extended form and is tested on its own.
A radio that cannot do split answers RPRT -11. That is an answer, not a broken
connection, so the frequency and mode it did report still count.
Two radios. Settings hold a list rather than one host and port, with the single
radio an older settings file holds carried into it. Both radios are read and
both show on the bandmap, the active one green and the other orange, but only
the active one drives the entry window: the second radio moving must not drag
the operator off the station being worked. Ctrl+Tab swaps, and a contact
records which radio made it.
This is not a full two-radio operating position — no second entry window, no
alternating CQ, no audio switching. It is two radios read and logged correctly.
Tested against a stand-in for rigctld over a real socket, and looked at under
Xvfb with two fake radios, one of them split.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things stopped it.
A contest is keyed by ContestNR, not by ContestID. ContestID is only the
ContestInstance table's primary key; DXLOG.ContestNR refers to ContestNR, which
is what N1MM's SQLWhereString matches on. The two agree in a fresh log and
drift apart in one that has been used for a while, so we were reading one
contest's header with another contest's contacts. In a real log of 56284
contacts, joining on ContestID disagreed with the contact's own ContestName
52029 times; joining on ContestNR disagreed 109 times.
N1MM names a contest per mode: CQWWCW, CQWWSSB, CQWWRTTY, never plain CQWW.
Our contests now use those names, and N1mmContestNames turns one back into a
contest and a mode when a log is opened. The name carries the mode, so it beats
the ModeCategory column. Plain CQWW still opens, because older versions wrote
it that way.
The log columns were sized by counting characters, which was too narrow for the
headers: those are drawn in the theme's font, not the grid's monospace. The
grid sizes them now, with the character count as a floor so a column of short
values does not collapse.
Bandmap spots now age from when they arrived rather than from the time written
in them. A node with a wrong clock, or one replaying its backlog on connect,
emptied the bandmap as fast as it filled it.
Checked by opening a real 56284-contact N1MM log under Xvfb and reading the
contest and contacts back.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ctrl+Y from the entry window, or Enter on a row in the log, opens the whole
contact in one dialog. It reaches the fields the log has no column for: QSX
frequency, name, QTH, comment, grid, power, radio number, run position, and the
country and WPX prefixes. Previous and Next walk the log without closing and
offer to save first.
The dialog checks values through the same QsoEditor the log columns use, so a
zone the contest will not take is refused the same way in both places.
QsoEdit now names the field that was refused, so the dialog can put the cursor
back in the right box, and QsoEditor.ApplyAll changes several fields at once.
Points and the multiplier flags are shown but not editable. They are worked out
from the rules every time the log changes, so a typed value would be lost on
the next edit. N1MM lets you type them; we do not, rather than offering a box
that does nothing.
A contact's own country prefix now wins over the country file when scoring.
Without this, correcting the country in the dialog would change what the log
shows and what Cabrillo says, but not the score.
LoggingSession.Update now broadcasts the change like Edit does, so a contact
saved from the dialog reaches the other stations.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Double-click a cell in the log window to change it. The columns follow the
contest exchange instead of being fixed in the XAML, so CQ WW gets a Zone
column and Sweepstakes gets Nr, Prec, Ck and Sec.
QsoEditor holds the column list and applies one field edit. Validation comes
from the ExchangeFieldKind the contest declared: a CQ zone is 1 to 40, an ITU
zone 1 to 90, a section is looked up in the ARRL list, a grid must parse.
A refused edit returns the reason and leaves the log alone, so the cell
reverts. Changing the callsign runs the country lookup again.
Delete, or the right-click menu, removes the selected contact after a
confirmation. Either way the log is rescored, so a multiplier the removed
contact held passes to the next contact that claims it.
Both go out to the other stations in N1MM's own messages: contactreplace
carries oldcall and oldtimestamp, contactdelete names the contact. An incoming
edit or delete is matched by contact id first, falling back to call plus
timestamp because N1MM does not know our ids.
Country, continent and the two prefixes were filled in twice, once when
logging and once when editing. They now come from CountryFields.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The function keys send through cwdaemon or a WinKeyer, with N1MM's message
macros. Escape stops sending.
Settings are read with the reflection serializer rather than a generated one:
the generated one hands back null for every property the file leaves out
instead of the value the property is declared with, which crashed the program
the first time a new setting was added.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Checked by opening a log this program wrote in N1MM 1.0.11031. Three things it
rejected:
- a log naming a contest with no row in the Contest table throws "No current
row" in Contest.FromRow, so the definition row is written whenever a contest
is opened;
- an empty overlay category is answered with "Invalid Overlay Category:", so an
entry with no overlay now says "N/A", and the dialog offers N1MM's list;
- the sent exchange omits the report, which is what N1MM's own contest dialog
asks for.
With those three fixed N1MM opens the log and shows the contacts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nonemm.Session holds what the operator is typing, what the log says about it and
what happens on Enter, with no UI toolkit behind it. Nonemm.Rig talks to
hamlib's rigctld and reconnects on its own. Nonemm.Spotting reads DX cluster
lines into a bandmap that drops spots after an hour.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>