Compare commits

..
17 Commits
Author SHA1 Message Date
rouggy 9599c3e0b9 chore: release v0.25.9 2026-08-18 05:09:04 +02:00
rouggy a81125eab1 fix(icom): a set whose acknowledgement is lost is sent once more
Extends to frequency and mode what PTT already had. A missing FB is not a
missing command: the rig acts on the frame as it decodes it, and what
expires is our wait for the answer, on a bus shared with the rig's own
transceive updates. JTDX in "Split: Fake It" moves the dial and the mode
immediately before every key-down, so those acks queue behind each other.

Losing one is fatal to the client rather than merely untidy: rigctld answers
RPRT -9, JTDX reads that as losing rig control and tears the connection down
mid-over. An operator's log shows three set_freq failures and one set_mode,
each followed within 300 ms by a fresh rigctld client -- and shows the PTT
resend rescuing an over that would otherwise have ended there.

Opt-in per caller rather than folded into exec: only a command that says
"be in this state" can be repeated safely, and a relative one must never
come through here.

The acknowledgement loss itself is still unexplained. Every failure in that
log is preceded by a state read reporting SSB on a rig in DATA, which points
at CI-V frame desync rather than a slow rig, and needs a trace to pin down.
2026-08-17 16:23:54 +02:00
rouggy bbe1b3ce80 fix(tci): a refused un-key no longer leaves the rig keyed for good
The trx handler stamped its PTT cache BEFORE commanding the radio and left
it in place when the command failed. An operator running JTDX over TCI with
an Icom on CI-V lost an un-key to a lost acknowledgement: the cache recorded
"off" regardless, and from then on every trx:0,false was dismissed as a
repeat of a state the radio had never reached. The cache is per-server, not
per-connection, so reconnecting JTDX changed nothing either -- the
transmitter stayed keyed into the amplifier, with no drive, until the radio
was switched off by hand.

The cache is now written only on success, and a failure clears "known"
outright so the next command reaches the radio whatever it is.

Second guard: releasePTT drops a PTT this server asserted when the client
disconnects, and when the server stops -- before the CAT backend goes down,
while the rig is still reachable. rigctld has had that since a K3 sat in
transmit for 29 s; the TCI server was written without it, so an operator
moving from Hamlib to TCI silently lost the protection. A later log shows
the rig keyed for 40 s across a JTDX reconnect for exactly that reason.
2026-08-17 16:23:53 +02:00
rouggy 5e80c27f61 feat(appearance): the band/mode matrix colours can be chosen
The PH/CW/DIG grid in the Stats panel is the fastest read in the app and its
palette was fixed per theme. Settings -> Appearance now offers the six: the
four status fills, the never-worked fill, and the ring on the cell being
entered.

Stored as OVERRIDES, not as a palette. Each of the twelve themes ships an
--mx-* ramp tuned to its own background, so an operator who only wants a
different green must not thereby freeze the other four to the theme they
happened to be using that day. An empty value means "whatever the theme
says"; the chosen ones are stamped inline on <html>, where they win over
every theme; switching the feature off hands the colours straight back.

The pickers are seeded from what the matrix is painting at that moment
rather than from a fixed palette, so the choice starts from the colours in
front of the operator. A new --mx-cur token carries the current-entry ring:
it follows --warning by default, so it stays theme-correct on all twelve,
but can be recoloured without dragging every other warning in the app along.

The legend under the matrix and its cell tooltips were hardcoded English.
They now go through t() with the same keys as the pickers, so the grid and
the settings cannot disagree about which green is which.
2026-08-17 16:23:39 +02:00
rouggy 7be6f64596 chore: release v0.25.8 2026-08-17 13:18:32 +02:00
rouggy 0bab7f05b9 feat(relays): accept a self-signed certificate on a generic HTTP board
HTTPS to a relay board could not work. Nearly every board that offers it signs
its own certificate — there is no authority anywhere that could have signed it —
so the request failed verification before it left.

A checkbox, per board, off by default. Not a blanket switch, because the other
HTTPS case is real and opposite: a board reached from outside through a proxy
with a genuine certificate, where verification is the only thing standing
between an antenna switch and the internet. Same setting, two boards, different
answers.

Off by default is only safe if the failure explains itself, so a certificate
error now names the box to tick. Go's own "x509: certificate signed by unknown
authority" is accurate and tells an operator nothing about what to do next.

Shown only once an https:// URL is actually in the board's configuration. A
board on plain HTTP has no certificate to argue about, and an option that cannot
matter yet is one more thing to wonder about.

The flag joins the driver cache key: ticking it has to rebuild the driver, or
the cached one would go on refusing the certificate with the verifying client it
already holds.

The boards that take a bare host — WebSwitch, KMTronic — keep verification. An
https:// typed there is the proxy case by construction, since they default to
plain HTTP on the LAN.

Three tests against a real self-signed TLS server: accepted with the box,
refused with a message naming it without the box, and one board's setting not
leaking into another's.
2026-08-17 10:57:29 +02:00
rouggy dc898ce2af fix(amps): combined amplifiers are commanded together, power level included
Two faults in the combiner coupling, both reported from the operating position.

THE POWER LEVEL WAS NEVER COUPLED. ON, OFF and OPERATE fanned out to the group;
L/M/H did not — it was simply the command nobody had linked. Two combined
amplifiers left at different power levels feed the combiner unevenly, which is
the thing the coupling exists to prevent.

THE COMMANDS DID NOT LEAVE TOGETHER. The second amplifier was commanded only
once the first had answered, and an SPE answers over its own link in its own
time. The combiner heard power appear on one input before the other and beeped
about it, on every OFF and every ON.

Each target now gets a goroutine, all parked on one channel until every one is
ready; closing it releases them together. That is the difference between "start
one, then start the other" and "both leave at once" — they have separate clients
and separate connections, so nothing downstream re-serialises them.

It matters most on the power level, which is not one command at all: an SPE has
no "set level", so the driver taps the POWER key and waits for the amp to report
the new one before tapping again — up to three taps, up to two seconds each. One
after the other, the pair would sit at different levels for six seconds.

A single amplifier still runs inline: no goroutine, no barrier, nothing new to
go wrong for the operators who have one amp. The one that was clicked stays
first, because its failure is the one worth reporting.
2026-08-17 10:42:19 +02:00
rouggy e5c9ca1a7d fix(rotator): the compass shows the heading it already knows
Opening Station Control left the compass blank for a second or two while the
amplifiers, relay boards and power supply filled at once.

Nothing was slow. The status bar keeps the shared heading loop running, so when
the compass mounts there is already a tick pending — up to the full three-second
idle interval away — and the new subscriber simply waited out the rest of it.
The heading was known the whole time and had nowhere to be read from.

The last heading is now kept and handed to whoever subscribes next. That matters
more on the Alpha SPID this was reported on than it would elsewhere: every poll
is an open, a read at 600 baud and a close, so even fetching immediately on
mount would not have been immediate.

In a microtask, so a subscriber is never called back before subscribeRotorHeading
has returned to it.
2026-08-17 10:32:06 +02:00
rouggy 21a0d560de feat(awards): a reference's number can be corrected in the editor
The one field the editor would not let you touch, and the one that was wrong on
WAJA. Every other property of a reference — its name, pattern, entity list,
validity window — was editable; the code was rendered readOnly, so correcting a
number meant deleting all 47 references and importing a new list, throwing away
anything the operator had adjusted in it.

A rename in the store, not a delete plus an insert: everything the reference
carries travels with it, which is the whole point of correcting a number rather
than replacing an entry. A number already in use is refused — REPLACE INTO would
have let one reference silently swallow another, discovered much later as a
prefecture quietly missing from the list.

The typed code is held apart from the selection. The list and every field patch
key off the selected code, so editing it in place made the editor lose the
reference mid-edit.

SaveAwardReference now recomputes the log like Delete and Replace already did. A
reference's name is what the award column SHOWS for awards displaying by name,
and its pattern is part of what matches at all — so editing one changes rows,
and the grid was left showing the old label until something else happened to
trigger a pass.

Changelog: the three TCI-sharing lines are merged into one. The server and the
two fixes made to it while building are one unreleased feature, and an operator
only ever meets the finished thing. The TCI-client PTT line stays separate — it
is OpsLog driving a SunSDR, the other direction entirely.
2026-08-17 10:26:13 +02:00
rouggy 2941121f4b fix(awards): WAJA was numbered by the Japanese state, not by the JARL
The catalog carried Japan's civil prefecture code (JIS X 0401) — 01 Hokkaido,
02 Aomori, 03 Iwate, 04 Miyagi — where the award uses the JARL's own numbering.
The two agree on the first three prefectures and then part company on 35 of the
remaining 44: Tokyo is 10 to the JARL and 13 to the government, Niigata 08
against 15, Toyama 28 against 16.

The names were right throughout, which is why nothing looked wrong: the award
matches on the prefecture NAME in the QTH, so it counted exactly the right
contacts. Only the number against each one was wrong — and that number is what
an operator writes on a JARL claim.

Renumbered by name from the official JARL list, so everything else each entry
carried travels with it. That includes the Tokyo spelling pattern, which had to
move from 13 to 10; left where it was it would have been matching QTHs for
Saitama.

Two digits throughout, as the JARL prints them. Not cosmetic: the codes are
strings, so "1" sorts between "09" and "10" and the reference list appeared in
an order no published list uses.

Version 2 on the definition, so this actually reaches people. An operator who
has not edited WAJA gets it at startup; one who has is offered it, since their
work outranks ours. Nobody has to re-import by hand.

Reported with the official JARL chart alongside the exported award.
2026-08-17 10:17:28 +02:00
rouggy 6c75ff345b fix(tci server): split armed on the frequency asked for, in either order
Audit prompted by "are we sure the commands are implemented — split, Fake It,
Split rig?". The rigctl server is complete and hardened; the TCI one, three days
old, had reintroduced a bug rigctld had already paid for.

A client working split says two things — where to transmit, and that split is
on — and nothing obliges it to say them in that order. A write to channel B
while the rig was still simplex was DISCARDED, on the sound principle that
preparing a transmit frequency is not a request to QSY. But then the split was
armed on whatever the transmit VFO held, which is the receive frequency: the
operator transmits straight onto the DX while their software shows exactly what
they asked for. The frequency is now remembered and used when the split arrives,
which is what rigctld does with set_split_vfo / set_split_freq.

Two more from the same source:

Asking for a split state the rig is already in touches nothing. A client in Fake
It uses no split but still says so to be sure, and answering an error to a
request that was already true is what made JTDX abandon a transmission a second
into the frame through the rigctl server.

A repeated PTT command is not re-sent. One client restated it sixteen times a
second, and the Flex's own "xmit 1" was overwritten between two of them inside a
millisecond. The same radio sits behind this server — the operator reporting
this is on the Flex API backend.

Fake It itself needs nothing but channel A, and now has a test saying so.
2026-08-17 10:00:57 +02:00
rouggy d5e25244ee fix(tci server): announce transmit permission, and log what the client sends
MSHV's PTT test does nothing against the TCI sharing server.

The initialisation block never carried TX_ENABLE. The document files it under
unidirectional control rather than initialisation, so it was missed when the
block was written from §4.1 — but its own note says it is "sent to the client
when connected", and that is the point: a client that models transmit
permission starts out assuming it may NOT transmit. Without it MSHV never even
tries, so nothing arrives to relay and there is nothing to see at either end.

Sent as true always. OpsLog is not what decides — the radio behind whichever
backend is connected does, and its refusal already travels back through SetPTT
into the log.

TX_FREQUENCY goes with it, at connect and whenever the transmit frequency
moves. It is the command a client showing "TX 14.200" reads; channel B alone
left that stale.

And every command a client sends is now logged. This is the only evidence there
will ever be about a program on someone else's machine: "the PTT test does
nothing" cannot be answered without knowing whether MSHV sent trx at all, and
in what form. Cheap — TCI is event-driven, a client speaks when the operator
does something — and capped at 200 lines per connection so one that does poll
cannot quietly fill the log.

If this was not the cause, the next report answers it in one line rather than
another round of guessing.
2026-08-17 09:39:02 +02:00
rouggy 4095455e66 fix(tci): a refused PTT is reported instead of vanishing
"PTT via CAT does nothing on TCI." What OpsLog sends is right — trx:0,true; is
the documented command and the same one the reference clients send — so the
command was going out and the radio was discarding it.

ExpertSDR announces transmit permission with TX_ENABLE: on connect, and again
whenever the band changes, "in case transmitter permission was changed" (§4.3).
While it is false the radio simply IGNORES trx. OpsLog never read that command,
so there was nothing in the log, nothing on screen, and a dead key.

Now the permission is tracked and SetPTT refuses out loud, naming where to look:
the frequency must be inside a transmit band and TX enabled in ExpertSDR. The
refusal travels the path that already exists — Manager.SetPTT to pttKey, which
logs it and hands it to the UI.

Silence is not a "no". A radio that never mentions TX_ENABLE — an older
ExpertSDR, or another program speaking TCI — is not treated as refusing: we key
and let it decide. Permission is also forgotten on connect, so a refusal
remembered from a band since left cannot block PTT until a restart.

This may not be the operator's own cause, and that is the other half of the
change: if it is not, the log now settles it in one line. cat: TCI: → trx:0,true;
present means the command left OpsLog and the radio ignored it for a reason it
has not told us; absent means the fault is on this side.
2026-08-17 09:30:36 +02:00
rouggy 3f95763ca6 feat(lookup): a cache TTL of 0 switches the cache off
Wanted for the case where the answers are moving: an operator correcting their
own QRZ record, or chasing a DXpedition whose page changes during the operation,
otherwise waits out thirty days before OpsLog will ask again. Clearing the cache
by hand works once; this is the setting for a whole session.

Nothing is read from it and nothing is written to it — rows stored while it is
off would only sit there going stale, waiting for the day it comes back on.
Switching off is NOT clearing: what it already holds stays, and the Clear cache
button remains the way to throw that away.

Two distinctions the code now has to keep, both load-bearing:

An EXPLICIT stored zero is off; an ABSENT key is the thirty-day default. Every
operator who has never opened this setting has nothing stored, and reading that
blank as a zero would silently switch the cache off for all of them.

The CONSTRUCTOR's zero is still the default, not off. At startup the settings
have not been read yet, and beginning with no cache would hammer the provider
for the first seconds of every launch. Only SetTTL, called once the operator's
settings are known, can switch it off.

A negative lifetime is meaningless and is ignored rather than rounded into
either meaning.

The input had to change too: it derived its value from the stored number on
every keystroke, so the box could not be emptied — and 0 was unreachable
outright, since parseInt('0') || 30 is 30.
2026-08-17 09:21:12 +02:00
rouggy 72ec3cbb97 feat(cat): share the rig over TCI as well as Hamlib — one or the other
internal/rigctld exists because Windows gives a COM port to ONE process: the
moment OpsLog talks to the radio natively, nothing else can. It answers the
programs that speak Hamlib NET rigctl. This answers the ones built around Expert
Electronics' TCI instead — and it answers them whatever radio is connected,
because it sits on the same backend-agnostic Rig interface. An operator with an
Icom or a Yaesu can now hand a TCI-only program a working rig.

One server or the other, never both. They answer the same questions about the
same radio, nothing speaks both, and a second listener is only a second thing to
go wrong.

Written against the official TCI Protocol document (ExpertSDR3/TCI, 12 January
2024, MIT — downloaded and read, not recalled): the initialisation set of §4.1
in its documented order, and the argument order of every command from §4.2. A
client will not proceed past connect without that block, which is why it is
written out in full rather than stubbed.

The one dangerous detail is the VFO mapping. TCI's channel A is where you
LISTEN and channel B where you transmit — the opposite way round from OpsLog's
RigState, which follows ADIF. Getting that backwards would put a station on the
DX's own frequency, so it is pinned in both directions by a test, and RxFreq was
added to the adapter rather than inferred.

Writing channel B while the rig is simplex is ignored: the client asked to
prepare a split transmit frequency, not to QSY, and a logger doing that on every
spot click would drag the operator off the station they were listening to. A
backend that cannot split still refuses out loud.

Only changes are pushed. TCI clients redraw on each command, so re-sending an
unchanged frequency four times a second makes a VFO readout flicker and fights
the operator's own tuning.

Nine tests, no socket needed — the protocol is the decision, not the transport.
2026-08-17 02:35:21 +02:00
rouggy a8bca8c316 feat(awards): an entity that IS one island group fills its own IOTA reference
QRZ carries <iota> only for the operators who filled it in, and most have not.
But for 99 entities the reference follows from the entity alone — a station in
Ascension is on AF-003 whatever its callbook record says — and the entity is
known for every callsign from cty.dat, with no callbook at all.

So this reaches the operator with no QRZ subscription and the station that has
never touched a callbook, and it lands before the contact is logged, which is
when a reference is worth having.

Filled ONLY when the callbook gave nothing: an operator who typed a reference
knows something a table cannot — an IOTA-heavy entity, a rare island being
activated — and that still wins, as does one picked by hand on the entry.

The table is the IOTA programme's own dxcc_matches_one_iota.json, which lists
exactly the entities that map to ONE reference. France is not among them: a
French station is usually on the mainland and on no island, and guessing would
put a reference on hundreds of contacts that earn none.

Held as a table rather than a download — 3.5 kB that changes when an entity
appears, so fetching it daily would buy nothing and would fail exactly where a
portable station usually is. The header says where it came from and how to
refresh it.

Follows 747c2b9 and eab11db, which read the callbook's own tag.
2026-08-17 02:26:52 +02:00
rouggy 8b75166dbe chore: open 0.25.8
Version constants stay at 0.25.7 — the release script bumps them.
2026-08-17 02:20:56 +02:00
49 changed files with 4473 additions and 404 deletions
+76 -1
View File
@@ -1,6 +1,11 @@
package main
import "testing"
import (
"errors"
"sync"
"testing"
"time"
)
// The coupling is a SET, not a global switch.
//
@@ -61,3 +66,73 @@ func TestLinkedAmpsNeedsTwo(t *testing.T) {
}
}
}
// Two combined amplifiers must be commanded AT THE SAME TIME, not one after the
// other.
//
// Sequentially, the second was commanded only once the first had answered — and
// an SPE answers over its own link, in its own time. The combiner heard power
// appear on one input before the other and beeped about it, on every OFF and
// every ON. This is what an operator hears, so it is worth a test that would
// hear it too.
func TestLinkedAmpCommandsLeaveTogether(t *testing.T) {
a := &App{}
const slow = 150 * time.Millisecond
var mu sync.Mutex
starts := map[string]time.Time{}
err := a.ampFanOut([]string{"one", "two"}, func(id string) error {
mu.Lock()
starts[id] = time.Now()
mu.Unlock()
time.Sleep(slow) // an amplifier taking its time to answer
return nil
})
if err != nil {
t.Fatalf("fan-out: %v", err)
}
if len(starts) != 2 {
t.Fatalf("%d amplifiers were commanded, want both", len(starts))
}
// Both goroutines wait on one channel and are released by closing it, so the
// gap is scheduling noise. Sequential execution would put a full command
// between them.
gap := starts["one"].Sub(starts["two"])
if gap < 0 {
gap = -gap
}
if gap > slow/3 {
t.Errorf("the two amplifiers were commanded %v apart — the combiner hears that as one input arriving late", gap)
}
}
// The amplifier the operator clicked comes first, and its failure is the one
// reported: "the amp I pressed did not respond" beats the same message about
// its silent partner.
func TestLinkedAmpErrorNamesTheOneClicked(t *testing.T) {
a := &App{}
clicked := errors.New("the one clicked")
other := errors.New("the other one")
err := a.ampFanOut([]string{"clicked", "other"}, func(id string) error {
if id == "clicked" {
return clicked
}
return other
})
if !errors.Is(err, clicked) {
t.Errorf("fan-out reported %v, want the amplifier the operator pressed", err)
}
}
// One amplifier is the ordinary case and must not change: run inline, no
// goroutine, no barrier, and the error straight back.
func TestSingleAmpRunsInline(t *testing.T) {
a := &App{}
boom := errors.New("not running")
if err := a.ampFanOut([]string{"solo"}, func(string) error { return boom }); !errors.Is(err, boom) {
t.Errorf("a single amplifier reported %v, want the error itself", err)
}
if err := a.ampFanOut(nil, func(string) error { return boom }); err != nil {
t.Errorf("an empty group reported %v, want nothing to do", err)
}
}
+609 -84
View File
File diff suppressed because it is too large Load Diff
+64
View File
@@ -138,6 +138,70 @@ func normRowColors(s RowColorSettings) RowColorSettings {
return out
}
// MatrixColors recolours the band/mode matrix — the PH/CW/DIG grid in the Stats
// panel, whose five fills and current-entry ring are the fastest read in the
// whole app and the one an operator is most likely to want in their own colours.
//
// Every colour is OPTIONAL and an empty one keeps whatever the active theme
// paints. That is why this stores OVERRIDES rather than a palette: each of the
// twelve themes ships a matrix ramp tuned to its own background, and an operator
// who only wants a different green must not thereby freeze the other four to the
// theme they happened to be using the day they picked it.
type MatrixColors struct {
// Enabled off leaves the theme's own ramp untouched, so switching it off is a
// genuine revert and not "some other set of colours".
Enabled bool `json:"enabled"`
CallConfirmed string `json:"call_confirmed"`
CallWorked string `json:"call_worked"`
EntityConfirmed string `json:"entity_confirmed"`
EntityWorked string `json:"entity_worked"`
NotWorked string `json:"not_worked"`
CurrentEntry string `json:"current_entry"`
}
// normMatrixColors keeps only plain hex values. Anything else becomes "" — i.e.
// "use the theme's" — because these are written straight into a CSS custom
// property, and the same rule as hexColor's own comment applies: what cannot be
// trusted into a stylesheet is refused rather than passed through.
func normMatrixColors(c MatrixColors) MatrixColors {
clean := func(s string) string {
s = strings.TrimSpace(s)
if hexColor.MatchString(s) {
return strings.ToLower(s)
}
return ""
}
return MatrixColors{
Enabled: c.Enabled,
CallConfirmed: clean(c.CallConfirmed),
CallWorked: clean(c.CallWorked),
EntityConfirmed: clean(c.EntityConfirmed),
EntityWorked: clean(c.EntityWorked),
NotWorked: clean(c.NotWorked),
CurrentEntry: clean(c.CurrentEntry),
}
}
// GetMatrixColors returns the operator's matrix palette overrides. All-empty
// (the default) means "whatever the theme says".
func (a *App) GetMatrixColors() MatrixColors {
var c MatrixColors
if raw := a.settingOr(keyMatrixColors, ""); raw != "" {
_ = json.Unmarshal([]byte(raw), &c)
}
return normMatrixColors(c)
}
// SaveMatrixColors persists them.
func (a *App) SaveMatrixColors(c MatrixColors) error {
b, err := json.Marshal(normMatrixColors(c))
if err != nil {
return err
}
a.setSetting(keyMatrixColors, string(b))
return nil
}
// GetRowColors returns the row-colouring configuration, defaults included so the
// panel never has to invent one.
func (a *App) GetRowColors() RowColorSettings {
+62
View File
@@ -1,4 +1,66 @@
[
{
"version": "0.25.9",
"date": "",
"en": [
"The band/mode matrix colours can be chosen in Appearance, starting from the ones your theme already paints. Its legend is translated too.",
"TCI sharing: a refused un-key no longer leaves the rig stuck transmitting, and PTT is dropped if the client dies mid-over.",
"Icom: a frequency or mode change whose acknowledgement is lost is sent again, like PTT — losing one made JTDX drop the radio.",
"FlexRadio: transmit audio can switch to DAX for a voice message and back afterwards, so your microphone is not left off air.",
"Rotor widget: quick-turn buttons you can name and aim yourself, plus an azimuth box where Enter turns the antenna.",
"A station portable in another entity no longer inherits its home county, state and grid — TI8/W2RE was logged in New York.",
"LoTW confirmations for FT2, FT4 and FT8 match again: LoTW answers “DATA”, and the exact mode never lined up with the log.",
"CAT: a transverter offset, so a 28 MHz IF behind a 144 MHz transverter logs, spots and tunes on the band you are really on.",
"Band map: zoom goes down to 2 px/kHz for a whole band in one screen, and each band keeps the zoom you left it at."
],
"fr": [
"Les couleurs de la matrice bandes/modes se choisissent dans Apparence, à partir de celles du thème. Sa légende est traduite aussi.",
"Partage TCI : un retour en réception refusé ne laisse plus le poste bloqué en émission, et le PTT retombe si le logiciel meurt.",
"Icom : un changement de fréquence ou de mode dont laccusé se perd est renvoyé, comme le PTT — en perdre un faisait lâcher JTDX.",
"FlexRadio : laudio d’émission peut basculer sur DAX le temps dun message vocal et revenir après, pour ne pas perdre le micro.",
"Widget rotor : des boutons de rotation rapide que tu nommes et vises toi-même, et un champ azimut où Entrée lance lantenne.",
"Une station portable dans une autre entité nhérite plus de son comté, son état et son locator dorigine : TI8/W2RE sortait à New York.",
"Les confirmations LoTW des QSO FT2, FT4 et FT8 se retrouvent : LoTW répond « DATA », et le mode exact ne correspondait jamais au log.",
"CAT : un décalage transverter, pour quune FI 28 MHz derrière un transverter 144 MHz logue et accorde sur la vraie bande.",
"Carte de bande : le zoom descend à 2 px/kHz pour voir toute la bande dun coup, et chaque bande retient son zoom."
]
},
{
"version": "0.25.8",
"date": "",
"en": [
"An entity that is a single island group now fills the IOTA reference on its own — no callbook subscription needed.",
"CAT sharing can now speak TCI instead of Hamlib, split included, so a TCI-only program reaches whatever radio you are on.",
"Lookup cache: a TTL of 0 switches it off, so a callbook record you are correcting is re-read every time.",
"TCI radios: when the rig forbids transmitting, PTT says so instead of doing nothing silently.",
"WAJA carried Japans civil prefecture numbers instead of the JARLs: 35 of the 47 references are renumbered.",
"Award references can be renumbered in the editor — the number was the one field it would not let you correct.",
"The compass fills the moment Station Control opens, instead of waiting out the rest of a polling interval.",
"Combined amplifiers: the power level (L/M/H) is coupled too, and both amps are commanded at once so the combiner stops beeping.",
"Generic HTTP relay: an https:// board can be accepted with its own self-signed certificate, per board.",
"Club Log: the on-close upload now goes out as one batch — sending hundreds of contacts one at a time got operators blocked.",
"eQSL: uploads go out in batches of 100 too, and QRZ.com and HRDLog — which have no batch upload — are spaced out instead.",
"A spot you send now shows in your own spot list — most nodes never echo it back, so it looked like nothing had gone out.",
"Icom with JTDX in Fake It split: a lost PTT acknowledgement is sent again instead of failing, which made JTDX drop the rig.",
"A cluster whose only greeting is “login:” and which then asks for a password now connects — both prompts were being missed."
],
"fr": [
"Une entité qui est un seul groupe d’îles remplit désormais la référence IOTA toute seule, sans abonnement callbook.",
"Le partage CAT peut désormais parler TCI au lieu de Hamlib, split compris : un logiciel TCI atteint la radio, quelle quelle soit.",
"Cache des recherches : un TTL à 0 le désactive, pour relire à chaque fois une fiche callbook en cours de correction.",
"Radios TCI : quand la radio interdit l’émission, le PTT le dit au lieu de ne rien faire en silence.",
"WAJA portait les numéros civils des préfectures japonaises et non ceux de la JARL : 35 des 47 références sont renumérotées.",
"Les références dun diplôme se renumérotent dans l’éditeur : le numéro était le seul champ quil refusait de corriger.",
"La boussole se remplit dès louverture de Station Control, au lieu dattendre la fin dun intervalle dinterrogation.",
"Amplis combinés : le niveau de puissance (L/M/H) est couplé lui aussi, et les deux amplis sont commandés en même temps — fini le bip du combineur.",
"Relais HTTP générique : une carte en https:// peut être acceptée avec son certificat auto-signé, carte par carte.",
"Club Log : lenvoi à la fermeture part désormais en un lot — envoyer des centaines de contacts un par un faisait bloquer lopérateur.",
"eQSL : les envois partent aussi par lots de 100, et QRZ.com et HRDLog — qui nont pas denvoi groupé — sont espacés à la place.",
"Un spot que tu envoies apparaît maintenant dans ta liste : la plupart des nœuds ne le renvoient pas, il semblait n’être jamais parti.",
"Icom avec JTDX en split Fake It : un accusé de réception PTT perdu est renvoyé au lieu d’échouer — JTDX lâchait le poste.",
"Un cluster dont tout laccueil est « login: » puis qui réclame un mot de passe se connecte : les deux invites étaient ignorées."
]
},
{
"version": "0.25.7",
"date": "",
+15 -2
View File
@@ -94,7 +94,8 @@ import { ShutdownProgress } from '@/components/ShutdownProgress';
import { ClusterGrid } from '@/components/ClusterGrid';
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
import { applySpotDisplay, readSpotDisplayOptions, spotIsWorked, SPOT_DISPLAY_OPTIONS_EXPOSED } from '@/lib/spotDisplay';
import { GetRowColors, GetSpotTTLMinutes, IsNewUSCounty } from '../wailsjs/go/main/App';
import { GetMatrixColors, GetRotorPresets, GetRowColors, GetSpotTTLMinutes, IsNewUSCounty } from '../wailsjs/go/main/App';
import { applyMatrixColors } from '@/lib/matrixColors';
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
import { NetControlPanel } from '@/components/NetControlPanel';
import { ContestPanel, CONTEST_DEFAULT, type ContestSession } from '@/components/ContestPanel';
@@ -2017,6 +2018,16 @@ export default function App() {
// settings dialog closes, which is the only place it changes.
const [rowColors, setRowColors] = useState<any>(null);
useEffect(() => { GetRowColors().then(setRowColors).catch(() => {}); }, [showSettings]);
// Rotor quick-turn buttons (Settings → Rotator). Same reload trigger as the
// row colours: the settings dialog is the only place they change.
const [rotorPresets, setRotorPresets] = useState<{ label: string; azimuth: number }[]>([]);
useEffect(() => { GetRotorPresets().then((p) => setRotorPresets((p ?? []) as any)).catch(() => {}); }, [showSettings]);
// Band/mode matrix palette overrides (Settings → Appearance). Stamped onto
// <html> rather than held in state: the matrix reads CSS custom properties, so
// nothing re-renders and no component has to be told about the colours. Same
// reload trigger as the row colours — the settings dialog is where they change,
// and the panel already previews live while it is open.
useEffect(() => { GetMatrixColors().then((c) => applyMatrixColors(c as any)).catch(() => {}); }, [showSettings]);
// Spot lifetime (Settings → DX Cluster). Spots are actually REMOVED rather
// than filtered at render: the cluster list, every band map and the counts all
// read the same array, so pruning it once is what makes the setting mean the
@@ -6303,8 +6314,10 @@ export default function App() {
{/* Rotor compass: azimuth dial + needles + click-to-turn. Shows when a
rotator is configured or a DX bearing exists. */}
{showRotor && (rotatorHeading.enabled || dxPath) && (
<div className="w-[186px] shrink-0 min-h-0">
<div className="w-[320px] shrink-0 min-h-0">
<RotorCompass
presets={rotorPresets}
onStop={() => { RotatorStop().then(pokeRotorHeading).catch((err) => setError(String(err?.message ?? err))); }}
bearing={dxPath?.bearingShort ?? null}
headings={beamHeadings}
boomHeading={boomHeading}
+112 -1
View File
@@ -1,9 +1,13 @@
import { useEffect, useState } from 'react';
import { GetRowColors, SaveRowColors } from '../../wailsjs/go/main/App';
import { GetMatrixColors, GetRowColors, SaveMatrixColors, SaveRowColors } from '../../wailsjs/go/main/App';
import { Checkbox } from '@/components/ui/checkbox';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import type { RowColorSettings } from '@/lib/rowColors';
import {
MATRIX_VARS, applyMatrixColors, effectiveMatrixColor, emptyMatrixColors,
type MatrixColors,
} from '@/lib/matrixColors';
// A fixed palette plus a free picker. Muted values on purpose: they are
// composited at low opacity over a dark grid, where a saturated colour reads as
@@ -30,6 +34,111 @@ const CHANNEL_LABELS: Record<string, string> = {
qsl: 'appr.chQsl', lotw: 'LoTW', eqsl: 'eQSL', qrz: 'QRZ.com',
};
// MatrixColorsSection recolours the band/mode matrix — the PH/CW/DIG grid in the
// Stats panel.
//
// The pickers are seeded from what the matrix is painting RIGHT NOW (the active
// theme's ramp, or an existing override), not from a fixed palette: the operator
// starts from the colours in front of them and moves one, instead of being
// handed six values that may belong to a theme they stopped using. Every change
// is applied to the live document at once, so the sample row below is the real
// thing rather than a mock-up of it.
function MatrixColorsSection() {
const { t } = useI18n();
const [cfg, setCfg] = useState<MatrixColors | null>(null);
useEffect(() => {
(async () => {
try {
setCfg((await GetMatrixColors()) as any);
} catch {
setCfg(emptyMatrixColors());
}
})();
}, []);
const save = (next: MatrixColors) => {
setCfg(next);
applyMatrixColors(next); // live, before the round trip — the panel must not lag the choice
SaveMatrixColors(next as any).catch(() => {});
};
// Turning it ON with nothing stored would change nothing at all and read as a
// broken switch, so the empty slots are filled from the theme's current ramp:
// the operator sees six swatches that match the grid and edits from there.
const enable = (on: boolean) => {
if (!cfg) return;
if (!on) {
save({ ...cfg, enabled: false });
return;
}
const seeded = { ...cfg, enabled: true };
for (const { key, cssVar } of MATRIX_VARS) {
if (!String(seeded[key] ?? '').trim()) seeded[key] = effectiveMatrixColor(cssVar);
}
save(seeded);
};
// Reset clears the overrides but keeps the section switched on, then re-seeds
// from the theme — "back to the theme's colours", which is what an operator
// means by reset here, rather than "switch the whole feature off".
const reset = () => {
if (!cfg) return;
applyMatrixColors({ ...emptyMatrixColors(), enabled: false });
const seeded = { ...emptyMatrixColors(), enabled: true };
for (const { key, cssVar } of MATRIX_VARS) seeded[key] = effectiveMatrixColor(cssVar);
save(seeded);
};
if (!cfg) return null;
return (
<div className="space-y-3 border-t border-border/60 pt-4">
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox checked={cfg.enabled} className="mt-0.5" onCheckedChange={(c) => enable(!!c)} />
<span>
{t('appr.matrixEnable')}{' '}
<span className="text-xs text-muted-foreground">{t('appr.matrixHint')}</span>
</span>
</label>
{cfg.enabled && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
{MATRIX_VARS.map(({ key, cssVar, label }) => (
<label key={key} className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="color"
value={String(cfg[key] || '').trim() || effectiveMatrixColor(cssVar)}
onChange={(e) => save({ ...cfg, [key]: e.target.value })}
className="size-6 rounded-md border border-border bg-transparent p-0 cursor-pointer shrink-0"
/>
{t(label)}
</label>
))}
</div>
{/* The matrix as it will actually look: same tokens, same shapes. */}
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground w-10 shrink-0">{t('appr.matrixSample')}</span>
<span className="inline-block w-7 h-5 rounded bg-mx-call-conf" />
<span className="inline-block w-7 h-5 rounded bg-mx-call-work" />
<span className="inline-block w-7 h-5 rounded bg-mx-dx-conf" />
<span className="inline-block w-7 h-5 rounded bg-mx-dx-work" />
<span className="inline-block w-7 h-5 rounded bg-mx-none" />
<span className="inline-block w-7 h-5 rounded bg-mx-none ring-2 ring-mx-cur ring-inset" />
</div>
<button type="button" onClick={reset}
className="text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground">
{t('appr.matrixReset')}
</button>
</div>
)}
</div>
);
}
export function AppearancePanel() {
const { t } = useI18n();
const [cfg, setCfg] = useState<RowColorSettings | null>(null);
@@ -148,6 +257,8 @@ export function AppearancePanel() {
))}
</div>
)}
<MatrixColorsSection />
</div>
);
}
+35 -3
View File
@@ -14,7 +14,7 @@ import { useI18n } from '@/lib/i18n';
import {
GetAwardDefs, SaveAwardDefs, ResetAwardDefs, AwardFields,
GetAwardReferenceMeta, UpdateAwardReferenceList,
ListAwardReferences, SearchAwardReferences, SaveAwardReference, DeleteAwardReference,
ListAwardReferences, SearchAwardReferences, SaveAwardReference, DeleteAwardReference, RenameAwardReference,
ImportAwardReferencesText, GetAwardPresets, ApplyAwardPreset,
ListCountries, DXCCForCountry, DXCCName,
PopulateBuiltinReferences, HasBuiltinReferences,
@@ -915,6 +915,10 @@ function ReferencesPanel({ code, presets, meta, awardValidFrom, awardValidTo, on
const [refs, setRefs] = useState<AwardRef[]>([]);
const [q, setQ] = useState('');
const [selCode, setSelCode] = useState<string | null>(null);
// The code as TYPED. The list and every patch key off selCode, so editing the
// code in place would make the editor lose the reference mid-edit; the draft
// is applied as a rename when the operator saves.
const [codeDraft, setCodeDraft] = useState('');
const [busy, setBusy] = useState(false);
const [bulk, setBulk] = useState('');
const [showBulk, setShowBulk] = useState(false);
@@ -952,6 +956,7 @@ function ReferencesPanel({ code, presets, meta, awardValidFrom, awardValidTo, on
}
const sel = refs.find((r) => r.code === selCode) || null;
useEffect(() => { setCodeDraft(selCode ?? ''); }, [selCode]);
// Large lists are already filtered by the server; small lists filter locally.
const filtered = useMemo(() => {
if (large) return refs;
@@ -965,6 +970,27 @@ function ReferencesPanel({ code, presets, meta, awardValidFrom, awardValidTo, on
try { await SaveAwardReference(code, r as any); load(); onChanged(); }
catch (e: any) { setErr(String(e?.message ?? e)); }
}
// Save the selected reference, renumbering it first when the code was edited.
//
// The rename has to come first and has to be a rename: saving under the new
// code would simply create a second reference and leave the old one behind,
// which is how a list quietly grows duplicates.
async function saveSelected(r: AwardRef) {
const next = codeDraft.trim().toUpperCase();
if (!next) { setErr(t('awed.refCodeEmpty')); return; }
if (next !== r.code) {
try {
await RenameAwardReference(code, r.code, next);
} catch (e: any) {
// Most often the number is already taken by another reference. Said
// here rather than swallowed: the save has NOT happened.
setErr(String(e?.message ?? e));
return;
}
setSelCode(next);
}
await saveRef({ ...r, code: next });
}
async function addRef() {
const c = prompt(t('awed.newRefCodePrompt'))?.trim().toUpperCase();
if (!c) return;
@@ -1046,7 +1072,13 @@ function ReferencesPanel({ code, presets, meta, awardValidFrom, awardValidTo, on
) : (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Input className="h-8 w-28 font-mono font-semibold" value={sel.code} readOnly />
{/* Editable, because a shipped list can be wrong about it: WAJA
went out carrying Japan's civil prefecture numbers instead of
the JARL's, and correcting that meant deleting all 47
references and importing a new list. */}
<Input className="h-8 w-28 font-mono font-semibold" value={codeDraft}
title={t('awed.refCodeTip')}
onChange={(e) => setCodeDraft(e.target.value)} />
<label className="flex items-center gap-1.5 text-xs cursor-pointer"><Checkbox checked={sel.valid} onCheckedChange={(c) => patchSel({ valid: !!c })} /> {t('awed.valid')}</label>
<div className="flex-1" />
<button className="text-muted-foreground hover:text-destructive" onClick={() => delRef(sel.code)}><Trash2 className="size-4" /></button>
@@ -1084,7 +1116,7 @@ function ReferencesPanel({ code, presets, meta, awardValidFrom, awardValidTo, on
? t('awed.refValidHintAward', { from: openEnded(awardValidFrom), to: openEnded(awardValidTo) })
: t('awed.refValidHint')}
</p>
<div className="flex justify-end pt-1"><Button size="sm" className="h-7" onClick={() => sel && saveRef(sel)}><Save className="size-3.5 mr-1" /> {t('awed.saveReference')}</Button></div>
<div className="flex justify-end pt-1"><Button size="sm" className="h-7" onClick={() => sel && saveSelected(sel)}><Save className="size-3.5 mr-1" /> {t('awed.saveReference')}</Button></div>
</div>
)}
</div>
+60 -9
View File
@@ -1,10 +1,11 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { spotStatusKey, inferSpotMode, spotModeCategory } from '@/lib/spot';
import { SPOT_MARKERS, activeMarkers } from '@/lib/spotMarkers';
import { applySpotDisplay, readSpotDisplayOptions } from '@/lib/spotDisplay';
import { writeUiPref } from '@/lib/uiPref';
// BandMap — vertical spectrum panel inspired by Log4OM.
// - Full band is always visible; zoom changes pixels-per-kHz, scroll
@@ -217,7 +218,37 @@ function statusStyle(s: string): { pill: string; bar: string; line: string; dot:
// Pixels-per-kHz at each zoom step. Base 8 px/kHz means a stacked spot
// every 2.75 kHz fits without anti-overlap kicking in — comfortable for
// most bands. Higher levels are for fine inspection of crowded sub-bands.
const PX_PER_KHZ = [8, 16, 32, 64, 128, 256];
// Zoom steps, px per kHz. 2 and 4 sit below the old floor of 8 so a whole band
// fits without FIT taking the scale away from you: 20 m end to end is 700 px at
// 2 px/kHz, which scrolls in one screen on most displays.
const PX_PER_KHZ = [2, 4, 8, 16, 32, 64, 128, 256];
const DEFAULT_ZOOM_IDX = PX_PER_KHZ.indexOf(32);
// Zoom is remembered PER BAND, in ONE json key rather than one key per band:
// lib/uiPref keeps an explicit list of the preferences that travel with data/,
// and thirteen entries there to say the same thing would be thirteen chances to
// forget one.
const ZOOM_KEY = 'opslog.bandMapZoom';
function readZoomMap(): Record<string, number> {
try {
const m = JSON.parse(localStorage.getItem(ZOOM_KEY) || '{}');
return m && typeof m === 'object' ? m : {};
} catch {
return {};
}
}
function readZoom(band: string): number {
const n = readZoomMap()[band];
return Number.isInteger(n) && n >= 0 && n < PX_PER_KHZ.length ? n : DEFAULT_ZOOM_IDX;
}
function writeZoom(band: string, idx: number): void {
const m = readZoomMap();
m[band] = idx;
writeUiPref(ZOOM_KEY, JSON.stringify(m));
}
const SCALE_W = 56;
const PILL_H = 22; // px — height of each callsign pill
const PILL_GAP = 32; // px between scale border and first pill (room for leader)
@@ -252,7 +283,22 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
}, [spotStatusRaw, dispOpts.muteWorked, dispOpts.slotHighlight]);
const range = BAND_RANGES[band];
const segments = SEGMENT_COLORS[band] ?? [];
const [zoomIdx, setZoomIdx] = useState(2); // default 32 px/kHz
const [zoomIdx, setZoomIdx] = useState(() => readZoom(band));
// The docked map follows the rig, so a band change must bring up THAT band's
// remembered zoom.
useEffect(() => { setZoomIdx(readZoom(band)); }, [band]);
// Stored from the interaction, not from an effect on zoomIdx: an effect would
// also fire on the band change above, and in the same commit it would still be
// holding the PREVIOUS band's index — writing it over the new band's. Doing it
// where the operator actually turns the wheel keeps the two apart. (Running
// twice under StrictMode is harmless: the same value is stored.)
const changeZoom = useCallback((delta: number) => {
setZoomIdx((z) => {
const n = Math.max(0, Math.min(PX_PER_KHZ.length - 1, z + delta));
if (n !== z) writeZoom(band, n);
return n;
});
}, [band]);
const scrollerRef = useRef<HTMLDivElement | null>(null);
const [containerH, setContainerH] = useState(400);
@@ -421,7 +467,7 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
if (!range) return;
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
setZoomIdx((z) => Math.max(0, Math.min(PX_PER_KHZ.length - 1, z + (e.deltaY > 0 ? -1 : 1))));
changeZoom(e.deltaY > 0 ? -1 : 1);
}
};
el.addEventListener('wheel', onWheel, { passive: false });
@@ -476,8 +522,10 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
// Tick step (where small marks land) and label step (where the kHz
// number is printed) are decoupled so the scale shows ~10-20 numeric
// labels per viewport regardless of zoom — at base 8 px/kHz we want
// labels every 25 kHz, not every 250.
// labels per viewport regardless of zoom — at 8 px/kHz we want labels
// every 25 kHz, not every 250. The bare values below are the floor, and
// they are what the 2 px/kHz step lands on: a whole band in one screen
// wants a number every 100 kHz, not every 25.
let tickStep = 50;
let labelStep = 100;
if (pxPerKHz >= 4) { tickStep = 25; labelStep = 50; }
@@ -502,14 +550,17 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
return (
<div className="h-full w-full flex flex-col min-h-0 bg-card">
<div className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground bg-muted/40 border-b border-border flex flex-nowrap items-center gap-0.5 shrink-0">
<span className="flex-1 min-w-0 truncate">{t('bmp.map')} · {band}</span>
<button type="button" onClick={() => setZoomIdx((z) => Math.max(0, z - 1))} disabled={fitToBand || zoomIdx === 0}
{/* The band alone. "Map · 20M" spent a third of a narrow header saying
what the panel obviously is, and with four band maps side by side it
was four times the same word. */}
<span className="flex-1 min-w-0 truncate">{band}</span>
<button type="button" onClick={() => changeZoom(-1)} disabled={fitToBand || zoomIdx === 0}
className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
title={t('bmp.zoomOut')}>
<Minus className="size-3" />
</button>
<span className="shrink-0 font-mono text-[10px] normal-case tracking-normal whitespace-nowrap px-0.5">{fitToBand ? t('bmp.fit') : `${pxPerKHz}px/kHz`}</span>
<button type="button" onClick={() => setZoomIdx((z) => Math.min(PX_PER_KHZ.length - 1, z + 1))} disabled={fitToBand || zoomIdx === PX_PER_KHZ.length - 1}
<button type="button" onClick={() => changeZoom(1)} disabled={fitToBand || zoomIdx === PX_PER_KHZ.length - 1}
className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
title={t('bmp.zoomIn')}>
<Plus className="size-3" />
+23 -19
View File
@@ -4,6 +4,7 @@ import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import { sunTimes } from '@/lib/sun';
import { isQSLConfirmed } from '@/lib/qsl';
import { useI18n } from '@/lib/i18n';
import { BandSlotQSOs } from '../../wailsjs/go/main/App';
import type { WorkedBeforeView } from '@/types';
@@ -76,28 +77,31 @@ const STATUS_CLASSES: Record<string, string> = {
dxcc_w: 'bg-mx-dx-work',
};
// Legend entries, in the same colour order as the cells. swatch = the
// background class (or a special ring marker for the current-entry cell).
// Legend entries, in the same colour order as the cells — and the same order and
// i18n keys the Appearance panel's colour pickers use, so the two can never
// disagree about which green is which. swatch = the background class (or a
// special ring marker for the current-entry cell).
const LEGEND: { swatch: string; ring?: boolean; label: string }[] = [
{ swatch: 'bg-mx-call-conf', label: 'Call confirmed' },
{ swatch: 'bg-mx-call-work', label: 'Call worked' },
{ swatch: 'bg-mx-dx-conf', label: 'Entity confirmed' },
{ swatch: 'bg-mx-dx-work', label: 'Entity worked' },
{ swatch: 'bg-mx-none', label: 'Not worked' },
{ swatch: 'bg-mx-none', ring: true, label: 'Current entry' },
{ swatch: 'bg-mx-call-conf', label: 'mx.callConf' },
{ swatch: 'bg-mx-call-work', label: 'mx.callWork' },
{ swatch: 'bg-mx-dx-conf', label: 'mx.dxConf' },
{ swatch: 'bg-mx-dx-work', label: 'mx.dxWork' },
{ swatch: 'bg-mx-none', label: 'mx.none' },
{ swatch: 'bg-mx-none', ring: true, label: 'mx.current' },
];
function cellTitle(band: string, cls: string, status: string, current: boolean): string {
function cellTitle(t: (k: string) => string, band: string, cls: string, status: string, current: boolean): string {
const desc =
status === 'call_c' ? 'This callsign confirmed' :
status === 'call_w' ? 'This callsign worked (not confirmed)' :
status === 'dxcc_c' ? 'Entity confirmed (other callsign)' :
status === 'dxcc_w' ? 'Entity worked (other callsign)' :
'Never worked';
return `${band} ${cls}: ${desc}${current ? ' — current entry' : ''}`;
status === 'call_c' ? t('mx.tipCallConf') :
status === 'call_w' ? t('mx.tipCallWork') :
status === 'dxcc_c' ? t('mx.tipDxConf') :
status === 'dxcc_w' ? t('mx.tipDxWork') :
t('mx.tipNone');
return `${band} ${cls}: ${desc}${current ? ' — ' + t('mx.current') : ''}`;
}
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true, lat, lon, forCall, onEditQso }: Props) {
const { t } = useI18n();
// Cell drill-down: which band+class the operator clicked, or null.
const [slot, setSlot] = useState<{ band: string; cls: string } | null>(null);
// Columns from the operator's configured bands (so the matrix shows only the
@@ -308,7 +312,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
return (
<td
key={b.tag}
title={cellTitle(b.tag, cls, st, isCurrent) + (st ? ' — click to list the QSOs' : '')}
title={cellTitle(t, b.tag, cls, st, isCurrent) + (st ? ' — ' + t('mx.tipClick') : '')}
onClick={st ? () => setSlot({ band: b.tag, cls }) : undefined}
className={cn(
'w-[28px] h-[24px] rounded transition-colors p-0',
@@ -316,7 +320,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
// Only a filled cell has anything to show — an empty one
// stays inert rather than opening a "no QSOs" dialog.
st && 'cursor-pointer hover:brightness-110',
isCurrent && 'ring-2 ring-warning ring-inset',
isCurrent && 'ring-2 ring-mx-cur ring-inset',
)}
/>
);
@@ -335,10 +339,10 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
className={cn(
'inline-block size-3 rounded shrink-0',
l.swatch,
l.ring && 'ring-2 ring-warning ring-inset',
l.ring && 'ring-2 ring-mx-cur ring-inset',
)}
/>
{l.label}
{t(l.label)}
</span>
))}
</div>
+156 -26
View File
@@ -5,12 +5,13 @@
// when an Ultrabeam is bidirectional, the opposite one when reversed); a small
// red marker on the bezel shows the short-path bearing to the DX. Click the dial
// to turn the antenna there.
import { useMemo } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { geoAzimuthalEquidistant, geoPath, geoGraticule10 } from 'd3-geo';
import { feature } from 'topojson-client';
import landTopo from 'world-atlas/land-110m.json';
import { Compass, X } from 'lucide-react';
import { Compass, X, Play, Square } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
// Decode the coastline outline once (≈110 m simplified land polygons).
const LAND = feature(landTopo as any, (landTopo as any).objects.land);
@@ -29,6 +30,11 @@ interface Props {
onSelectRotor?: (i: number) => void; // switch the active rotor
onGoto?: (az: number) => void; // click-to-turn
onClose?: () => void;
// Quick-turn buttons and the azimuth box, shown only where the caller wants
// them: Station Control draws its own GoTo/Stop around this compass, and two
// sets of the same controls side by side would be nothing but confusing.
presets?: { label: string; azimuth: number }[];
onStop?: () => void;
}
const SIZE = 168;
@@ -41,7 +47,43 @@ function pt(az: number, radius: number): [number, number] {
return [C + radius * Math.cos(a), C + radius * Math.sin(a)];
}
export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled, rotors, activeRotor, onSelectRotor, onGoto, onClose }: Props) {
export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled, rotors, activeRotor, onSelectRotor, onGoto, onClose, presets, onStop }: Props) {
const { t } = useI18n();
// Raw text, not a number: binding the input to a normalised value makes
// Backspace fight the operator on the way from "230" to "23". It is parsed
// when it is sent, and only then.
const [azText, setAzText] = useState('');
const showControls = !!(presets || onStop);
// Which preset was just pressed, so it can light up for a moment.
//
// A rotor takes seconds to start moving and the needle barely twitches at
// first, so without this the only answer to "did that register?" is to press
// it again — which is how an antenna ends up ordered somewhere twice. The
// acknowledgement has to come from the button itself, at once.
const [flashIdx, setFlashIdx] = useState<number | null>(null);
const flashTimer = useRef<number | undefined>(undefined);
useEffect(() => () => window.clearTimeout(flashTimer.current), []);
const pressPreset = (i: number, az: number) => {
if (!onGoto) return;
setFlashIdx(i);
window.clearTimeout(flashTimer.current);
flashTimer.current = window.setTimeout(() => setFlashIdx(null), 450);
onGoto(az);
};
// 0-359 and nothing else. 360 is refused rather than folded to 0 — it is
// almost always a typo for 36 or 306, and a rotor swinging through north on a
// slip of the finger is worth one rejected keypress.
const sendAz = () => {
const s = azText.trim();
if (s === '' || !onGoto) return;
const n = Number(s);
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0 || n > 359) return;
onGoto(n);
setAzText('');
};
const cardinals = useMemo(
() => [ { d: 0, l: 'N' }, { d: 45, l: 'NE' }, { d: 90, l: 'E' }, { d: 135, l: 'SE' },
{ d: 180, l: 'S' }, { d: 225, l: 'SW' }, { d: 270, l: 'W' }, { d: 315, l: 'NW' } ],
@@ -72,6 +114,35 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
const headLabel = headings.length ? headings[0] : null;
// Short and long path to the DX, in figures.
//
// The bezel already carries the short path as a red marker, but a marker is a
// direction, not a number — the same pair sits in the status bar at 10px and
// operators reported not being able to read it. It lives here because it
// belongs to the compass: every place that draws one gets the readout, instead
// of each caller inventing its own. Clickable when the caller can turn, like
// the status bar's. Built as a value because it is placed in one of two
// columns depending on whether the controls are shown.
const pathReadout = (
<div className="flex gap-1.5 mt-2 font-mono w-full">
{([['SP', bearing ?? null], ['LP', bearing == null ? null : (bearing + 180) % 360]] as const).map(([lbl, az]) => (
<button key={lbl} type="button" disabled={az == null || !onGoto}
onClick={() => { if (az != null && onGoto) onGoto(Math.round(az)); }}
title={az == null ? '' : `${lbl} ${Math.round(az)}°`}
className={
'flex-1 rounded-md border py-1 text-xs font-semibold tabular-nums transition-colors active:scale-95 ' +
(az == null
? 'border-border text-muted-foreground/50 cursor-not-allowed'
: onGoto
? 'border-info-border text-info-muted-foreground hover:bg-info-muted cursor-pointer'
: 'border-border text-muted-foreground cursor-default')
}>
{lbl} {az == null ? '—' : `${Math.round(az)}°`}
</button>
))}
</div>
);
return (
<section className="flex flex-col h-full min-h-0 rounded-lg border border-border bg-card overflow-hidden">
{/* Header — matches the WinKeyer / Voice keyer panels. */}
@@ -128,9 +199,13 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
</div>
)}
{/* Dial on the left, controls on the right. The controls column is sized to
fit WITHIN the dial's height: the widget sits in a row whose height is
set by the entry strip, so it may grow sideways but never downwards. */}
<div className="flex items-start gap-2 p-2 min-h-0">
{/* flex-col: the readout goes BELOW the dial. This wrapper was a row, so a
sibling of the <svg> landed beside it. */}
<div className="flex flex-col items-center justify-center p-2 min-h-0">
<div className="flex flex-col items-center justify-center min-h-0 shrink-0">
<svg
viewBox={`0 0 ${SIZE} ${SIZE}`}
className={onGoto ? 'cursor-pointer select-none' : 'select-none'}
@@ -191,30 +266,85 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
<circle cx={C} cy={C} r={3.5} fill="#15803d" stroke="#fff" strokeWidth={1} />
</svg>
{/* Short and long path to the DX, in figures.
The bezel already carries the short path as a red marker, but a
marker is a direction, not a number — the same pair sits in the
status bar at 10px and operators reported not being able to read it.
Here because it belongs to the compass: every place that draws one
gets the readout, instead of each caller inventing its own.
Clickable when the caller can turn, like the status bar's. */}
<div className="flex gap-1.5 mt-2 font-mono w-full">
{([['SP', bearing ?? null], ['LP', bearing == null ? null : (bearing + 180) % 360]] as const).map(([lbl, az]) => (
<button key={lbl} type="button" disabled={az == null || !onGoto}
onClick={() => { if (az != null && onGoto) onGoto(Math.round(az)); }}
title={az == null ? '' : `${lbl} ${Math.round(az)}°`}
className={
'flex-1 rounded-md border py-1 text-xs font-semibold tabular-nums transition-colors ' +
(az == null
? 'border-border text-muted-foreground/50 cursor-not-allowed'
: onGoto
? 'border-info-border text-info-muted-foreground hover:bg-info-muted cursor-pointer'
: 'border-border text-muted-foreground cursor-default')
}>
{lbl} {az == null ? '—' : `${Math.round(az)}°`}
{/* With the controls column present the readout goes at the FOOT OF IT
instead: the dial sets the widget's height, the controls are shorter
than the dial, and that leftover space is exactly the right size for
the pair. Under the dial it would push the whole widget taller. */}
{!showControls && pathReadout}
</div>
{/* Quick turns + free azimuth + Stop. */}
{showControls && (
<div className="flex flex-col gap-1.5 flex-1 min-w-0">
{/* Two columns so six regions fit beside the dial rather than under
it. An operator with fewer keeps the same compact block. */}
{!!presets?.length && (
<div className="grid grid-cols-2 gap-1">
{presets.map((p, i) => (
<button
key={`${p.label}-${i}`}
type="button"
disabled={!onGoto}
onClick={() => pressPreset(i, p.azimuth)}
title={`${p.label}${p.azimuth}°`}
className={cn(
'rounded-md border py-1 text-xs font-semibold truncate transition-all duration-150 active:scale-95',
flashIdx === i
// Lit, and showing the azimuth it just sent: the label alone
// would only say the press landed, not what was ordered.
? 'border-success bg-success text-success-foreground scale-95'
: 'border-border bg-muted/40',
onGoto && flashIdx !== i ? 'hover:bg-muted' : '',
!onGoto ? 'opacity-50 cursor-not-allowed' : '',
)}
>
{flashIdx === i ? `${p.azimuth}°` : p.label}
</button>
))}
</div>
)}
{/* Free azimuth: Enter sends, so the whole thing is type-three-digits
-and-go without reaching for the mouse. */}
<div className="flex gap-1">
<input
type="text"
inputMode="numeric"
value={azText}
onChange={(e) => setAzText(e.target.value.replace(/[^0-9]/g, '').slice(0, 3))}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); sendAz(); } }}
placeholder={t('rotor.azPh')}
title={t('rotor.azTitle')}
disabled={!onGoto}
className="min-w-0 flex-1 rounded-md border border-border bg-background px-2 py-1 text-xs font-mono tabular-nums text-center disabled:opacity-50"
/>
<button
type="button"
onClick={sendAz}
disabled={!onGoto || azText.trim() === ''}
title={t('rotor.go')}
className="flex items-center gap-1 rounded-md border border-success/60 bg-success-muted px-2 py-1 text-xs font-bold text-success-muted-foreground transition-transform hover:bg-success/25 active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed"
>
<Play className="size-3" /> {t('rotor.go')}
</button>
))}
</div>
{onStop && (
<button
type="button"
onClick={onStop}
title={t('rotor.stop')}
className="flex items-center justify-center gap-1.5 rounded-md border border-destructive/60 bg-destructive/15 py-1 text-xs font-bold text-destructive hover:bg-destructive/25"
>
<Square className="size-3 fill-current" /> {t('rotor.stop')}
</button>
)}
{/* mt-auto: the readout sits at the FOOT of the column, level with the
bottom of the dial, instead of floating under the Stop button. */}
<div className="mt-auto">{pathReadout}</div>
</div>
)}
</div>
</section>
);
+144 -13
View File
@@ -11,6 +11,7 @@ import {
GetCATSettings, SaveCATSettings, DiscoverFlexRadios,
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop,
GetRotorPresets, SaveRotorPresets, ResetRotorPresets,
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
GetAntGeniusSettings, SaveAntGeniusSettings,
GetTunerGeniusSettings, SaveTunerGeniusSettings,
@@ -1399,16 +1400,25 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [bandDraft, setBandDraft] = useState('');
const [modeDraft, setModeDraft] = useState('');
const [catCfg, setCatCfg] = useState<CATSettings>({
enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120,
enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120, flex_dvk_dax: false,
yaesu_port: '', yaesu_baud: 38400, yaesu_low_lines: false, kenwood_low_lines: false, kenwood_port: '', kenwood_baud: 9600, kenwood_host: '', kenwood_data_mode: 'usb', xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70, xiegu_ptt_line: '',
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
digital_default: 'FT8', share_enabled: false, share_port: 4532,
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0, offset_on: false, offset_hz: 0,
digital_default: 'FT8', share_enabled: false, share_port: 4532, share_proto: 'rigctl', share_tci_port: 40001,
ptt_hotkey_enabled: false, ptt_hotkey: '', ptt_hotkey_toggle: false,
});
// While true, the next key press is captured as the PTT hotkey.
const [capturingPtt, setCapturingPtt] = useState(false);
const [rotors, setRotors] = useState<RotatorDevice[]>([]);
const [rotorPresets, setRotorPresets] = useState<{ label: string; azimuth: number }[]>([]);
// Whether the presets have actually been READ back yet.
//
// An empty list is a legitimate answer — an operator may want no quick-turn
// buttons at all — so the backend stores it as one. That makes "not loaded
// yet" and "deliberately none" the same value, and a Save landing in the first
// state wiped the buttons for good. Saving is gated on having read them, so
// the only empty list that can ever reach the store is one the operator made.
const [rotorPresetsLoaded, setRotorPresetsLoaded] = useState(false);
const [rotatorTesting, setRotatorTesting] = useState(false);
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
@@ -1825,6 +1835,11 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
await reloadClusterServers();
setCatCfg(c);
setRotors((r ?? []) as any);
// Loaded HERE, in the loader that runs on mount — not only in the
// event-driven one below. Missing from this one, the state stayed empty
// on a normal open and Save then wrote an empty list over the operator's
// buttons. See rotorPresetsLoaded for the belt to this brace.
try { setRotorPresets((((await GetRotorPresets()) ?? []) as any)); setRotorPresetsLoaded(true); } catch {}
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
@@ -1868,6 +1883,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
try { setLookup(await GetLookupSettings() as any); } catch {}
try { setCatCfg(await GetCATSettings() as any); } catch {}
try { setRotors(((await GetRotators()) ?? []) as any); } catch {}
try { setRotorPresets((((await GetRotorPresets()) ?? []) as any)); setRotorPresetsLoaded(true); } catch {}
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
@@ -2062,6 +2078,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
await SaveLookupSettings(lookup as any);
await SaveCATSettings(catCfg as any);
await SaveRotators(rotors as any);
// Only once they have been read back — see rotorPresetsLoaded.
if (rotorPresetsLoaded) await SaveRotorPresets(rotorPresets as any);
await SaveUltrabeamSettings(ultrabeam as any);
await SaveAntGeniusSettings(antgenius as any);
await SaveTunerGeniusSettings(tunergenius as any);
@@ -2368,6 +2386,11 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
}
function LookupPanel() {
// The cache lifetime as TYPED, so the box can be emptied and re-filled.
// Re-seeded when the settings arrive from the backend — which is after the
// first render, so it cannot simply be the initial value.
const [ttlText, setTtlText] = useState(String(lookup.cache_ttl_days));
useEffect(() => { setTtlText(String(lookup.cache_ttl_days)); }, [lookup.cache_ttl_days]);
// Per-row provider editor — kept inline because it's only used twice
// and needs closure access to the parent state.
const row = (
@@ -2504,16 +2527,31 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<div className="flex gap-3 items-end">
<div className="space-y-1 w-40">
<Label>{t('lk.ttl')}</Label>
{/* Raw text, not the stored number. Deriving the value from the
number on every keystroke made the box impossible to empty
and "0" itself unreachable, since parseInt('0') || 30 is 30.
Zero is now a real setting, so it has to be typeable. */}
<Input
type="number" min={1} max={3650}
value={lookup.cache_ttl_days}
onChange={(e) => setLookup((s) => ({ ...s, cache_ttl_days: parseInt(e.target.value) || 30 }))}
type="number" min={0} max={3650}
value={ttlText}
onChange={(e) => {
const raw = e.target.value;
setTtlText(raw);
const n = parseInt(raw, 10);
if (Number.isFinite(n) && n >= 0) {
setLookup((s) => ({ ...s, cache_ttl_days: Math.min(n, 3650) }));
}
}}
onBlur={() => setTtlText(String(lookup.cache_ttl_days))}
/>
</div>
<Button variant="outline" onClick={clearCache} disabled={clearing}>
{clearing ? t('lk.clearing') : t('lk.clearCache')}
</Button>
</div>
{lookup.cache_ttl_days === 0 && (
<p className="text-[11px] text-warning mt-2">{t('lk.cacheOff')}</p>
)}
</div>
</>
);
@@ -2842,6 +2880,10 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<span className="text-xs text-muted-foreground">{t('cat.flexDecodeSecsHint')}</span>
</div>
)}
<label className="col-span-2 flex items-start gap-2 text-sm cursor-pointer">
<Checkbox className="mt-0.5" checked={!!catCfg.flex_dvk_dax} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, flex_dvk_dax: !!c }))} />
<span>{t('cat.flexDvkDax')} <span className="text-xs text-muted-foreground">{t('cat.flexDvkDaxHint')}</span></span>
</label>
</>
)}
{catCfg.backend === 'xiegu' && (
@@ -3122,6 +3164,28 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
</>
)}
{/* Transverter offset. OUTSIDE the per-backend blocks on purpose: a
transverter hangs off the IF of whatever radio you own, and the
offset is applied in the CAT manager, above every backend. Tucked
under the OmniRig/Icom section it would have been invisible to the
Yaesu, Kenwood, Xiegu, Flex and TCI operators who need it just as
much. */}
<label className="col-span-2 flex items-start gap-2 text-sm cursor-pointer">
<Checkbox className="mt-0.5" checked={!!catCfg.offset_on}
onCheckedChange={(c) => setCatCfg((s) => ({ ...s, offset_on: !!c }))} />
<span>{t('cat.offsetOn')} <span className="text-xs text-muted-foreground">{t('cat.offsetHint')}</span></span>
</label>
{catCfg.offset_on && (
<div className="col-span-2 flex items-center gap-2 pl-6">
<Label className="text-sm">{t('cat.offsetMhz')}</Label>
<Input
type="number" step="0.000001" className="w-40"
value={(catCfg.offset_hz ?? 0) / 1e6}
onChange={(e) => setCatCfg((s) => ({ ...s, offset_hz: Math.round((parseFloat(e.target.value) || 0) * 1e6) }))}
/>
<span className="text-xs text-muted-foreground">{t('cat.offsetExample')}</span>
</div>
)}
<div className="space-y-1 col-span-2">
<Label>{t('cat.digitalDefault')}</Label>
<Select
@@ -3149,15 +3213,47 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</label>
<p className="text-[11px] text-muted-foreground">{t('cat.shareHint')}</p>
{catCfg.share_enabled && (
<div className="space-y-1 max-w-[200px]">
<Label>{t('cat.sharePort')}</Label>
<PortInput
value={catCfg.share_port || 4532}
fallback={4532}
onChange={(n) => setCatCfg((s) => ({ ...s, share_port: n }))}
/>
<div className="flex flex-wrap items-end gap-4">
{/* One protocol or the other. They answer the same questions about
the same radio, and no program speaks both so this is a
choice, not two switches. */}
<div className="space-y-1">
<Label>{t('cat.shareProto')}</Label>
<Select
value={(catCfg as any).share_proto === 'tci' ? 'tci' : 'rigctl'}
onValueChange={(v) => setCatCfg((s) => ({ ...s, share_proto: v } as any))}
>
<SelectTrigger className="h-8 w-[240px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="rigctl">{t('cat.shareRigctl')}</SelectItem>
<SelectItem value="tci">{t('cat.shareTci')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1 max-w-[200px]">
<Label>{t('cat.sharePort')}</Label>
{(catCfg as any).share_proto === 'tci' ? (
<PortInput
value={(catCfg as any).share_tci_port || 40001}
fallback={40001}
onChange={(n) => setCatCfg((s) => ({ ...s, share_tci_port: n } as any))}
/>
) : (
<PortInput
value={catCfg.share_port || 4532}
fallback={4532}
onChange={(n) => setCatCfg((s) => ({ ...s, share_port: n }))}
/>
)}
</div>
</div>
)}
{catCfg.share_enabled && (catCfg as any).share_proto === 'tci' && catCfg.backend === 'tci' && (
// Both ends TCI: ExpertSDR is almost certainly already holding
// 40001 on this machine, and our server would fail to bind. Worth
// saying here rather than leaving it in the log.
<p className="text-[11px] text-warning">{t('cat.shareTciClash')}</p>
)}
</div>
{/* PTT hotkey a keyboard key that keys the rig while OpsLog is focused.
Uses the Audio PTT method (CAT / RTS / DTR), falling back to CAT. */}
@@ -4083,6 +4179,41 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
)}
</div>
)}
{/* Quick-turn buttons for the rotor widget. Their azimuths start out
computed from the station square, so they are right for THIS QTH
rather than copied from someone else's. */}
<div className="border-t border-border/60 pt-3 space-y-2">
<div className="text-sm font-semibold">{t('rot.presets')}</div>
<p className="text-xs text-muted-foreground">{t('rot.presetsHint')}</p>
<div className="space-y-1.5">
{rotorPresets.map((p, i) => (
<div key={i} className="flex items-center gap-2">
<Input className="w-24" maxLength={6} value={p.label}
placeholder={t('rot.presetName')}
onChange={(e) => setRotorPresets((list) => list.map((x, j) => (j === i ? { ...x, label: e.target.value } : x)))} />
<Input className="w-24" type="number" min={0} max={359} value={p.azimuth}
onChange={(e) => setRotorPresets((list) => list.map((x, j) => (j === i ? { ...x, azimuth: Math.max(0, Math.min(359, parseInt(e.target.value, 10) || 0)) } : x)))} />
<span className="text-xs text-muted-foreground">°</span>
<Button variant="ghost" size="sm" title={t('rot.remove')}
onClick={() => setRotorPresets((list) => list.filter((_, j) => j !== i))}>
<Trash2 className="size-3.5" />
</Button>
</div>
))}
</div>
<div className="flex items-center gap-2">
{rotorPresets.length < 8 && (
<Button variant="outline" size="sm" onClick={() => setRotorPresets((l) => [...l, { label: '', azimuth: 0 }])}>
<Plus className="size-3.5 mr-1" /> {t('rot.presetAdd')}
</Button>
)}
<Button variant="outline" size="sm"
onClick={() => { ResetRotorPresets().then((p) => setRotorPresets((p ?? []) as any)).catch((e) => setErr(String(e?.message ?? e))); }}>
{t('rot.presetsReset')}
</Button>
</div>
</div>
{rotatorTest && (
<div className={cn(
'text-xs rounded-md p-2.5 border',
@@ -3,6 +3,7 @@ import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Squar
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
@@ -82,7 +83,7 @@ type Device = {
id: string; type: string; name: string; host: string;
user?: string; pass?: string; channels?: number; labels: string[];
// Generic HTTP board only. The per-relay URLs win over the patterns.
on_urls?: string[]; off_urls?: string[]; on_pattern?: string; off_pattern?: string;
on_urls?: string[]; off_urls?: string[]; on_pattern?: string; off_pattern?: string; insecure_tls?: boolean;
};
type Relay = { number: number; label: string; on: boolean };
type DevStatus = { id: string; name: string; type: string; connected: boolean; error?: string; relays: Relay[] };
@@ -787,6 +788,12 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
&& [...(device.on_urls ?? []), ...(device.off_urls ?? []), device.on_pattern ?? '', device.off_pattern ?? '']
.some((s) => (s ?? '').includes('{value}'))
&& device.labels.some((l) => !l.trim());
// Any https:// among this board's URLs. A relay box on the LAN signs its own
// certificate, so HTTPS to one cannot be verified — the operator has to say
// whether to accept that, and the question only arises once they type https.
const usesHTTPS = isHTTPGen
&& [...(device.on_urls ?? []), ...(device.off_urls ?? []), device.on_pattern ?? '', device.off_pattern ?? '']
.some((u) => (u ?? '').trim().toLowerCase().startsWith('https://'));
// COM ports for the generic USB-serial relay picker.
const [serialPorts, setSerialPorts] = useState<string[]>([]);
useEffect(() => {
@@ -959,6 +966,19 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
</div>
</div>
<div className="text-[10px] text-muted-foreground">{t('station.patternHint')}</div>
{/* Shown only once an https:// URL is actually in use. A board on
plain HTTP has no certificate to argue about, and an option that
cannot matter yet is one more thing to wonder about. */}
{usesHTTPS && (
<label className="flex items-start gap-2 text-xs cursor-pointer">
<Checkbox className="mt-0.5" checked={!!device.insecure_tls}
onCheckedChange={(c) => onChange({ ...device, insecure_tls: !!c })} />
<span>
{t('station.insecureTls')}
<span className="block text-[10px] text-muted-foreground">{t('station.insecureTlsHint')}</span>
</span>
</label>
)}
<div className="space-y-1">
<Label>{t('station.perRelayUrls')}</Label>
<div className="space-y-1">
File diff suppressed because one or more lines are too long
+66
View File
@@ -0,0 +1,66 @@
// Operator overrides for the band/mode matrix palette.
//
// Applied as inline custom properties on <html>, which is what lets them be
// OVERRIDES rather than a palette: the twelve themes each define their own
// --mx-* ramp in style.css, an inline value wins over all of them, and removing
// it hands the colour straight back to the theme. Nothing has to know which
// theme is active, and switching theme with overrides off is a clean revert.
export type MatrixColors = {
enabled: boolean;
call_confirmed: string;
call_worked: string;
entity_confirmed: string;
entity_worked: string;
not_worked: string;
current_entry: string;
};
// The six settings fields and the CSS custom property each one drives. Also the
// display order — the same order the legend under the matrix reads in, so the
// settings panel and the grid can never disagree about which green is which.
export const MATRIX_VARS: { key: keyof Omit<MatrixColors, 'enabled'>; cssVar: string; label: string }[] = [
{ key: 'call_confirmed', cssVar: '--mx-call-conf', label: 'mx.callConf' },
{ key: 'call_worked', cssVar: '--mx-call-work', label: 'mx.callWork' },
{ key: 'entity_confirmed', cssVar: '--mx-dx-conf', label: 'mx.dxConf' },
{ key: 'entity_worked', cssVar: '--mx-dx-work', label: 'mx.dxWork' },
{ key: 'not_worked', cssVar: '--mx-none', label: 'mx.none' },
{ key: 'current_entry', cssVar: '--mx-cur', label: 'mx.current' },
];
export const emptyMatrixColors = (): MatrixColors => ({
enabled: false,
call_confirmed: '', call_worked: '', entity_confirmed: '',
entity_worked: '', not_worked: '', current_entry: '',
});
// applyMatrixColors stamps (or clears) the overrides on <html>. Safe to call as
// often as you like — it is the whole rendering path, so the settings panel uses
// it for a live preview and the app uses it once at startup.
export function applyMatrixColors(c?: MatrixColors | null): void {
const root = document.documentElement;
for (const { key, cssVar } of MATRIX_VARS) {
const v = c?.enabled ? String(c[key] ?? '').trim() : '';
if (v) root.style.setProperty(cssVar, v);
else root.style.removeProperty(cssVar);
}
}
// effectiveMatrixColor reads what the matrix is ACTUALLY painting right now —
// the override if there is one, else the active theme's value. It is what seeds
// the colour pickers, so the operator starts from the colours in front of them
// instead of from a hardcoded palette that may belong to a different theme.
//
// A custom property's computed value has its var() references substituted, so
// --mx-cur resolves to the theme's --warning rather than to the literal text.
export function effectiveMatrixColor(cssVar: string): string {
try {
const v = getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim();
// <input type="color"> only accepts #rrggbb. Anything else (a theme that
// ever moves to oklch, an empty read during boot) falls back to mid grey
// rather than silently resetting the picker to black.
return /^#[0-9a-f]{6}$/i.test(v) ? v.toLowerCase() : '#808080';
} catch {
return '#808080';
}
}
+15
View File
@@ -36,6 +36,14 @@ let timer: number | undefined;
let inFlight = false;
let lastAz: number | null = null;
let lastMoveAt = 0;
// The last heading anyone received, replayed to whoever subscribes next.
//
// Without it, opening Station Control left the compass blank for one or two
// seconds while every other panel filled at once. Nothing was slow: the status
// bar already had the loop running with a tick pending, so a component mounting
// halfway through an idle interval simply waited out the rest of it. The
// heading was known the whole time — it just had nowhere to be read from.
let last: RotorHeading | null = null;
function schedule(delay: number) {
if (timer !== undefined) window.clearTimeout(timer);
@@ -54,6 +62,7 @@ async function tick() {
if (lastAz !== null && h.azimuth !== lastAz) lastMoveAt = Date.now();
lastAz = h.azimuth;
}
last = h;
subs.forEach((fn) => { try { fn(h); } catch { /* a subscriber must not stop the loop */ } });
} catch {
// Leave the last heading alone: a single failed poll on a shared serial port
@@ -69,6 +78,12 @@ async function tick() {
// unsubscribe. The loop stops when the last subscriber leaves.
export function subscribeRotorHeading(fn: (h: RotorHeading) => void): () => void {
subs.add(fn);
// Hand over what is already known, at once. An Alpha SPID poll is an open,
// a read at 600 baud and a close, so even an immediate one takes a moment —
// the cached heading is what makes the compass appear with the panel rather
// than after it. In a microtask, so a subscriber is never called back before
// subscribeRotorHeading has returned to it.
if (last) { const h = last; queueMicrotask(() => { if (subs.has(fn)) fn(h); }); }
if (timer === undefined && !inFlight) void tick();
return () => {
subs.delete(fn);
+1
View File
@@ -44,6 +44,7 @@ const PORTABLE_KEYS = [
'opslog.clusterSlotHighlight', // cluster/band map: colour calls not worked on this band+mode
'opslog.bandMapWidth', // docked band map: column width (px)
'opslog.bandMapTabWidth', // Band map tab: shared card width (px)
'opslog.bandMapZoom', // band map zoom (px/kHz step) remembered per band, as one {band: index} map
// NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here.
// They are handled by lib/gridPrefs, which scopes the localStorage cache PER
// PROFILE and mirrors to the DB (already per-profile) itself — mirroring them
+7
View File
@@ -85,6 +85,12 @@
--mx-dx-conf: #3730a3; /* entity confirmed*/
--mx-dx-work: #a5b4fc; /* entity worked */
--mx-none: #e7e5e4; /* never worked */
/* Ring on the cell the operator is entering. Declared ONCE, on :root, and
deliberately not repeated per theme: it follows --warning, which every theme
already tunes to its own background. The token exists so the matrix ring can
be recoloured on its own without dragging every other warning in the app
with it (Appearance → matrix colours). */
--mx-cur: var(--warning);
--scrollbar-thumb: #b8a880;
--scrollbar-thumb-hover: #968455;
@@ -974,6 +980,7 @@
--color-mx-dx-conf: var(--mx-dx-conf);
--color-mx-dx-work: var(--mx-dx-work);
--color-mx-none: var(--mx-none);
--color-mx-cur: var(--mx-cur);
--radius: 0.5rem;
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.25.7';
export const APP_VERSION = '0.25.9';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+12
View File
@@ -477,6 +477,8 @@ export function GetLogbookRevision():Promise<string>;
export function GetLookupSettings():Promise<main.LookupSettings>;
export function GetMatrixColors():Promise<main.MatrixColors>;
export function GetMySQLSettings():Promise<main.MySQLSettings>;
export function GetOfflineStatus():Promise<main.OfflineStatus>;
@@ -511,6 +513,8 @@ export function GetRotatorHeading():Promise<main.RotatorHeading>;
export function GetRotators():Promise<Array<main.RotatorDevice>>;
export function GetRotorPresets():Promise<Array<main.RotorPreset>>;
export function GetRowColors():Promise<main.RowColorSettings>;
export function GetSPEStatus():Promise<spe.Status>;
@@ -861,6 +865,8 @@ export function ReloadUDPIntegrations():Promise<Array<string>>;
export function RemovePassphrase(arg1:string):Promise<void>;
export function RenameAwardReference(arg1:string,arg2:string,arg3:string):Promise<void>;
export function RenameDatabase(arg1:string):Promise<void>;
export function RenameLogbook(arg1:string):Promise<void>;
@@ -879,6 +885,8 @@ export function ResetAwardDefs():Promise<Array<award.Def>>;
export function ResetDatabaseToDefault():Promise<void>;
export function ResetRotorPresets():Promise<Array<main.RotorPreset>>;
export function RestartApp():Promise<void>;
export function RestartQSORecorder():Promise<void>;
@@ -947,6 +955,8 @@ export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
export function SaveMatrixColors(arg1:main.MatrixColors):Promise<void>;
export function SaveMySQLSettings(arg1:main.MySQLSettings):Promise<void>;
export function SaveOperatingAntenna(arg1:operating.Antenna):Promise<operating.Antenna>;
@@ -967,6 +977,8 @@ export function SaveRelayAuto(arg1:main.RelayAutoConfig):Promise<void>;
export function SaveRotators(arg1:Array<main.RotatorDevice>):Promise<void>;
export function SaveRotorPresets(arg1:Array<main.RotorPreset>):Promise<void>;
export function SaveRowColors(arg1:main.RowColorSettings):Promise<void>;
export function SaveSelfSpotSettings(arg1:main.SelfSpotSettings):Promise<void>;
+24
View File
@@ -894,6 +894,10 @@ export function GetLookupSettings() {
return window['go']['main']['App']['GetLookupSettings']();
}
export function GetMatrixColors() {
return window['go']['main']['App']['GetMatrixColors']();
}
export function GetMySQLSettings() {
return window['go']['main']['App']['GetMySQLSettings']();
}
@@ -962,6 +966,10 @@ export function GetRotators() {
return window['go']['main']['App']['GetRotators']();
}
export function GetRotorPresets() {
return window['go']['main']['App']['GetRotorPresets']();
}
export function GetRowColors() {
return window['go']['main']['App']['GetRowColors']();
}
@@ -1662,6 +1670,10 @@ export function RemovePassphrase(arg1) {
return window['go']['main']['App']['RemovePassphrase'](arg1);
}
export function RenameAwardReference(arg1, arg2, arg3) {
return window['go']['main']['App']['RenameAwardReference'](arg1, arg2, arg3);
}
export function RenameDatabase(arg1) {
return window['go']['main']['App']['RenameDatabase'](arg1);
}
@@ -1698,6 +1710,10 @@ export function ResetDatabaseToDefault() {
return window['go']['main']['App']['ResetDatabaseToDefault']();
}
export function ResetRotorPresets() {
return window['go']['main']['App']['ResetRotorPresets']();
}
export function RestartApp() {
return window['go']['main']['App']['RestartApp']();
}
@@ -1834,6 +1850,10 @@ export function SaveLookupSettings(arg1) {
return window['go']['main']['App']['SaveLookupSettings'](arg1);
}
export function SaveMatrixColors(arg1) {
return window['go']['main']['App']['SaveMatrixColors'](arg1);
}
export function SaveMySQLSettings(arg1) {
return window['go']['main']['App']['SaveMySQLSettings'](arg1);
}
@@ -1874,6 +1894,10 @@ export function SaveRotators(arg1) {
return window['go']['main']['App']['SaveRotators'](arg1);
}
export function SaveRotorPresets(arg1) {
return window['go']['main']['App']['SaveRotorPresets'](arg1);
}
export function SaveRowColors(arg1) {
return window['go']['main']['App']['SaveRowColors'](arg1);
}
+50
View File
@@ -2021,6 +2021,7 @@ export namespace main {
flex_host: string;
flex_port: number;
flex_spots: boolean;
flex_dvk_dax: boolean;
flex_decode_spots: boolean;
flex_decode_secs: number;
xiegu_port: string;
@@ -2047,9 +2048,13 @@ export namespace main {
tci_spots: boolean;
poll_ms: number;
delay_ms: number;
offset_on: boolean;
offset_hz: number;
digital_default: string;
share_enabled: boolean;
share_port: number;
share_proto: string;
share_tci_port: number;
ptt_hotkey_enabled: boolean;
ptt_hotkey: string;
ptt_hotkey_toggle: boolean;
@@ -2067,6 +2072,7 @@ export namespace main {
this.flex_host = source["flex_host"];
this.flex_port = source["flex_port"];
this.flex_spots = source["flex_spots"];
this.flex_dvk_dax = source["flex_dvk_dax"];
this.flex_decode_spots = source["flex_decode_spots"];
this.flex_decode_secs = source["flex_decode_secs"];
this.xiegu_port = source["xiegu_port"];
@@ -2093,9 +2099,13 @@ export namespace main {
this.tci_spots = source["tci_spots"];
this.poll_ms = source["poll_ms"];
this.delay_ms = source["delay_ms"];
this.offset_on = source["offset_on"];
this.offset_hz = source["offset_hz"];
this.digital_default = source["digital_default"];
this.share_enabled = source["share_enabled"];
this.share_port = source["share_port"];
this.share_proto = source["share_proto"];
this.share_tci_port = source["share_tci_port"];
this.ptt_hotkey_enabled = source["ptt_hotkey_enabled"];
this.ptt_hotkey = source["ptt_hotkey"];
this.ptt_hotkey_toggle = source["ptt_hotkey_toggle"];
@@ -2679,6 +2689,30 @@ export namespace main {
this.cache_ttl_days = source["cache_ttl_days"];
}
}
export class MatrixColors {
enabled: boolean;
call_confirmed: string;
call_worked: string;
entity_confirmed: string;
entity_worked: string;
not_worked: string;
current_entry: string;
static createFrom(source: any = {}) {
return new MatrixColors(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.call_confirmed = source["call_confirmed"];
this.call_worked = source["call_worked"];
this.entity_confirmed = source["entity_confirmed"];
this.entity_worked = source["entity_worked"];
this.not_worked = source["not_worked"];
this.current_entry = source["current_entry"];
}
}
export class MySQLSettings {
enabled: boolean;
@@ -3178,6 +3212,20 @@ export namespace main {
this.motorized = source["motorized"];
}
}
export class RotorPreset {
label: string;
azimuth: number;
static createFrom(source: any = {}) {
return new RotorPreset(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.label = source["label"];
this.azimuth = source["azimuth"];
}
}
export class RowColorRule {
id: string;
color: string;
@@ -3375,6 +3423,7 @@ export namespace main {
off_urls?: string[];
on_pattern?: string;
off_pattern?: string;
insecure_tls?: boolean;
static createFrom(source: any = {}) {
return new StationDevice(source);
@@ -3394,6 +3443,7 @@ export namespace main {
this.off_urls = source["off_urls"];
this.on_pattern = source["on_pattern"];
this.off_pattern = source["off_pattern"];
this.insecure_tls = source["insecure_tls"];
}
}
export class StationRelay {
+144 -143
View File
@@ -46,11 +46,12 @@
"qsl"
],
"total": 0,
"builtin": true
"builtin": true,
"version": 2
},
"references": [
{
"code": "1",
"code": "01",
"name": "Hokkaido",
"dxcc": 0,
"group": "",
@@ -58,16 +59,81 @@
"valid": true
},
{
"code": "10",
"name": "Gunma",
"code": "02",
"name": "Aomori",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "03",
"name": "Iwate",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "04",
"name": "Akita",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "05",
"name": "Yamagata",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "06",
"name": "Miyagi",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "07",
"name": "Fukushima",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "08",
"name": "Niigata",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "09",
"name": "Nagano",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "10",
"name": "Tokyo",
"dxcc": 0,
"group": "",
"subgrp": "",
"pattern": "\\bTok[iy]o\\b",
"valid": true
},
{
"code": "11",
"name": "Saitama",
"name": "Kanagawa",
"dxcc": 0,
"group": "",
"subgrp": "",
@@ -83,16 +149,15 @@
},
{
"code": "13",
"name": "Tokyo",
"name": "Saitama",
"dxcc": 0,
"group": "",
"subgrp": "",
"pattern": "\\bTok[iy]o\\b",
"valid": true
},
{
"code": "14",
"name": "Kanagawa",
"name": "Ibaraki",
"dxcc": 0,
"group": "",
"subgrp": "",
@@ -100,7 +165,7 @@
},
{
"code": "15",
"name": "Niigata",
"name": "Tochigi",
"dxcc": 0,
"group": "",
"subgrp": "",
@@ -108,7 +173,7 @@
},
{
"code": "16",
"name": "Toyama",
"name": "Gunma",
"dxcc": 0,
"group": "",
"subgrp": "",
@@ -116,22 +181,6 @@
},
{
"code": "17",
"name": "Ishikawa",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "18",
"name": "Fukui",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "19",
"name": "Yamanashi",
"dxcc": 0,
"group": "",
@@ -139,31 +188,7 @@
"valid": true
},
{
"code": "2",
"name": "Aomori",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "20",
"name": "Nagano",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "21",
"name": "Gifu",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "22",
"code": "18",
"name": "Shizuoka",
"dxcc": 0,
"group": "",
@@ -171,7 +196,15 @@
"valid": true
},
{
"code": "23",
"code": "19",
"name": "Gifu",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "20",
"name": "Aichi",
"dxcc": 0,
"group": "",
@@ -179,7 +212,7 @@
"valid": true
},
{
"code": "24",
"code": "21",
"name": "Mie",
"dxcc": 0,
"group": "",
@@ -187,15 +220,7 @@
"valid": true
},
{
"code": "25",
"name": "Shiga",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "26",
"code": "22",
"name": "Kyoto",
"dxcc": 0,
"group": "",
@@ -203,23 +228,15 @@
"valid": true
},
{
"code": "27",
"name": "Osaka",
"code": "23",
"name": "Shiga",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "28",
"name": "Hyogo",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "29",
"code": "24",
"name": "Nara",
"dxcc": 0,
"group": "",
@@ -227,24 +244,56 @@
"valid": true
},
{
"code": "3",
"name": "Iwate",
"code": "25",
"name": "Osaka",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "30",
"code": "26",
"name": "Wakayama",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "27",
"name": "Hyogo",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "28",
"name": "Toyama",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "29",
"name": "Fukui",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "30",
"name": "Ishikawa",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "31",
"name": "Tottori",
"name": "Okayama",
"dxcc": 0,
"group": "",
"subgrp": "",
@@ -260,31 +309,31 @@
},
{
"code": "33",
"name": "Okayama",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "34",
"name": "Hiroshima",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "35",
"name": "Yamaguchi",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "34",
"name": "Tottori",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "35",
"name": "Hiroshima",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "36",
"name": "Tokushima",
"name": "Kagawa",
"dxcc": 0,
"group": "",
"subgrp": "",
@@ -292,7 +341,7 @@
},
{
"code": "37",
"name": "Kagawa",
"name": "Tokushima",
"dxcc": 0,
"group": "",
"subgrp": "",
@@ -314,14 +363,6 @@
"subgrp": "",
"valid": true
},
{
"code": "4",
"name": "Miyagi",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "40",
"name": "Fukuoka",
@@ -385,46 +426,6 @@
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "5",
"name": "Akita",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "6",
"name": "Yamagata",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "7",
"name": "Fukushima",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "8",
"name": "Ibaraki",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "9",
"name": "Tochigi",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
}
]
}
+101
View File
@@ -0,0 +1,101 @@
package award
import (
"encoding/json"
"testing"
)
// WAJA is numbered by the JARL, and the numbering is NOT Japan's ordinary
// prefecture code.
//
// The catalog shipped with the government's JIS numbering instead — 01
// Hokkaido, 02 Aomori, 03 Iwate, 04 Miyagi… — which agrees with the JARL's for
// the first three prefectures and then diverges for thirty-five of the
// remaining forty-four. The names were right throughout, so the award still
// counted the right contacts; every reference simply carried the wrong number,
// which is what an operator sends to the JARL when they claim it.
//
// The two schemes agree often enough to look correct at a glance, so this pins
// the places they differ rather than a count. Each pair below is one the old
// list got wrong, and the comment is what the old list said.
func TestCatalogWAJAUsesTheJARLNumbering(t *testing.T) {
raw, ok := CatalogRefs("WAJA")
if !ok {
t.Fatal("WAJA has no reference list in the embedded catalog")
}
var refs []struct {
Code string `json:"code"`
Name string `json:"name"`
Pattern string `json:"pattern"`
}
if err := json.Unmarshal(raw, &refs); err != nil {
t.Fatalf("WAJA references: %v", err)
}
if len(refs) != 47 {
t.Fatalf("WAJA has %d prefectures, want exactly 47", len(refs))
}
byName := map[string]string{}
byCode := map[string]string{}
for _, r := range refs {
byName[r.Name] = r.Code
if prev, dup := byCode[r.Code]; dup {
t.Errorf("number %s is on both %s and %s", r.Code, prev, r.Name)
}
byCode[r.Code] = r.Name
}
for _, c := range []struct{ name, code string }{
{"Hokkaido", "01"}, // the one both schemes agree on, and the anchor
{"Miyagi", "06"}, // was 04
{"Akita", "04"}, // was 05
{"Niigata", "08"}, // was 15 — the JIS number
{"Nagano", "09"}, // was 20
{"Tokyo", "10"}, // was 13, the JIS number everyone recognises
{"Kanagawa", "11"}, // was 14
{"Saitama", "13"}, // was 11
{"Ibaraki", "14"}, // was 8
{"Gunma", "16"}, // was 10
{"Yamanashi", "17"}, // was 19
{"Kyoto", "22"}, // was 26
{"Osaka", "25"}, // was 27
{"Toyama", "28"}, // was 16
{"Ishikawa", "30"}, // was 17
{"Okayama", "31"}, // was 33
{"Tottori", "34"}, // was 31
{"Kagawa", "36"}, // was 37
{"Tokushima", "37"}, // was 36
{"Okinawa", "47"}, // unchanged: the far end of the list was already right
} {
if got := byName[c.name]; got != c.code {
t.Errorf("%s is numbered %q, want %q on the JARL list", c.name, got, c.code)
}
}
// Two digits throughout, as the JARL prints them. Not cosmetic: the codes
// are strings, so "1" sorts between "09" and "10" and the panel showed the
// prefectures in an order no list anywhere uses.
for _, r := range refs {
if len(r.Code) != 2 {
t.Errorf("%s is numbered %q — the JARL list is two digits throughout", r.Name, r.Code)
}
}
// The Tokyo spelling rule has to sit on Tokyo, and Tokyo moved. Left behind
// on the old number it would be matching QTHs for Saitama.
for _, r := range refs {
if r.Pattern == "" {
continue
}
if r.Name != "Tokyo" {
t.Errorf("%s (%s) carries the pattern %q, which belongs to Tokyo", r.Name, r.Code, r.Pattern)
}
}
if byName["Tokyo"] != "" {
for _, r := range refs {
if r.Name == "Tokyo" && r.Pattern == "" {
t.Error("Tokyo lost its spelling pattern in the renumbering — Tokio would stop counting")
}
}
}
}
+42
View File
@@ -266,6 +266,48 @@ func (r *Repo) Upsert(ctx context.Context, awardCode string, ref Ref) error {
return err
}
// Rename changes a reference's CODE, keeping everything else about it.
//
// Wanted because a shipped list can simply be wrong: WAJA went out numbered by
// the Japanese state instead of by the JARL, and the only way to correct it was
// to delete all 47 references and import a new list — losing anything the
// operator had adjusted. The number is the one field an editor could not touch.
//
// A rename, not a delete plus an insert: everything the reference carries — its
// pattern, its DXCC list, its validity window — travels with it, which is the
// whole point of correcting a number rather than replacing an entry.
func (r *Repo) Rename(ctx context.Context, awardCode, oldCode, newCode string) error {
ac := strings.ToUpper(strings.TrimSpace(awardCode))
from := strings.ToUpper(strings.TrimSpace(oldCode))
to := strings.ToUpper(strings.TrimSpace(newCode))
if ac == "" || from == "" || to == "" {
return fmt.Errorf("empty award or reference code")
}
if from == to {
return nil
}
// A collision would REPLACE the other reference and take its name, pattern
// and dates with it — one silently swallowing another, discovered much later
// as a reference that has quietly gone missing.
var n int
if err := r.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM award_references WHERE award_code = ? AND ref_code = ?`, ac, to).Scan(&n); err != nil {
return err
}
if n > 0 {
return fmt.Errorf("%s already has a reference %s", ac, to)
}
res, err := r.db.ExecContext(ctx,
`UPDATE award_references SET ref_code = ? WHERE award_code = ? AND ref_code = ?`, to, ac, from)
if err != nil {
return err
}
if rows, _ := res.RowsAffected(); rows == 0 {
return fmt.Errorf("%s has no reference %s", ac, from)
}
return nil
}
// Delete removes one reference from an award.
func (r *Repo) Delete(ctx context.Context, awardCode, refCode string) error {
_, err := r.db.ExecContext(ctx,
+129
View File
@@ -0,0 +1,129 @@
package awardref
// One-IOTA DXCC entities: a table of the entities that ARE a single IOTA group.
//
// WHY IT EXISTS. QRZ.com carries <iota> for an operator who fills it in, and
// most do not. But for a great many entities the island reference follows from
// the entity alone — a station in Ascension Island is on AF-003, there is
// nothing else it could be — and the entity is known for every callsign, from
// cty.dat, without any callbook at all. So the reference can be filled for an
// operator with no QRZ subscription, on a station that has never touched a
// callbook, before the contact is logged.
//
// Only entities that map to EXACTLY ONE reference are here. France is not: a
// French station is usually on the mainland and on no island at all, and
// guessing would put a reference on hundreds of contacts that earn none.
//
// SOURCE: dxcc_matches_one_iota.json from the IOTA programme
// (www.iota-world.org/islands-on-the-air/downloads/), fetched 2026-08-17,
// 99 entities. It changes only when an entity appears or IOTA re-maps one, so
// it is a table here rather than a download: it then works offline, which is
// where a portable station usually is. To refresh, fetch that file again and
// re-emit this map, sorted by entity number.
//
// Entity names are comments only, joined from internal/dxcc for readability.
var iotaByDXCC = map[int]string{
5: "EU-002", // Aland Islands
10: "AF-002", // Amsterdam & St. Paul Is.
12: "NA-022", // Anguilla
17: "NA-020", // Aves Island
20: "OC-089", // Baker & Howland Islands
21: "EU-004", // Balearic Islands
24: "AN-002", // Bouvet
29: "AF-004", // Canary Islands
34: "OC-038", // Chatham Islands
35: "OC-002", // Christmas Island
36: "NA-011", // Clipperton Island
37: "NA-012", // Cocos Island
38: "OC-003", // Cocos (Keeling) Islands
41: "AF-008", // Crozet Island
43: "NA-095", // Desecheo Island
45: "EU-001", // Dodecanese
62: "NA-021", // Barbados
64: "NA-005", // Bermuda
65: "NA-023", // British Virgin Islands
69: "NA-016", // Cayman Islands
71: "SA-004", // Galapagos Islands
82: "NA-097", // Jamaica
84: "NA-107", // Martinique
91: "SA-036", // Aruba
94: "NA-100", // Antigua & Barbuda
95: "NA-101", // Dominica
96: "NA-103", // Montserrat
97: "NA-108", // St. Lucia
99: "AF-011", // Glorioso Islands
103: "OC-026", // Guam
105: "NA-015", // Guantanamo Bay
106: "EU-114", // Guernsey
111: "AN-003", // Heard Island
114: "EU-116", // Isle Of Man
118: "EU-022", // Jan Mayen
123: "OC-023", // Johnston Island
131: "AF-048", // Kerguelen Islands
133: "OC-039", // Kermadec Islands
138: "OC-020", // Kure Island
141: "SA-002", // Falkland Islands
147: "OC-004", // Lord Howe Island
153: "AN-005", // Macquarie Island
157: "OC-031", // Nauru
159: "AS-013", // Maldives
161: "SA-007", // Malpelo Island
165: "AF-049", // Mauritius
166: "OC-086", // Mariana Islands
167: "EU-053", // Market Reef
169: "AF-027", // Mayotte
171: "OC-072", // Mellish Reef
174: "OC-030", // Midway Island
177: "OC-073", // Minami Torishima
182: "NA-098", // Navassa Island
188: "OC-040", // Niue
189: "OC-005", // Norfolk Island
190: "OC-097", // Samoa
195: "AF-039", // Annobon Island
199: "AN-004", // Peter 1 Island
201: "AF-021", // Pr. Edward & Marion Is.
205: "AF-003", // Ascension Island
207: "AF-017", // Rodriguez Island
211: "NA-063", // Sable Island
217: "SA-013", // San Felix & San Ambrosio
222: "EU-018", // Faroe Islands
238: "AN-008", // South Orkney Islands
240: "AN-009", // South Sandwich Islands
241: "AN-010", // South Shetland Islands
247: "AS-051", // Spratly Islands
249: "NA-104", // St. Kitts & Nevis
250: "AF-022", // St. Helena
252: "NA-094", // St. Paul Island
253: "SA-014", // St. Peter & St. Paul
257: "EU-023", // Malta
270: "OC-048", // Tokelau Islands
273: "SA-010", // Trindade & Martim Vaz
276: "AF-031", // Tromelin Island
277: "NA-032", // St. Pierre & Miquelon
282: "OC-015", // Tuvalu
283: "AS-004", // Uk Base Areas On Cyprus
285: "NA-106", // Us Virgin Islands
297: "OC-053", // Wake Island
301: "OC-017", // Western Kiribati
303: "OC-007", // Willis Island
381: "AS-019", // Singapore
411: "AF-007", // Comoros
453: "AF-016", // Reunion Island
460: "OC-060", // Rotuma Island
489: "OC-112", // Conway Reef
490: "OC-018", // Banaba Island
505: "AS-110", // Pratas Island
506: "AS-116", // Scarborough Reef
509: "OC-027", // Marquesas Islands
512: "OC-176", // Chesterfield Islands
513: "OC-182", // Ducie Island
515: "OC-200", // Swains Island
516: "NA-146", // St. Barthelemy
517: "SA-099", // Curacao
519: "NA-145", // Saba & St. Eustatius
520: "SA-006", // Bonaire
}
// IOTAForDXCC returns the island reference of a DXCC entity that is a single
// IOTA group, or "" for an entity that holds several islands or none.
func IOTAForDXCC(dxcc int) string { return iotaByDXCC[dxcc] }
+51
View File
@@ -0,0 +1,51 @@
package awardref
import (
"regexp"
"testing"
)
// The entities an operator meets: an island group that is its own DXCC, and a
// mainland country that is not.
func TestIOTAForDXCC(t *testing.T) {
cases := []struct {
dxcc int
want string
why string
}{
{205, "AF-003", "Ascension Island is one island and one reference"},
{5, "EU-002", "Aland Islands"},
{12, "NA-022", "Anguilla"},
{24, "AN-002", "Bouvet — the entity whose entry is checked most often and worked least"},
// France holds hundreds of islands and, far more to the point, a
// mainland. Filling a reference here would earn nothing and would put
// one on nearly every European contact in the log.
{227, "", "France is not one IOTA"},
{291, "", "the United States is not one IOTA"},
{0, "", "no entity resolved"},
{99999, "", "not an entity at all"},
}
for _, c := range cases {
if got := IOTAForDXCC(c.dxcc); got != c.want {
t.Errorf("IOTAForDXCC(%d) = %q, want %q — %s", c.dxcc, got, c.want, c.why)
}
}
}
// Every reference in the table must be a well-formed IOTA reference, because
// one that is not would be written onto a contact's award references and count
// for nothing — and would then have to be found by hand, one QSO at a time.
func TestEveryOneIOTAEntryIsWellFormed(t *testing.T) {
ref := regexp.MustCompile(`^(AF|AN|AS|EU|NA|OC|SA)-\d{3}$`)
if len(iotaByDXCC) < 50 {
t.Fatalf("the table holds %d entities — it has been truncated", len(iotaByDXCC))
}
for dxcc, r := range iotaByDXCC {
if dxcc <= 0 {
t.Errorf("entity number %d is not a DXCC entity", dxcc)
}
if !ref.MatchString(r) {
t.Errorf("entity %d maps to %q, which is not an IOTA reference", dxcc, r)
}
}
}
+112
View File
@@ -0,0 +1,112 @@
package awardref
import (
"context"
"path/filepath"
"testing"
"hamlog/internal/db"
)
func renameRepo(t *testing.T) *Repo {
t.Helper()
conn, err := db.Open(filepath.Join(t.TempDir(), "a.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { conn.Close() })
return NewRepo(conn)
}
// Correcting a reference's number must keep the reference.
//
// WAJA shipped numbered by the Japanese state instead of by the JARL, and until
// now the only way to fix that was to delete all 47 references and import a new
// list — losing anything the operator had adjusted. A rename keeps the pattern,
// the entity list and the validity window, because a wrong NUMBER is all that
// was wrong.
func TestRenameKeepsEverythingButTheCode(t *testing.T) {
r := renameRepo(t)
ctx := context.Background()
if err := r.Upsert(ctx, "WAJA", Ref{
Code: "13", Name: "Tokyo", Pattern: `\bTok[iy]o\b`, Valid: true,
DXCCList: []int{339}, ValidFrom: "1970-01-01",
}); err != nil {
t.Fatalf("seed: %v", err)
}
if err := r.Rename(ctx, "WAJA", "13", "10"); err != nil {
t.Fatalf("rename: %v", err)
}
refs, err := r.List(ctx, "WAJA")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(refs) != 1 {
t.Fatalf("WAJA holds %d references after a rename, want 1 — it was copied, not renamed", len(refs))
}
got := refs[0]
if got.Code != "10" {
t.Errorf("code = %q, want 10", got.Code)
}
if got.Name != "Tokyo" || got.Pattern != `\bTok[iy]o\b` {
t.Errorf("the reference lost what it carried: name=%q pattern=%q", got.Name, got.Pattern)
}
if len(got.DXCCList) != 1 || got.DXCCList[0] != 339 || got.ValidFrom != "1970-01-01" {
t.Errorf("the reference lost its entity list or dates: %+v", got)
}
}
// A number already in use must be refused. Left to REPLACE, the rename would
// take the other reference's name, pattern and dates with it — one entry
// silently swallowing another, found much later as a prefecture that has
// quietly gone missing from the list.
func TestRenameRefusesANumberAlreadyTaken(t *testing.T) {
r := renameRepo(t)
ctx := context.Background()
if err := r.Upsert(ctx, "WAJA", Ref{Code: "10", Name: "Gunma", Valid: true}); err != nil {
t.Fatalf("seed: %v", err)
}
if err := r.Upsert(ctx, "WAJA", Ref{Code: "13", Name: "Tokyo", Valid: true}); err != nil {
t.Fatalf("seed: %v", err)
}
if err := r.Rename(ctx, "WAJA", "13", "10"); err == nil {
t.Fatal("renaming onto an existing number was accepted — one reference would have eaten the other")
}
refs, _ := r.List(ctx, "WAJA")
if len(refs) != 2 {
t.Fatalf("WAJA holds %d references, want both still there", len(refs))
}
}
// Renaming something that is not there is an error, not a silent no-op: it
// means the editor and the store disagree about what the award holds.
func TestRenameAnUnknownReferenceFails(t *testing.T) {
r := renameRepo(t)
if err := r.Rename(context.Background(), "WAJA", "99", "10"); err == nil {
t.Error("renaming a reference the award does not have was accepted")
}
}
// Codes are stored upper-cased, so a rename must compare the same way — else
// "eu-048" onto "EU-048" looks like a move and is really the same reference,
// which the collision check has to catch.
func TestRenameIsCaseInsensitive(t *testing.T) {
r := renameRepo(t)
ctx := context.Background()
if err := r.Upsert(ctx, "IOTA", Ref{Code: "EU-048", Name: "Belle-Ile", Valid: true}); err != nil {
t.Fatalf("seed: %v", err)
}
if err := r.Rename(ctx, "iota", "eu-048", "eu-048"); err != nil {
t.Errorf("renaming a reference to itself in another case failed: %v", err)
}
if err := r.Rename(ctx, "IOTA", "eu-048", "eu-049"); err != nil {
t.Fatalf("rename: %v", err)
}
refs, _ := r.List(ctx, "IOTA")
if len(refs) != 1 || refs[0].Code != "EU-049" {
t.Errorf("references = %+v, want the one renamed to EU-049 and upper-cased", refs)
}
}
+52
View File
@@ -79,6 +79,9 @@ type Manager struct {
pollEvery time.Duration
cmdDelay time.Duration // pause after each command (some rigs need it)
// freqOffset is the transverter offset in Hz — see SetFreqOffset. Added to
// what the rig reports, taken off what it is told.
freqOffset int64
}
func NewManager(emit func(RigState)) *Manager {
@@ -185,8 +188,39 @@ func (m *Manager) stopLocked() {
}
}
// SetFreqOffset sets the transverter offset: the number of hertz between what
// the RIG is tuned to and where the station is actually on the air.
//
// A 28 MHz IF driving a 144 MHz transverter is an offset of +116 MHz. Everything
// above this layer — the entry form, the band, the log, the cluster, the shared
// CAT servers — then works in real frequencies, and the rig keeps seeing its own.
//
// Zero disables it, which is why it is a plain number and not a flag plus a
// number: "enabled with an offset of nothing" and "disabled" are the same thing
// to everyone downstream.
func (m *Manager) SetFreqOffset(hz int64) {
m.mu.Lock()
m.freqOffset = hz
m.mu.Unlock()
}
// freqOffsetHz reads the offset.
func (m *Manager) freqOffsetHz() int64 {
m.mu.RLock()
defer m.mu.RUnlock()
return m.freqOffset
}
// SetFrequency dispatches a SetFreq call to the CAT goroutine.
//
// The caller speaks in REAL frequencies (a 2 m spot is 144.300), so the offset
// comes back off before the rig hears it. Without this the offset would be a
// display trick: the readout would say 144 and every spot click, band change and
// memory recall would send the rig somewhere 116 MHz away.
func (m *Manager) SetFrequency(hz int64) error {
if off := m.freqOffsetHz(); off != 0 && hz > off {
hz -= off
}
return m.exec(func(b Backend) error { return b.SetFrequency(hz) })
}
@@ -215,6 +249,10 @@ type splitSetter interface {
// band when it was transmitting on the DX's own frequency. A refusal WSJT-X can
// report is worth far more than a success it cannot check.
func (m *Manager) SetSplit(on bool, txHz int64) error {
// Real frequency in, IF frequency out — same as SetFrequency.
if off := m.freqOffsetHz(); off != 0 && txHz > off {
txHz -= off
}
return m.exec(func(b Backend) error {
s, ok := b.(splitSetter)
if !ok {
@@ -744,6 +782,20 @@ func (m *Manager) run(b Backend, stop, done chan struct{}, cmds chan func(), pol
ns.Enabled = true
ns.Backend = b.Name()
ns.UpdatedAt = time.Now()
// Transverter offset: the rig reports its IF, the operator is on the
// real band. Applied BEFORE the band is worked out, or a 28 MHz IF
// behind a 2 m transverter would log every contact on 10 m — and the
// band the backend may already have filled in is the IF's, so it is
// recomputed rather than trusted.
if off := m.freqOffsetHz(); off != 0 {
if ns.FreqHz != 0 {
ns.FreqHz += off
ns.Band = ""
}
if ns.RxFreqHz != 0 {
ns.RxFreqHz += off
}
}
if ns.FreqHz != 0 && ns.Band == "" {
ns.Band = BandFromHz(ns.FreqHz)
}
+39 -5
View File
@@ -1,6 +1,7 @@
package cat
import (
"errors"
"fmt"
"strings"
"sync"
@@ -513,7 +514,8 @@ func (b *IcomSerial) SetFrequency(hz int64) error {
return fmt.Errorf("invalid frequency")
}
b.lastSetFreq, b.lastSetFreqAt = hz, time.Now()
return b.exec(append([]byte{civ.CmdSetFreq}, civ.FreqToBCD(hz)...)...)
return b.execIdempotent(fmt.Sprintf("set frequency %d Hz", hz),
append([]byte{civ.CmdSetFreq}, civ.FreqToBCD(hz)...)...)
}
func (b *IcomSerial) SetMode(mode string) error {
@@ -523,7 +525,7 @@ func (b *IcomSerial) SetMode(mode string) error {
}
// Set the base mode (keeping the rig's current filter by sending only the
// mode byte), then set the data-mode flag for digital modes.
if err := b.exec(civ.CmdSetMode, code); err != nil {
if err := b.execIdempotent("set mode "+mode, civ.CmdSetMode, code); err != nil {
return err
}
dataByte := byte(0)
@@ -531,16 +533,48 @@ func (b *IcomSerial) SetMode(mode string) error {
dataByte = 1
}
// Filter 0x01 (FIL1) is the conventional default for the data-mode set.
_ = b.exec(civ.CmdExtra, civ.SubDataMode, dataByte, 0x01)
_ = b.execIdempotent("set data mode", civ.CmdExtra, civ.SubDataMode, dataByte, 0x01)
return nil
}
// errIcomAckLost is "the reply never came" — as opposed to a reply that said no
// (NG) or a dead port. Only this one is worth repeating: the command may well
// have been carried out and only its acknowledgement lost. Sentinel rather than
// a formatted string so callers can tell the two apart.
var errIcomAckLost = errors.New("icom: timeout waiting for response")
// execIdempotent runs a SET command and sends it ONCE MORE if the
// acknowledgement is lost.
//
// A missing FB is not a missing command — the rig acts on the frame as soon as it
// decodes it, and what expires is our wait for the answer on a bus shared with
// the rig's own transceive updates. JTDX in "Split Operating: Fake It" moves the
// dial and the mode immediately before every key-down, so those acks queue behind
// each other, and losing one was fatal: rigctld answers RPRT -9, JTDX reads that
// as losing rig control and tears the connection down mid-over. An operator's log
// shows it happening on set_ptt, on set_freq and on set_mode alike, each failure
// followed within 300 ms by a fresh rigctld client — and shows this resend
// rescuing a PTT that would otherwise have ended the over.
//
// Only for commands that say "be in this state": re-sending one changes nothing
// if the first arrived. A relative or incremental command must not come through
// here, which is why this is opt-in per caller rather than folded into exec.
func (b *IcomSerial) execIdempotent(what string, payload ...byte) error {
err := b.exec(payload...)
if err == nil || !errors.Is(err, errIcomAckLost) {
return err
}
applog.Printf("icom: %s — no acknowledgement in %s, sending it once more", what, icomCmdTimeout)
return b.exec(payload...)
}
// SetPTT keys or unkeys the transmitter (CI-V 0x1C 0x00).
func (b *IcomSerial) SetPTT(on bool) error {
state := byte(0)
if on {
state = 1
}
return b.exec(civ.CmdPTT, civ.SubPTT, state)
return b.execIdempotent(fmt.Sprintf("PTT %v", on), civ.CmdPTT, civ.SubPTT, state)
}
// SetPower turns the transceiver on or off (CI-V 0x18). Power-ON is prefixed with
@@ -619,7 +653,7 @@ func (b *IcomSerial) recv(timeout time.Duration, match func(civ.Decoded) bool) (
case <-cancel:
return civ.Decoded{}, fmt.Errorf("icom: interrupted")
case <-deadline:
return civ.Decoded{}, fmt.Errorf("icom: timeout waiting for response")
return civ.Decoded{}, errIcomAckLost
}
}
}
+39
View File
@@ -46,6 +46,18 @@ type TCI struct {
mode string
split bool
tx bool
// txAllowed is what the radio last said about TRANSMIT PERMISSION.
//
// TX_ENABLE is sent by ExpertSDR when a client connects and again whenever
// the band changes, "in case transmitter permission was changed" (§4.3). When
// it is false the radio silently ignores TRX — which is exactly what an
// operator sees as "PTT does nothing", with no error anywhere to explain it.
//
// txAllowedKnown keeps an OLDER ExpertSDR, or a TCI-compatible program that
// never sends TX_ENABLE at all, from being treated as refusing: without a
// word from the radio we key and let it decide.
txAllowed bool
txAllowedKnown bool
lastSig string // last logged state signature (log only on change)
@@ -109,6 +121,10 @@ func (t *TCI) Connect() error {
t.mu.Lock()
t.conn = conn
t.ready = false
// Forget the previous session's transmit permission: the radio announces it
// again on connect, and a refusal remembered from a band we have since left
// would block PTT until it did.
t.txAllowed, t.txAllowedKnown = false, false
t.mu.Unlock()
debugLog.Printf("TCI: connected to %s", url)
go t.reader(conn)
@@ -298,7 +314,22 @@ func (t *TCI) SetMode(mode string) error {
}
// SetPTT keys or unkeys the transmitter (VFO 0).
//
// A refusal by the radio is reported rather than swallowed. ExpertSDR announces
// transmit permission with TX_ENABLE and then simply IGNORES trx when it is
// false — out-of-band frequency, TX disabled in the program, no PA. The command
// went out, nothing happened, and nothing anywhere said why. Now the operator
// is told, and the message names the place to look.
func (t *TCI) SetPTT(on bool) error {
if on {
t.mu.Lock()
known, allowed := t.txAllowedKnown, t.txAllowed
t.mu.Unlock()
if known && !allowed {
return fmt.Errorf("the radio is refusing to transmit (TCI reports TX disabled) — " +
"check the frequency is inside a transmit band and that TX is enabled in ExpertSDR")
}
}
return t.send(fmt.Sprintf("trx:0,%t;", on))
}
@@ -392,6 +423,14 @@ func (t *TCI) handle(msg string) {
if get(0) == "0" {
t.tx = get(1) == "true"
}
case "tx_enable":
if get(0) == "0" {
allowed := get(1) == "true"
if !t.txAllowedKnown || t.txAllowed != allowed {
debugLog.Printf("TCI: the radio %s transmitting", map[bool]string{true: "allows", false: "REFUSES"}[allowed])
}
t.txAllowed, t.txAllowedKnown = allowed, true
}
default:
lname := strings.ToLower(name)
// A click on one of our panorama spots comes back as
+80
View File
@@ -0,0 +1,80 @@
//go:build windows
package cat
import "testing"
// feed pushes messages at the backend the way the radio would.
func feed(t *TCI, msgs ...string) {
for _, m := range msgs {
t.handle(m)
}
}
// "PTT via CAT does nothing on TCI."
//
// ExpertSDR announces transmit permission with TX_ENABLE — on connect, and
// again whenever the band changes "in case transmitter permission was changed"
// (§4.3 of the protocol document). When it is false the radio simply IGNORES
// trx. OpsLog sent the documented command, the radio discarded it, and nothing
// anywhere said why: the operator pressed a dead key.
func TestPTTIsRefusedOutLoudWhenTheRadioForbidsTransmitting(t *testing.T) {
tci := NewTCI("localhost", 40001, "FT8", false)
feed(tci, "tx_enable:0,false")
err := tci.SetPTT(true)
if err == nil {
t.Fatal("keying was accepted while the radio forbids transmitting — the operator gets no reason at all")
}
// The message has to name where to look; "PTT failed" sends nobody anywhere.
for _, want := range []string{"transmit", "ExpertSDR"} {
if !contains(err.Error(), want) {
t.Errorf("the refusal reads %q, which does not mention %q", err.Error(), want)
}
}
// Unkeying is never blocked. Whatever the radio thinks about permission, a
// request to STOP transmitting must always reach it.
if err := tci.SetPTT(false); err != nil && contains(err.Error(), "refusing") {
t.Errorf("unkeying was refused: %v", err)
}
}
// Permission comes back when the operator returns to a band they may use, and
// PTT has to come back with it — not stay blocked until OpsLog is restarted.
func TestPermissionGrantedAgainRestoresPTT(t *testing.T) {
tci := NewTCI("localhost", 40001, "FT8", false)
feed(tci, "tx_enable:0,false")
if err := tci.SetPTT(true); err == nil {
t.Fatal("keying was accepted while forbidden")
}
feed(tci, "tx_enable:0,true")
// No connection here, so the send fails — but it must fail as a TRANSPORT
// error, never as a refusal.
if err := tci.SetPTT(true); err != nil && contains(err.Error(), "refusing") {
t.Errorf("still refusing after permission was granted: %v", err)
}
}
// A radio that never mentions TX_ENABLE — an older ExpertSDR, or one of the
// other programs that speak TCI — must not be treated as refusing. Silence is
// not a "no": we key, and let the radio decide.
func TestSilenceAboutPermissionIsNotARefusal(t *testing.T) {
tci := NewTCI("localhost", 40001, "FT8", false)
if err := tci.SetPTT(true); err != nil && contains(err.Error(), "refusing") {
t.Errorf("a radio that never sent TX_ENABLE was treated as forbidding transmit: %v", err)
}
}
func contains(s, sub string) bool {
return len(sub) == 0 || (len(s) >= len(sub) && indexOf(s, sub) >= 0)
}
func indexOf(s, sub string) int {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}
+144 -23
View File
@@ -18,6 +18,7 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"hamlog/internal/applog"
@@ -355,6 +356,18 @@ func (s *session) run() {
// that gets skipped.
var idleTick = 30 * time.Second
// promptTick is the read deadline used until the login handshake is finished.
// Short, because a node's "login:" / "password:" carries no newline and is only
// visible when the read times out — see the read loop. It costs a few wake-ups
// during the first seconds of a connection and nothing afterwards.
const promptTick = 700 * time.Millisecond
// handshakeWindow bounds how long the fast promptTick applies. A node that has
// a password configured but never asks for one would otherwise keep the loop
// waking every 700 ms for the life of the connection, for a prompt that is never
// coming. Any real login exchange is over in a second or two.
const handshakeWindow = 20 * time.Second
// quietNotice is the silence after which the log says so, once.
const quietNotice = 10 * time.Minute
@@ -398,16 +411,37 @@ func (s *session) runOnce() (time.Time, error) {
// Login: send on first prompt OR blindly after 1.5s. Many DXSpider
// nodes accept the callsign without re-prompting.
loginSent := false
//
// Atomic because the blind-login timer below and the read loop both touch
// these. loginSent used to be a plain bool that the timer NEVER SET: the
// callsign went out and nothing recorded it, so the password branch — gated on
// loginSent — was dead code, and the session could only reach "connected" by
// recognising a welcome banner. On a node whose entire greeting is a bare
// "login:" and which then demands a password (f5mzn.org:9000), that left the
// server stuck at "connecting" for ever while telnet logged in by hand fine.
var loginSent, pwdSent atomic.Bool
// CompareAndSwap, not a plain store: the timer and the loop can reach these at
// the same moment, and the callsign must be written exactly once.
sendLogin := func() {
if s.login != "" && loginSent.CompareAndSwap(false, true) {
_, _ = conn.Write([]byte(s.login + "\r\n"))
}
}
// Sent ONCE per connection. A node that re-prompts is refusing the password,
// and answering with the same one again only loops — better to let the
// refusal show in the console than to hide it behind a retry.
sendPassword := func() {
if s.cfg.Password != "" && loginSent.Load() && pwdSent.CompareAndSwap(false, true) {
_, _ = conn.Write([]byte(s.cfg.Password + "\r\n"))
}
}
if s.login != "" {
go func() {
select {
case <-s.stopCh:
return
case <-time.After(1500 * time.Millisecond):
if !loginSent {
_, _ = conn.Write([]byte(s.login + "\r\n"))
}
sendLogin()
}
}()
}
@@ -453,7 +487,21 @@ func (s *session) runOnce() (time.Time, error) {
var connectedAt time.Time
var quiet time.Duration // how long the node has said nothing
var quietNoticed bool // the long-silence line is said once
var pending string // a line cut in half by a read deadline
markConnected := func() {
if s.snapshot().State == StateConnected {
return
}
connectedAt = time.Now()
s.mu.Lock()
s.status.State = StateConnected
s.status.ConnectedAt = connectedAt
s.status.Error = ""
s.mu.Unlock()
s.emitStatus()
fireInitCommands()
}
rd := bufio.NewReader(conn)
for {
select {
@@ -471,7 +519,21 @@ func (s *session) runOnce() (time.Time, error) {
// Only a REAL error ends the session. A dead peer still gets caught:
// TCP keepalive probes an idle connection and its failure arrives here
// as an error, not as a timeout.
_ = conn.SetReadDeadline(time.Now().Add(idleTick))
// SHORT deadline until the handshake is done, the long idle tick after.
//
// A cluster writes its prompts WITHOUT a trailing newline, and ReadString
// only returns on one — so a prompt is never a "line" at all, it is whatever
// is sitting in the buffer when the read deadline expires. At the ordinary
// 30 s tick that made a bare "login:" invisible for half a minute and a
// following "password:" invisible for another, which is long enough for the
// node to give up on us. Only the handshake needs the fast tick; once logged
// in, a long deadline is exactly what we want (see idleTick).
tick := idleTick
if time.Since(linkUpAt) < handshakeWindow &&
(!loginSent.Load() || (s.cfg.Password != "" && !pwdSent.Load())) {
tick = promptTick
}
_ = conn.SetReadDeadline(time.Now().Add(tick))
chunk, err := rd.ReadString('\n')
if err != nil {
var ne net.Error
@@ -480,16 +542,48 @@ func (s *session) runOnce() (time.Time, error) {
// Keep it: a spot line straddling the deadline would otherwise lose
// its first half and arrive as nonsense, or vanish entirely.
pending += chunk
quiet += idleTick
// …but a newline-less PROMPT is not half a line, it is a question,
// and this is the only place it can ever be seen. Answer it, show it
// in the console (an operator watching a stuck server has a right to
// see what the node actually asked), and drop it so it is not glued
// onto the front of the next real line.
if p := strings.TrimSpace(pending); p != "" {
switch {
case !loginSent.Load() && s.login != "" && isLoginPrompt(p):
s.emitLine(p, false)
pending = ""
sendLogin()
if s.cfg.Password == "" {
markConnected()
}
continue
case !pwdSent.Load() && isPasswordPrompt(p):
s.emitLine(p, false)
pending = ""
if s.cfg.Password == "" {
// Nothing to answer with. Shown in the console rather than
// swallowed: an unanswered "password:" sitting there IS the
// explanation for a server that never finishes connecting,
// and it is something the operator can act on.
continue
}
sendPassword()
markConnected()
continue
}
}
quiet += tick
// Said once at the first long silence, so a genuinely mute node is
// visible without a line every tick.
if quiet == quietNotice {
if !quietNoticed && quiet >= quietNotice {
quietNoticed = true
applog.Printf("cluster[%s] no traffic for %s — still connected", s.cfg.Name, quiet)
}
continue
}
return connectedAt, fmt.Errorf("read: %w", err)
}
quietNoticed = false
quiet = 0
line := pending + chunk
pending = ""
@@ -519,28 +613,23 @@ func (s *session) runOnce() (time.Time, error) {
}
}
// Login on explicit prompt.
if !loginSent && s.login != "" && isLoginPrompt(line) {
_, _ = conn.Write([]byte(s.login + "\r\n"))
loginSent = true
// Login on explicit prompt — the case where the node DID terminate it with
// a newline. The newline-less form is handled on the timeout path above.
if !loginSent.Load() && s.login != "" && isLoginPrompt(line) {
s.emitLine(line, false)
sendLogin()
continue
}
// Password on prompt (rare).
if loginSent && s.cfg.Password != "" && isPasswordPrompt(line) {
_, _ = conn.Write([]byte(s.cfg.Password + "\r\n"))
// Password on prompt.
if !pwdSent.Load() && isPasswordPrompt(line) {
s.emitLine(line, false)
sendPassword()
continue
}
// Mark connected once we've sent login OR seen a welcome banner.
if s.snapshot().State != StateConnected && (loginSent || isWelcome(line)) {
connectedAt = time.Now()
s.mu.Lock()
s.status.State = StateConnected
s.status.ConnectedAt = connectedAt
s.status.Error = ""
s.mu.Unlock()
s.emitStatus()
fireInitCommands()
if loginSent.Load() || isWelcome(line) {
markConnected()
}
// EVERY line goes to the console — spot or not. This is the whole point:
@@ -701,6 +790,38 @@ func parseSpot(line string) (Spot, bool) {
}, true
}
// NewLocalSpot builds the Spot for a DX announcement WE just sent, so it lands
// in the operator's own list at once.
//
// A node does not necessarily broadcast a spot back to the station that sent it:
// DXSpider suppresses the echo to the originator, and a node-side filter can eat
// it too. So an operator spotted a station, watched their own spot list stay
// empty, and concluded the spot had never gone out — when it had. This is the
// spot the echo would have carried, built from what we sent, deliberately the
// same shape so the UI's call+band de-dupe folds the two into one row on the
// nodes that DO echo.
func NewLocalSpot(srv ServerConfig, spotter, dxCall string, freqKHz float64, comment string) Spot {
freqHz := int64(freqKHz*1000 + 0.5)
now := time.Now()
sp := Spot{
SourceID: srv.ID,
SourceName: srv.Name,
Spotter: strings.ToUpper(strings.TrimSpace(spotter)),
DXCall: strings.ToUpper(strings.TrimSpace(dxCall)),
FreqKHz: freqKHz,
FreqHz: freqHz,
Band: bandFromHz(freqHz),
Comment: strings.TrimSpace(comment),
TimeUTC: now.UTC().Format("1504") + "Z",
ReceivedAt: now,
}
// Raw reads like the node's own broadcast — it is what anything showing the
// source line expects, and it keeps a local spot legible in the log.
sp.Raw = fmt.Sprintf("DX de %s: %9.1f %-12s %-30s %s",
sp.Spotter, sp.FreqKHz, sp.DXCall, sp.Comment, sp.TimeUTC)
return sp
}
func isLoginPrompt(s string) bool {
low := strings.ToLower(s)
return strings.Contains(low, "login:") ||
+105
View File
@@ -0,0 +1,105 @@
package cluster
import (
"bufio"
"net"
"strconv"
"strings"
"testing"
"time"
)
// A node whose whole greeting is a bare "login:" — no newline, no banner — and
// which then demands a password must still log in.
//
// Reported on f5mzn.org:9000: telnet by hand worked, OpsLog sat at "connecting"
// for ever. Two faults met there. The prompts carry no newline, and the read
// loop only ever looked at complete LINES, so neither prompt was seen at all;
// and the blind 1.5 s login never recorded that it had sent the callsign, which
// left the password branch — gated on that flag — permanently switched off.
//
// The test speaks the node's side literally: "login:" with no newline, then
// "password:" with no newline, then a spot. It asserts both answers arrive and
// that the session reaches Connected without any welcome banner to lean on.
func TestBareLoginAndPasswordPromptsWithoutNewlines(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
host, portStr, _ := net.SplitHostPort(ln.Addr().String())
port, err := strconv.Atoi(portStr)
if err != nil {
t.Fatal(err)
}
type answers struct{ login, pwd string }
got := make(chan answers, 1)
go func() {
c, err := ln.Accept()
if err != nil {
return
}
defer c.Close()
rd := bufio.NewReader(c)
// No newline, exactly as the node sends it.
if _, err := c.Write([]byte("login: ")); err != nil {
return
}
call, err := rd.ReadString('\n')
if err != nil {
return
}
if _, err := c.Write([]byte("password: ")); err != nil {
return
}
pwd, err := rd.ReadString('\n')
if err != nil {
return
}
got <- answers{strings.TrimSpace(call), strings.TrimSpace(pwd)}
// Something to prove the link is live and parsing again afterwards.
_, _ = c.Write([]byte("DX de F4BPO: 14074.0 OY1CT FT8 1234Z\r\n"))
time.Sleep(time.Second)
}()
spots := make(chan Spot, 4)
s := &session{
cfg: ServerConfig{Name: "pwd node", Host: host, Port: port, Password: "s3cret"},
login: "F4BPO",
onSpot: func(sp Spot) { spots <- sp },
onLine: func(Line) {},
onStatus: func() {},
stopCh: make(chan struct{}),
}
done := make(chan error, 1)
go func() { _, err := s.runOnce(); done <- err }()
defer func() { close(s.stopCh); <-done }()
select {
case a := <-got:
if a.login != "F4BPO" {
t.Errorf("callsign sent = %q, want F4BPO", a.login)
}
if a.pwd != "s3cret" {
t.Errorf("password sent = %q, want s3cret — the prompt carried no newline", a.pwd)
}
case <-time.After(10 * time.Second):
t.Fatal("the node's newline-less prompts were never answered")
}
select {
case sp := <-spots:
if sp.DXCall != "OY1CT" {
t.Errorf("spot from the wrong station: %+v", sp)
}
case <-time.After(5 * time.Second):
t.Fatal("no spot after the login — the stream is not being parsed")
}
// Connected without a welcome banner: the handshake alone must be enough.
if st := s.snapshot().State; st != StateConnected {
t.Errorf("state = %q, want %q — the server would still show as connecting", st, StateConnected)
}
}
+7
View File
@@ -117,6 +117,13 @@ func UploadClublogADIF(ctx context.Context, client *http.Client, cfg ServiceConf
if api == "" {
api = clublogAppAPIKey
}
// putlogs.php reads the upload as an ADIF *file*, so it needs a header.
// Callers that already build a full document (the QSL Manager) pass one;
// callers that only have <EOR>-terminated records (the on-close flush) do
// not, and a headerless file is rejected. Same rule as the LoTW writer.
if !strings.Contains(strings.ToUpper(adifDoc), "<EOH>") {
adifDoc = "OpsLog Club Log upload\n<PROGRAMID:6>OpsLog <EOH>\n" + adifDoc
}
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
+72
View File
@@ -267,6 +267,78 @@ func UploadEQSL(ctx context.Context, client *http.Client, user, pswd, qthNick, a
return UploadResult{OK: false, Message: reason}, fmt.Errorf("eqsl: upload failed: %s", reason)
}
// eqslBatchMax is the largest number of records eQSL asks a single upload to
// carry ("upload only files smaller than about 1000 records at a time", eQSL's
// own ImportADIF interface notes). Callers chunk to this.
const eqslBatchMax = 1000
// UploadEQSLBatch pushes MANY ADIF records to eQSL.cc in ONE request.
//
// ImportADIF.cfm is a file importer, not a per-QSO endpoint: it takes one *or
// more* QSOs and answers "Result: X out of Y records added" — the plural in its
// own reply. So an on-close sweep or a bulk upload is one request, not one per
// contact. No ADIF header is prepended: the single-record path has always posted
// bare <EOR> records and eQSL accepts them (per ADIF, a file starting with '<'
// has no header), and there is no reason to change what is known to work.
//
// A PARTIAL result ("97 out of 100") sets Ignored so the caller can say so.
// eQSL does not identify which records it left out, and in practice they are
// QSOs it already holds — the same duplicate that UploadEQSL reports as success.
func UploadEQSLBatch(ctx context.Context, client *http.Client, user, pswd, qthNick string, records []string) (UploadResult, error) {
user = strings.ToUpper(strings.TrimSpace(user))
if user == "" {
return UploadResult{}, fmt.Errorf("eqsl: username (callsign) not set")
}
if strings.TrimSpace(pswd) == "" {
return UploadResult{}, fmt.Errorf("eqsl: password not set")
}
docs := make([]string, 0, len(records))
for _, r := range records {
if strings.TrimSpace(r) == "" {
continue
}
docs = append(docs, eqslRecordWithNickname(strings.TrimRight(r, "\r\n"), qthNick))
}
if len(docs) == 0 {
return UploadResult{}, fmt.Errorf("eqsl: empty adif batch")
}
if len(docs) > eqslBatchMax {
return UploadResult{}, fmt.Errorf("eqsl: batch of %d exceeds the %d-record limit", len(docs), eqslBatchMax)
}
body, err := eqslPost(ctx, client, user, pswd, strings.Join(docs, "\n"))
if err != nil {
return UploadResult{OK: false, Message: body}, err
}
if reason := authErrEQSL(body); reason != "" {
return UploadResult{OK: false, Message: reason}, fmt.Errorf("eqsl: %s", reason)
}
// The counted result is read FIRST here, unlike the single-record path: a
// batch reply routinely carries both "Result: 97 out of 100 records added"
// and a "Bad record: Duplicate" line for the other three, and matching the
// duplicate first would throw away the count that says the rest went in.
if m := eqslResultRe.FindStringSubmatch(body); m != nil {
added, _ := strconv.Atoi(m[1])
total, _ := strconv.Atoi(m[2])
if added >= 1 {
return UploadResult{OK: true, Message: strings.TrimSpace(m[0]), Ignored: added < total}, nil
}
// "0 out of N" — nothing added. A re-upload of QSOs eQSL already holds
// lands here, and that is not a failure.
if strings.Contains(strings.ToLower(body), "duplicate") {
return UploadResult{OK: true, Message: "already in logbook"}, nil
}
reason := eqslReason(body)
return UploadResult{OK: false, Message: reason}, fmt.Errorf("eqsl: batch upload failed: %s", reason)
}
if strings.Contains(strings.ToLower(body), "duplicate") {
return UploadResult{OK: true, Message: "already in logbook"}, nil
}
reason := eqslReason(body)
return UploadResult{OK: false, Message: reason}, fmt.Errorf("eqsl: batch upload failed: %s", reason)
}
// eqslReason trims an eQSL reply to a short human-readable reason: the first
// "Error:" / "Warning:" / "Bad record:" line if present, else the whole body
// (capped), else a generic phrase.
+180 -30
View File
@@ -295,9 +295,10 @@ func (m *Manager) CloseUploadCount() int {
}
// FlushOnClose uploads every QSO due for an on-close push, scanning the whole
// logbook (not just this session). Called from the shutdown sequence. QRZ/Club
// Log go one-by-one (fast HTTP); LoTW is signed and uploaded as a single TQSL
// batch. Returns the number of QSOs uploaded successfully.
// logbook (not just this session). Called from the shutdown sequence. QRZ and
// the rest go one-by-one (fast HTTP, no batch API); LoTW is signed and uploaded
// as a single TQSL batch, and Club Log goes through its batch endpoint.
// Returns the number of QSOs uploaded successfully.
func (m *Manager) FlushOnClose() int {
if m.deps.CloseUploadIDs == nil {
return 0
@@ -312,41 +313,190 @@ func (m *Manager) FlushOnClose() int {
switch svc {
case ServiceLoTW:
uploaded += m.flushLoTWBatch(ids, cfg.LoTW)
case ServiceQRZ:
for _, id := range ids {
if ok, _ := m.upload(svc, id, cfg.QRZ); ok {
uploaded++
}
}
case ServiceClublog:
for _, id := range ids {
if ok, _ := m.upload(svc, id, cfg.Clublog); ok {
uploaded++
}
}
case ServiceHRDLog:
for _, id := range ids {
if ok, _ := m.upload(svc, id, cfg.HRDLog); ok {
uploaded++
}
}
uploaded += m.flushClublogBatch(ids, cfg.Clublog)
case ServiceEQSL:
for _, id := range ids {
if ok, _ := m.upload(svc, id, cfg.EQSL); ok {
uploaded++
}
}
uploaded += m.flushEQSLBatch(ids, cfg.EQSL)
case ServiceQRZ:
uploaded += m.flushOneByOne(svc, ids, cfg.QRZ)
case ServiceHRDLog:
uploaded += m.flushOneByOne(svc, ids, cfg.HRDLog)
case ServiceCloudlog:
for _, id := range ids {
if ok, _ := m.upload(svc, id, cfg.Cloudlog); ok {
uploaded++
}
}
uploaded += m.flushOneByOne(svc, ids, cfg.Cloudlog)
}
}
return uploaded
}
// uploadPace is the shortest gap between two consecutive single-QSO uploads in
// an on-close sweep. QRZ, HRDLog and Cloudlog have no batch endpoint — HRDLog's
// NewEntry.aspx keeps only the first record of a multi-record ADIF — so a sweep
// of a freshly imported log is unavoidably one request per contact. It does not
// have to arrive as fast as the link allows, though: that burst is what a
// service reads as a robot, and what got an operator's IP threatened at Club Log
// (see flushClublogBatch). The gap costs nothing in practice, since a round trip
// to any of these already takes longer than it.
const uploadPace = 200 * time.Millisecond
// flushOneByOne uploads ids one request at a time, paced. For the services that
// have no batch API; everything else has its own flush<Service>Batch.
func (m *Manager) flushOneByOne(svc Service, ids []int64, cfg ServiceConfig) int {
uploaded := 0
for i, id := range ids {
if i > 0 {
time.Sleep(uploadPace)
}
if ok, _ := m.upload(svc, id, cfg); ok {
uploaded++
}
}
return uploaded
}
// eqslBatchChunk is how many QSOs go into one ImportADIF.cfm request. eQSL's own
// limit is ten times this (eqslBatchMax); the smaller chunk keeps one refused
// record from taking a thousand others down with it, and keeps the form body
// small enough to be unremarkable.
const eqslBatchChunk = 100
// flushEQSLBatch uploads the on-close eQSL QSOs through ImportADIF.cfm in
// batches instead of one request per contact. Same reasoning as
// flushClublogBatch — eQSL's import endpoint has always taken a whole file, so
// the one-at-a-time loop was making hundreds of requests it never needed to.
func (m *Manager) flushEQSLBatch(ids []int64, cfg ServiceConfig) int {
uploaded := 0
var records []string
var kept []int64
send := func() {
if len(records) == 0 {
return
}
// nil client: UploadEQSLBatch then builds one with a 30 s timeout rather
// than reusing the 20 s budget of a single realtime QSO.
res, err := UploadEQSLBatch(context.Background(), nil, cfg.Username, cfg.Password, cfg.QTHNickname, records)
if err != nil || !res.OK {
if err == nil {
err = errFromResult(res)
}
m.logf("extsvc: eqsl batch upload (%d QSOs) failed: %v", len(kept), err)
if m.deps.NotifyError != nil {
m.deps.NotifyError(ServiceEQSL, 0, err)
}
} else {
// res.Ignored means eQSL took the file but left records out. Say the
// count out loud: the whole chunk is still marked sent (eQSL never
// says WHICH it dropped, and in practice they are QSOs it already
// had), so the log line is the only trace of the shortfall.
if res.Ignored {
m.logf("extsvc: eqsl batch upload PARTIAL (%d QSOs sent) %s", len(kept), res.Message)
} else {
m.logf("extsvc: eqsl batch upload OK (%d QSOs) %s", len(kept), res.Message)
}
if m.deps.MarkUploaded != nil {
for _, id := range kept {
m.deps.MarkUploaded(ServiceEQSL, id, res.LogID)
}
}
uploaded += len(kept)
}
records = records[:0]
kept = kept[:0]
}
for _, id := range ids {
if m.deps.ShouldUpload != nil && !m.deps.ShouldUpload(ServiceEQSL, id) {
continue
}
// eQSL keeps the QSO's own station call; the account is identified by the
// credentials and the optional QTH nickname — as in upload().
rec, ok := m.deps.BuildADIF(id, "")
if !ok {
continue
}
records = append(records, rec)
kept = append(kept, id)
if len(records) >= eqslBatchChunk {
send()
}
}
send()
return uploaded
}
// clublogBatchChunk is how many QSOs go into one putlogs.php request. Club Log
// dedupes server-side, so chunking is not about correctness — it keeps a single
// malformed record from failing a whole ten-thousand-QSO document, and matches
// what the QSL Manager's bulk upload already uses.
const clublogBatchChunk = 100
// flushClublogBatch uploads the on-close Club Log QSOs through the BATCH
// endpoint (putlogs.php) rather than one realtime.php call each.
//
// It used to walk the ids and call UploadClublog per QSO. On-close upload sweeps
// the WHOLE logbook, so importing an ADIF — or simply switching Club Log on over
// an existing log — turned one app close into hundreds of realtime.php posts.
// That endpoint is reserved for an operator logging contacts as they work them,
// and Club Log blocks the IP of anything that batches through it: an OpsLog user
// was flagged by G7VJR for 185 QSOs in four minutes, which is this loop, not a
// pile-up. Batch upload is the mechanism Club Log provides for exactly this.
func (m *Manager) flushClublogBatch(ids []int64, cfg ServiceConfig) int {
uploaded := 0
var records []string
var kept []int64
send := func() {
if len(records) == 0 {
return
}
// nil client on purpose: UploadClublogADIF then builds one with a 120 s
// timeout. m.deps.Client is the 20 s budget of a single realtime QSO,
// which a hundred-QSO document on a slow link would blow through.
res, err := UploadClublogADIF(context.Background(), nil, cfg, strings.Join(records, "\n"))
if err != nil || !res.OK {
if err == nil {
err = errFromResult(res)
}
m.logf("extsvc: clublog batch upload (%d QSOs) failed: %v", len(kept), err)
if m.deps.NotifyError != nil {
m.deps.NotifyError(ServiceClublog, 0, err)
}
} else {
m.logf("extsvc: clublog batch upload OK (%d QSOs) %s", len(kept), res.Message)
if m.deps.MarkUploaded != nil {
for _, id := range kept {
m.deps.MarkUploaded(ServiceClublog, id, res.LogID)
}
}
uploaded += len(kept)
}
records = records[:0]
kept = kept[:0]
}
for _, id := range ids {
// Skip QSOs not eligible (already sent). The wrong-logbook guard that
// upload() applies per QSO is not repeated here: closeUploadIDs has
// already filtered the sweep down to this logbook's callsign.
if m.deps.ShouldUpload != nil && !m.deps.ShouldUpload(ServiceClublog, id) {
continue
}
// Club Log takes the logbook callsign as its own form field, so the ADIF
// keeps the QSO's own station call (no override) — as in upload().
rec, ok := m.deps.BuildADIF(id, "")
if !ok {
continue
}
records = append(records, rec)
kept = append(kept, id)
if len(records) >= clublogBatchChunk {
send()
}
}
send()
return uploaded
}
// flushLoTWBatch signs+uploads all queued LoTW QSOs in one TQSL run, then
// stamps each as uploaded on success.
func (m *Manager) flushLoTWBatch(ids []int64, cfg ServiceConfig) int {
+74
View File
@@ -0,0 +1,74 @@
package lookup
import (
"context"
"testing"
"time"
)
// A TTL of zero means no cache: nothing is read from it, and nothing is written
// to it either.
//
// It is a real thing to want. An operator correcting their own QRZ record — or
// chasing a DXpedition whose page changes during the operation — otherwise
// waits out thirty days before OpsLog will ask again. Clearing the cache by
// hand works once; switching it off is the setting for a session where the
// answers are moving.
func TestATTLOfZeroSwitchesTheCacheOff(t *testing.T) {
c := testCache(t)
ctx := context.Background()
if err := c.Put(ctx, Result{Callsign: "M0ABC", Name: "Ann", Source: "qrz"}); err != nil {
t.Fatalf("put: %v", err)
}
if _, ok := c.Get(ctx, "M0ABC"); !ok {
t.Fatal("the cache did not hold a fresh entry while switched on")
}
c.SetTTL(0)
if c.Enabled() {
t.Error("Enabled() is true with a zero TTL")
}
if _, ok := c.Get(ctx, "M0ABC"); ok {
t.Error("a cached entry was still returned with the cache off — the provider would never be asked again")
}
// And nothing new is stored: those rows would only sit there going stale,
// waiting for the day the cache is switched back on.
if err := c.Put(ctx, Result{Callsign: "M0XYZ", Name: "Bob", Source: "qrz"}); err != nil {
t.Fatalf("put with the cache off: %v", err)
}
c.SetTTL(30 * 24 * time.Hour)
if _, ok := c.Get(ctx, "M0XYZ"); ok {
t.Error("a lookup made while the cache was off was written to it anyway")
}
// The entry from before it was switched off is still there — switching off
// is not the same as clearing, and the Clear cache button remains the way to
// throw the contents away.
if _, ok := c.Get(ctx, "M0ABC"); !ok {
t.Error("switching the cache off discarded what it already held")
}
}
// A negative lifetime is meaningless, and rounding it into either "off" or a
// default would be a guess. It is ignored instead.
func TestANegativeTTLIsIgnored(t *testing.T) {
c := testCache(t)
c.SetTTL(7 * 24 * time.Hour)
c.SetTTL(-1)
if !c.Enabled() {
t.Fatal("a negative TTL switched the cache off")
}
if c.ttl != 7*24*time.Hour {
t.Errorf("ttl = %v after a negative value, want the 7 days it already had", c.ttl)
}
}
// The constructor's zero is the DEFAULT, not "off": at startup the settings
// have not been read, and beginning with no cache would hammer the provider for
// the first seconds of every launch.
func TestNewCacheWithZeroStillCaches(t *testing.T) {
c := testCache(t) // built with NewCache(conn, 0)
if !c.Enabled() {
t.Error("a cache built with a zero TTL started switched off")
}
}
+66 -6
View File
@@ -195,10 +195,7 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
// operator was looking the call up for, and it came back empty while
// the same lookup without the suffix answered perfectly.
if !saysNothingAboutLocation(call) {
r.Country, r.Continent = "", ""
r.CQZ, r.ITUZ, r.DXCC = 0, 0, 0
r.Lat, r.Lon = 0, 0
r.Grid, r.State, r.County = "", "", ""
clearHomeLocation(&r)
}
fillFromDXCC(&r, dxcc) // entity/zones/lat-lon from the FULL (slashed) call
normalizeNames(&r)
@@ -378,6 +375,17 @@ func titleCase(s string) string {
// the DXCC number since QRZ's value is wrong and we don't have an entity
// → DXCC# table yet.
// Returns true if any field was filled.
// clearHomeLocation drops the fields that say WHERE a callbook record's operator
// lives, and keeps the ones that say WHO they are — name, address, QSL route.
// A portable operator's cards still go to the home address, so that address is
// not wrong; their county is.
func clearHomeLocation(r *Result) {
r.Country, r.Continent = "", ""
r.CQZ, r.ITUZ, r.DXCC = 0, 0, 0
r.Lat, r.Lon = 0, 0
r.Grid, r.State, r.County = "", "", ""
}
func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
if dxcc == nil {
return false
@@ -387,6 +395,33 @@ func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
return false
}
filled := false
// A subdivision belongs to an ENTITY, so a record describing one entity has
// nothing to say about a station operating from another.
//
// TI8/W2RE came back as a Costa Rica contact in Dutchess County, New York, on
// square FN31 — W2RE's home details, from a QRZ page that carries the home
// mailing address as most portable pages do. CNTY is *defined* as a US
// county, so that is not a cosmetic slip: it is a US county award credit
// recorded against a Costa Rica QSO, and a distance and beam heading computed
// from a square 3,600 km from where the station actually is.
//
// The home-call fallback above already clears these, but it only runs when NO
// provider had the slashed form. An operator with a page for their portable
// call never reached it. Cleared here instead, where the operating entity is
// known — which also heals rows already in the cache, since every cache hit
// comes back through here.
//
// Same-entity portables (F4BPO/P, W2RE/2) are untouched: the entities match,
// and there the home details ARE where the operator is.
if dxccNum != 0 && strings.ContainsRune(r.Callsign, '/') && !saysNothingAboutLocation(r.Callsign) {
if home := homeCall(r.Callsign); home != "" && home != r.Callsign {
if homeNum, _, _, _, _, _, _, homeOK := dxcc.Resolve(home); homeOK && homeNum != 0 && homeNum != dxccNum {
clearHomeLocation(r)
filled = true
}
}
}
if country != "" {
r.Country = country
filled = true
@@ -432,11 +467,21 @@ func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
// ----- Cache -----
// Cache is a SQLite-backed cache of lookup results with a TTL.
//
// A ttl of zero means NO CACHE: every lookup goes to the provider. That is a
// real thing to want — an operator correcting their own QRZ record, or chasing
// a DXpedition whose page changes during the operation, otherwise waits out the
// cache before OpsLog will look again.
type Cache struct {
db *sql.DB
ttl time.Duration
}
// NewCache builds the cache. A ttl of zero here is the CONSTRUCTOR default
// (thirty days), not "off": at startup the settings have not been read yet, and
// starting with no cache would hammer the provider for the first seconds of
// every launch. Switching it off is a decision the operator makes, through
// SetTTL, once their settings are known.
func NewCache(db *sql.DB, ttl time.Duration) *Cache {
if ttl <= 0 {
ttl = 30 * 24 * time.Hour
@@ -444,15 +489,25 @@ func NewCache(db *sql.DB, ttl time.Duration) *Cache {
return &Cache{db: db, ttl: ttl}
}
// SetTTL updates the cache TTL (e.g. when user changes settings).
// SetTTL updates the cache lifetime.
//
// ZERO switches the cache OFF — nothing is read from it and nothing is written
// to it. A NEGATIVE value is meaningless and is ignored, rather than being
// rounded into one of the two meanings above.
func (c *Cache) SetTTL(ttl time.Duration) {
if ttl > 0 {
if ttl >= 0 {
c.ttl = ttl
}
}
// Enabled reports whether anything is being cached at all.
func (c *Cache) Enabled() bool { return c != nil && c.ttl > 0 }
// Get returns the cached result if present and not expired.
func (c *Cache) Get(ctx context.Context, callsign string) (Result, bool) {
if !c.Enabled() {
return Result{}, false
}
row := c.db.QueryRowContext(ctx, `
SELECT callsign, name, qth, address, state, cnty, country, grid,
lat, lon, dxcc, cqz, ituz, cont, email, qsl_via, image_url,
@@ -519,6 +574,11 @@ func (c *Cache) Get(ctx context.Context, callsign string) (Result, bool) {
// Put upserts a lookup result. fetched_at is generated in Go (NowISO) so the
// INSERT is backend-agnostic; the conflict tail is dialect-specific.
func (c *Cache) Put(ctx context.Context, r Result) error {
if !c.Enabled() {
// Nothing reads it, so writing would only grow the table — and leave
// stale rows waiting for the day the cache is switched back on.
return nil
}
updateCols := []string{
"name", "qth", "address", "state", "cnty",
"country", "grid", "lat", "lon",
+93
View File
@@ -0,0 +1,93 @@
package lookup
import "testing"
// fakeDXCC resolves a handful of calls, enough to stand in for cty.dat.
type fakeDXCC map[string]struct {
num int
country string
cont string
cqz, ituz int
lat, lon float64
}
func (f fakeDXCC) Resolve(call string) (int, string, string, int, int, float64, float64, bool) {
e, ok := f[call]
if !ok {
return 0, "", "", 0, 0, 0, 0, false
}
return e.num, e.country, e.cont, e.cqz, e.ituz, e.lat, e.lon, true
}
func testDXCC() fakeDXCC {
return fakeDXCC{
// Costa Rica, as cty.dat reads the OPERATING call.
"TI8/W2RE": {num: 308, country: "Costa Rica", cont: "NA", cqz: 7, ituz: 11, lat: 9.9, lon: -84.1},
// The home call: United States.
"W2RE": {num: 291, country: "United States", cont: "NA", cqz: 5, ituz: 8, lat: 39.8, lon: -98.5},
// A same-entity portable: still France either way.
"F4BPO/P": {num: 227, country: "France", cont: "EU", cqz: 14, ituz: 27, lat: 46.2, lon: 2.2},
"F4BPO": {num: 227, country: "France", cont: "EU", cqz: 14, ituz: 27, lat: 46.2, lon: 2.2},
}
}
// A callbook record for a portable call routinely carries the operator's HOME
// address — most QRZ pages for a portable call do — and a subdivision belongs to
// an entity. Carrying the home county across an entity change is not cosmetic:
// CNTY is defined as a US county, so TI8/W2RE was coming out as a Costa Rica
// contact credited to Dutchess County, New York, with the distance and beam
// heading taken from a square 3,600 km from where the station actually was.
func TestPortableInAnotherEntityDropsTheHomeSubdivision(t *testing.T) {
r := Result{
Callsign: "TI8/W2RE",
Name: "Raymond", // who they are — kept
QTH: "Poughquag",
Address: "499 Pleasant Ridge Road", // cards still go there — kept
State: "NY",
County: "Dutchess",
Grid: "FN31",
Lat: 41.6, Lon: -73.7,
DXCC: 291,
}
fillFromDXCC(&r, testDXCC())
if r.County != "" {
t.Errorf("county = %q, want empty — a US county on a Costa Rica QSO is a false award credit", r.County)
}
if r.State != "" {
t.Errorf("state = %q, want empty — a subdivision of the entity that is not being worked", r.State)
}
if r.Grid != "" {
t.Errorf("grid = %q, want empty — it is the home square, 3,600 km from the operation", r.Grid)
}
// The entity and its centroid take over, so distance and bearing mean
// something again.
if r.DXCC != 308 || r.Country != "Costa Rica" {
t.Errorf("entity = %d %q, want 308 Costa Rica", r.DXCC, r.Country)
}
if r.Lat != 9.9 || r.Lon != -84.1 {
t.Errorf("lat/lon = %v/%v, want the Costa Rica centroid — the home coordinates must not survive", r.Lat, r.Lon)
}
// Who they are, and where their cards go, is unchanged.
if r.Name != "Raymond" || r.Address != "499 Pleasant Ridge Road" {
t.Errorf("name/address were cleared (%q / %q) — a portable operator's post still reaches home", r.Name, r.Address)
}
}
// The other half of the rule: a portable WITHIN the same entity is at home as
// far as the entity is concerned, and its details must survive untouched.
func TestSameEntityPortableKeepsItsLocation(t *testing.T) {
r := Result{
Callsign: "F4BPO/P",
State: "77", County: "Seine-et-Marne", Grid: "JN18cs",
Lat: 48.8, Lon: 2.4, DXCC: 227,
}
fillFromDXCC(&r, testDXCC())
if r.Grid != "JN18cs" || r.County != "Seine-et-Marne" || r.State != "77" {
t.Errorf("a same-entity portable lost its location: grid=%q county=%q state=%q", r.Grid, r.County, r.State)
}
if r.Lat != 48.8 || r.Lon != 2.4 {
t.Errorf("lat/lon = %v/%v — the precise home position was replaced by the entity centroid", r.Lat, r.Lon)
}
}
+84
View File
@@ -0,0 +1,84 @@
package qso
import (
"context"
"testing"
"time"
)
// LoTW never hands back the submode it was given: every digital contact comes
// back as the mode GROUP, "DATA". Matched on the exact mode string that is
// simply never equal, so an operator downloading their confirmations was told
// their own QSOs were not in their log — reported for FT2 contacts confirmed as
// DATA, and true of FT4 and FT8 alike.
func TestClassKeyMatchesAConfirmationCarryingTheModeGroup(t *testing.T) {
r := openRepo(t)
ctx := context.Background()
when := time.Date(2026, 8, 16, 16, 29, 0, 0, time.UTC)
id, err := r.Add(ctx, QSO{Callsign: "F1NQP", QSODate: when, Band: "20m", Mode: "FT2"})
if err != nil {
t.Fatal(err)
}
minute := when.Format("2006-01-02T15:04")
exact, err := r.DedupeKeyIDs(ctx)
if err != nil {
t.Fatal(err)
}
if _, found := exact[DedupeKey("F1NQP", minute, "20m", "DATA")]; found {
t.Fatal("the exact index matched DATA against FT2 — the test proves nothing")
}
byClass, err := r.DedupeClassKeyIDs(ctx)
if err != nil {
t.Fatal(err)
}
got, found := byClass[DedupeClassKey("F1NQP", minute, "20m", "DATA")]
if !found || got != id {
t.Errorf("class match = (%d, %v), want (%d, true) — the confirmation would be reported as having no local QSO", got, found, id)
}
}
// Two digital contacts with the same station, same band, same minute is barely
// physical — but if it happens, stamping the confirmation on whichever row the
// map happened to keep is a silent error in an award credit. Ambiguity maps to
// 0 so the caller reports it unmatched instead of guessing.
func TestAnAmbiguousClassKeyIsRefusedRatherThanGuessed(t *testing.T) {
r := openRepo(t)
ctx := context.Background()
when := time.Date(2026, 8, 16, 16, 29, 0, 0, time.UTC)
for _, m := range []string{"FT8", "RTTY"} {
if _, err := r.Add(ctx, QSO{Callsign: "F1NQP", QSODate: when, Band: "20m", Mode: m}); err != nil {
t.Fatal(err)
}
}
byClass, err := r.DedupeClassKeyIDs(ctx)
if err != nil {
t.Fatal(err)
}
if got := byClass[DedupeClassKey("F1NQP", when.Format("2006-01-02T15:04"), "20m", "DATA")]; got != 0 {
t.Errorf("ambiguous class key resolved to %d — one of two QSOs was picked at random", got)
}
}
// Phone and CW keep their own classes: a CW confirmation must never land on an
// SSB contact just because the rest of the key agrees.
func TestClassMatchDoesNotCrossPhoneAndCW(t *testing.T) {
r := openRepo(t)
ctx := context.Background()
when := time.Date(2026, 8, 16, 16, 29, 0, 0, time.UTC)
if _, err := r.Add(ctx, QSO{Callsign: "F1NQP", QSODate: when, Band: "20m", Mode: "SSB"}); err != nil {
t.Fatal(err)
}
byClass, err := r.DedupeClassKeyIDs(ctx)
if err != nil {
t.Fatal(err)
}
minute := when.Format("2006-01-02T15:04")
if _, found := byClass[DedupeClassKey("F1NQP", minute, "20m", "CW")]; found {
t.Error("a CW confirmation matched an SSB contact")
}
if _, found := byClass[DedupeClassKey("F1NQP", minute, "20m", "USB")]; !found {
t.Error("USB did not match the SSB contact — the phone sidebands are one class")
}
}
+45
View File
@@ -2691,6 +2691,51 @@ func (r *Repo) DedupeKeyIDs(ctx context.Context) (map[string]int64, error) {
return out, rows.Err()
}
// DedupeClassKey is DedupeKey with the mode collapsed to its CLASS (Phone / CW /
// Digital).
//
// For matching a downloaded confirmation whose mode is not the one you logged.
// LoTW does not hand back the submode it was given: a contact uploaded as FT4,
// FT8 or anything else digital comes back as the mode GROUP, "DATA". Matched on
// the exact string that is simply never equal, and the operator is told their
// own QSO is not in their log — which is how a confirmed contact goes unrecorded.
func DedupeClassKey(callsign, qsoDateMinute, band, mode string) string {
return strings.ToUpper(callsign) + "|" + qsoDateMinute + "|" + strings.ToLower(band) + "|" + modeClass(mode)
}
// DedupeClassKeyIDs is DedupeKeyIDs at mode-CLASS granularity, for the second
// pass when the exact key misses.
//
// A key shared by more than one QSO maps to 0 — AMBIGUOUS, not "pick one". Two
// contacts with the same station, on the same band, in the same minute, in two
// digital modes is barely physical; but if it ever happens, stamping the
// confirmation on whichever row the map happened to keep would be a silent
// error in someone's award credit. Better to report it unmatched.
func (r *Repo) DedupeClassKeyIDs(ctx context.Context) (map[string]int64, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, callsign, substr(qso_date, 1, 16), band, mode
FROM qso`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]int64, 1024)
for rows.Next() {
var id int64
var call, when, band, mode string
if err := rows.Scan(&id, &call, &when, &band, &mode); err != nil {
return nil, err
}
k := DedupeClassKey(call, when, band, mode)
if prev, seen := out[k]; seen && prev != id {
out[k] = 0
continue
}
out[k] = id
}
return out, rows.Err()
}
// matchRef is one local QSO's time + id, for time-window confirmation matching.
type matchRef struct {
when time.Time
+2 -2
View File
@@ -20,8 +20,8 @@ func NewDenkovi(serial string, count int) Device {
return denkoviStub{count: count}
}
func (s denkoviStub) Count() int { return s.count }
func (denkoviStub) Close() error { return nil }
func (s denkoviStub) Count() int { return s.count }
func (denkoviStub) Close() error { return nil }
func (denkoviStub) Status(context.Context) ([]bool, error) {
return nil, fmt.Errorf("Denkovi USB relay board is only supported on Windows")
}
+6 -3
View File
@@ -56,6 +56,9 @@ type httpGen struct {
user string
pass string
count int
// insecure accepts a certificate nothing can verify — the self-signed one a
// relay board on the LAN presents. Per board, and the operator's choice.
insecure bool
mu sync.Mutex
state []bool
@@ -64,7 +67,7 @@ type httpGen struct {
// NewHTTPGeneric builds the driver. onURLs/offURLs are per relay (index 0 =
// relay 1) and may be short or hold empty entries; onPat/offPat are the
// fallback patterns; labels are the relay names {value} substitutes.
func NewHTTPGeneric(onURLs, offURLs []string, onPat, offPat, user, pass string, count int, labels []string) Device {
func NewHTTPGeneric(onURLs, offURLs []string, onPat, offPat, user, pass string, count int, labels []string, insecure bool) Device {
if count <= 0 {
count = len(onURLs)
}
@@ -74,7 +77,7 @@ func NewHTTPGeneric(onURLs, offURLs []string, onPat, offPat, user, pass string,
return &httpGen{
onURLs: onURLs, offURLs: offURLs,
onPat: onPat, offPat: offPat, labels: labels,
user: user, pass: pass, count: count,
user: user, pass: pass, count: count, insecure: insecure,
state: make([]bool, count),
}
}
@@ -196,7 +199,7 @@ func (h *httpGen) Set(ctx context.Context, relay int, on bool) error {
}
u := h.urlFor(relay, on)
u = withScheme(u)
if _, err := get(ctx, u, h.user, h.pass); err != nil {
if _, err := get(ctx, u, h.user, h.pass, h.insecure); err != nil {
return err
}
h.mu.Lock()
+7 -7
View File
@@ -22,7 +22,7 @@ func TestHTTPGenericPattern(t *testing.T) {
d := NewHTTPGeneric(nil, nil,
srv.URL+"/relay?n={relay}&state=on",
srv.URL+"/relay?n={relay}&state=off", "", "", 4, nil)
srv.URL+"/relay?n={relay}&state=off", "", "", 4, nil, false)
if err := d.Set(context.Background(), 2, true); err != nil {
t.Fatalf("Set on: %v", err)
}
@@ -53,7 +53,7 @@ func TestHTTPGenericPerRelayURLsWinOverThePattern(t *testing.T) {
d := NewHTTPGeneric(
[]string{srv.URL + "/FF0101", "", srv.URL + "/weird/on"},
[]string{srv.URL + "/FF0100", "", ""},
srv.URL+"/pattern/on/{relay}", srv.URL+"/pattern/off/{relay}", "", "", 3, nil)
srv.URL+"/pattern/on/{relay}", srv.URL+"/pattern/off/{relay}", "", "", 3, nil, false)
_ = d.Set(context.Background(), 1, true) // its own URL
_ = d.Set(context.Background(), 2, true) // empty → falls back to the pattern
@@ -82,7 +82,7 @@ func TestHTTPGenericValueIsTheRelayLabel(t *testing.T) {
[]string{srv.URL + "/relay?on={value}"}, // per-relay URL
nil,
"", srv.URL+"/relay?off={value}", // and the pattern, for the other direction
"", "", 3, []string{"Ant1", "Beam 20m", ""})
"", "", 3, []string{"Ant1", "Beam 20m", ""}, false)
_ = d.Set(context.Background(), 1, true)
_ = d.Set(context.Background(), 2, false)
mu.Lock()
@@ -107,7 +107,7 @@ func TestHTTPGenericRelayOffset(t *testing.T) {
defer srv.Close()
d := NewHTTPGeneric(nil, nil,
srv.URL+"/set0/{relay-1}/1", srv.URL+"/set0/{relay-1}/0", "", "", 4, nil)
srv.URL+"/set0/{relay-1}/1", srv.URL+"/set0/{relay-1}/0", "", "", 4, nil, false)
_ = d.Set(context.Background(), 1, true)
_ = d.Set(context.Background(), 4, false)
mu.Lock()
@@ -123,7 +123,7 @@ func TestHTTPGenericRelayOffset(t *testing.T) {
// movement. It must be refused, and the message must say the label is what is
// missing.
func TestHTTPGenericRefusesValueWithoutALabel(t *testing.T) {
d := NewHTTPGeneric(nil, nil, "http://x/relay?on={value}", "", "", "", 2, []string{"", ""})
d := NewHTTPGeneric(nil, nil, "http://x/relay?on={value}", "", "", "", 2, []string{"", ""}, false)
err := d.Set(context.Background(), 1, true)
if err == nil || !strings.Contains(err.Error(), "label") {
t.Errorf("err = %v, want it to name the missing label", err)
@@ -149,7 +149,7 @@ func TestHTTPGenericSuppliesTheScheme(t *testing.T) {
// A switch with the ON URLs filled and OFF left empty latches. The error has to
// name the direction, or the operator cannot tell which half is missing.
func TestHTTPGenericNamesTheMissingDirection(t *testing.T) {
d := NewHTTPGeneric([]string{"http://x/on"}, nil, "", "", "", "", 1, nil)
d := NewHTTPGeneric([]string{"http://x/on"}, nil, "", "", "", "", 1, nil, false)
err := d.Set(context.Background(), 1, false)
if err == nil || !strings.Contains(err.Error(), "OFF") {
t.Errorf("err = %v, want it to name the OFF direction", err)
@@ -160,7 +160,7 @@ func TestHTTPGenericNamesTheMissingDirection(t *testing.T) {
func TestHTTPGenericRemembersWhatItCommanded(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
defer srv.Close()
d := NewHTTPGeneric(nil, nil, srv.URL+"/on/{relay}", srv.URL+"/off/{relay}", "", "", 3, nil)
d := NewHTTPGeneric(nil, nil, srv.URL+"/on/{relay}", srv.URL+"/off/{relay}", "", "", 3, nil, false)
_ = d.Set(context.Background(), 2, true)
st, err := d.Status(context.Background())
if err != nil {
+80
View File
@@ -0,0 +1,80 @@
package relaydev
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
)
// A relay board on the LAN signs its own certificate — there is no authority
// anywhere that could have signed it. httptest.NewTLSServer presents exactly
// that: a certificate from an unknown issuer, which is what the hardware does.
func selfSignedRelay(t *testing.T) (*httptest.Server, func() []string) {
t.Helper()
var mu sync.Mutex
var got []string
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
got = append(got, r.URL.Path)
mu.Unlock()
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
return srv, func() []string {
mu.Lock()
defer mu.Unlock()
return append([]string(nil), got...)
}
}
// With the box ticked, the board answers.
func TestHTTPSRelayWithASelfSignedCertificate(t *testing.T) {
srv, seen := selfSignedRelay(t)
d := NewHTTPGeneric(nil, nil, srv.URL+"/on/{relay}", srv.URL+"/off/{relay}", "", "", 2, nil, true)
if err := d.Set(context.Background(), 1, true); err != nil {
t.Fatalf("Set over HTTPS: %v", err)
}
if paths := seen(); len(paths) != 1 || paths[0] != "/on/1" {
t.Errorf("the board was asked for %v, want /on/1", paths)
}
}
// Without it, the request is refused — and the refusal has to name the box.
//
// Go's own message, "x509: certificate signed by unknown authority", is
// accurate and tells an operator nothing about what to do next. This is the
// difference between a dead end and an instruction, and it is the whole reason
// the default can safely stay OFF.
func TestARefusedCertificateNamesTheSetting(t *testing.T) {
srv, seen := selfSignedRelay(t)
d := NewHTTPGeneric(nil, nil, srv.URL+"/on/{relay}", srv.URL+"/off/{relay}", "", "", 2, nil, false)
err := d.Set(context.Background(), 1, true)
if err == nil {
t.Fatal("an unverifiable certificate was accepted with the box unticked")
}
if !strings.Contains(err.Error(), "self-signed") {
t.Errorf("the refusal reads %q — it does not say which setting to change", err)
}
if len(seen()) != 0 {
t.Error("the request reached the board despite the certificate being refused")
}
}
// The box belongs to ONE board. An operator with a self-signed switch on the
// LAN and a second board reached through a proper HTTPS proxy must keep real
// verification on the second — that link crosses the internet, and it commands
// an antenna.
func TestAcceptingOneBoardsCertificateDoesNotAffectAnother(t *testing.T) {
srv, _ := selfSignedRelay(t)
lan := NewHTTPGeneric(nil, nil, srv.URL+"/on/{relay}", "", "", "", 1, nil, true)
if err := lan.Set(context.Background(), 1, true); err != nil {
t.Fatalf("the LAN board: %v", err)
}
strict := NewHTTPGeneric(nil, nil, srv.URL+"/on/{relay}", "", "", "", 1, nil, false)
if err := strict.Set(context.Background(), 1, true); err == nil {
t.Error("the second board accepted the certificate too — the setting is not per board")
}
}
+57 -11
View File
@@ -17,7 +17,10 @@ package relaydev
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/xml"
"errors"
"fmt"
"io"
"net/http"
@@ -28,8 +31,8 @@ import (
// Device is one relay board.
type Device interface {
Count() int // number of user-controllable relays
Status(ctx context.Context) ([]bool, error) // state of each relay (index 0 = relay 1)
Count() int // number of user-controllable relays
Status(ctx context.Context) ([]bool, error) // state of each relay (index 0 = relay 1)
Set(ctx context.Context, relay int, on bool) error // relay is 1-based
// Close releases any OS handle the driver holds (serial port, FTDI handle).
// Network boards hold nothing and no-op. MUST be called when a cached driver is
@@ -40,8 +43,47 @@ type Device interface {
func httpClient() *http.Client { return &http.Client{Timeout: 5 * time.Second} }
// insecureClient talks to a board presenting a certificate nothing can verify.
//
// Which is nearly every board that offers HTTPS at all: a relay box on the LAN
// signs its own certificate, and there is no authority anywhere that could have
// signed it. Refusing that means refusing HTTPS on the hardware, which is not a
// security decision, only an outcome.
//
// So it is offered, per board, and OFF by default — because the other HTTPS
// case is real and opposite: a board reached from outside through a proxy with
// a genuine certificate, where verification is the only thing standing between
// an antenna switch and the internet. One box, on the board that needs it.
//
// Built once. A Transport per request would open a fresh TLS connection every
// time and never reuse one.
var insecureClient = &http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // the operator ticked the box for this board
},
}
// certError says which box to tick when TLS is what failed.
//
// Go's own message — "x509: certificate signed by unknown authority" — is
// accurate and tells an operator nothing about what to do next. Naming the
// setting turns a dead end into an instruction.
func certError(err error) error {
var unknown x509.UnknownAuthorityError
var host x509.HostnameError
var verify *tls.CertificateVerificationError
if errors.As(err, &unknown) || errors.As(err, &host) || errors.As(err, &verify) {
return fmt.Errorf("%w — the board's HTTPS certificate cannot be verified; "+
"tick \"Accept a self-signed certificate\" for this board if it is on your own network", err)
}
return err
}
// get issues a GET with optional basic auth and returns the body on 2xx.
func get(ctx context.Context, url, user, pass string) ([]byte, error) {
//
// insecure skips certificate verification, for a board that signs its own.
func get(ctx context.Context, url, user, pass string, insecure bool) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
@@ -49,9 +91,13 @@ func get(ctx context.Context, url, user, pass string) ([]byte, error) {
if user != "" || pass != "" {
req.SetBasicAuth(user, pass)
}
resp, err := httpClient().Do(req)
client := httpClient()
if insecure {
client = insecureClient
}
resp, err := client.Do(req)
if err != nil {
return nil, err
return nil, certError(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
@@ -87,8 +133,8 @@ type webswitch struct {
// NewWebswitch builds a WebSwitch 1216H client (5 relays).
func NewWebswitch(host string) Device { return &webswitch{host: host, count: 5} }
func (w *webswitch) Count() int { return w.count }
func (w *webswitch) Close() error { return nil } // stateless HTTP, nothing to release
func (w *webswitch) Count() int { return w.count }
func (w *webswitch) Close() error { return nil } // stateless HTTP, nothing to release
func (w *webswitch) Set(ctx context.Context, relay int, on bool) error {
if relay < 1 || relay > w.count {
@@ -98,7 +144,7 @@ func (w *webswitch) Set(ctx context.Context, relay int, on bool) error {
if on {
action = "on"
}
_, err := get(ctx, fmt.Sprintf("%s/relaycontrol/%s/%d", relayBase(w.host), action, relay), "", "")
_, err := get(ctx, fmt.Sprintf("%s/relaycontrol/%s/%d", relayBase(w.host), action, relay), "", "", false)
return err
}
@@ -109,7 +155,7 @@ func (w *webswitch) Status(ctx context.Context) ([]bool, error) {
sel.WriteString(strconv.Itoa(i))
sel.WriteByte('$')
}
body, err := get(ctx, fmt.Sprintf("%s/relaystate/get2/%s", relayBase(w.host), sel.String()), "", "")
body, err := get(ctx, fmt.Sprintf("%s/relaystate/get2/%s", relayBase(w.host), sel.String()), "", "", false)
if err != nil {
return nil, err
}
@@ -156,7 +202,7 @@ func (k *kmtronic) Set(ctx context.Context, relay int, on bool) error {
state = "01"
}
// FF<rr><ss>: e.g. FF0101 = relay 1 on, FF0800 = relay 8 off.
_, err := get(ctx, fmt.Sprintf("%s/FF%02d%s", relayBase(k.host), relay, state), k.user, k.pass)
_, err := get(ctx, fmt.Sprintf("%s/FF%02d%s", relayBase(k.host), relay, state), k.user, k.pass, false)
return err
}
@@ -170,7 +216,7 @@ type kmStatus struct {
}
func (k *kmtronic) Status(ctx context.Context) ([]bool, error) {
body, err := get(ctx, fmt.Sprintf("%s/status.xml", relayBase(k.host)), k.user, k.pass)
body, err := get(ctx, fmt.Sprintf("%s/status.xml", relayBase(k.host)), k.user, k.pass, false)
if err != nil {
return nil, err
}
+673
View File
@@ -0,0 +1,673 @@
// Package tciserver shares OpsLog's CAT link with programs that speak TCI.
//
// It is the second half of internal/rigctld, and exists for the same reason:
// Windows gives a COM port to ONE process, so the moment OpsLog talks to the
// radio directly nothing else can. rigctld answers the programs that speak
// Hamlib NET rigctl (WSJT-X, JTDX, MSHV, Log4OM); this answers the ones built
// around Expert Electronics' TCI instead — and it answers them whatever radio
// is actually connected, because it sits on the same backend-agnostic
// interface. An operator with an Icom or a Yaesu can hand a TCI-only program a
// working rig.
//
// ── The protocol ──────────────────────────────────────────────────────────
// Text commands over a WebSocket, "name:arg,arg;", the same syntax in both
// directions. On connection the server sends a block of initialisation
// commands describing the device, ending with ready; and start;. Thereafter
// either side may send a control command, and the server echoes every change
// to all connected clients so they stay in step with each other.
//
// vfo:0,0,14074000; receiver 0, channel A (RX), Hz
// vfo:0,1,14080000; channel B — the TX frequency when split is on
// modulation:0,usb; mode
// trx:0,true; PTT
// split_enable:0,true; split
// vfo:0,0; a READ: the reply is the three-argument form
//
// Written against the official TCI Protocol document (ExpertSDR3/TCI, 12
// January 2024, MIT) — the initialisation set and the argument order of every
// command below are from §4.1 and §4.2, not from guesswork about what a client
// might accept.
package tciserver
import (
"fmt"
"net"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
)
// Rig is what the server needs from OpsLog's CAT manager. An interface, so this
// package stays testable without a radio and without importing internal/cat —
// which also keeps it building on every platform.
type Rig interface {
Freq() int64 // TX frequency in Hz (ADIF sense), 0 if unknown
RxFreq() int64 // RX frequency in Hz; equals Freq when not split
Mode() string // ADIF mode (SSB, CW, FT8…)
Split() (bool, int64) // split on?, and the TX frequency
SetFreq(hz int64) error
SetMode(mode string) error
SetPTT(on bool) error
SetSplit(on bool, txHz int64) error
}
// DefaultPort is TCI's own default, which is what a client offers first.
const DefaultPort = 40001
// pollInterval is how often the rig is compared with what the clients were last
// told. TCI is an event protocol — a client is entitled to sit silent and be
// told when something moves — so this is the rate at which a knob turned on the
// radio reaches it.
const pollInterval = 250 * time.Millisecond
type Server struct {
port int
rig Rig
log func(string, ...any)
mu sync.Mutex
ln net.Listener
http *http.Server
conns map[*client]struct{}
closed bool
// pendingTxHz is a transmit frequency a client set on channel B while the rig
// was still simplex.
//
// It must be REMEMBERED, not discarded. A client working split sends two
// commands and is free to send them in either order; when the frequency comes
// first, throwing it away means the split is then armed on whatever the
// transmit VFO happened to hold — the receive frequency — and the operator
// transmits straight onto the DX while their software shows exactly what they
// asked for. rigctld learned this the same way, and pairs set_split_vfo with
// set_split_freq for the same reason.
pendingTxHz int64
// ptt mirrors the last PTT state a client commanded, so a repeat can be
// recognised. A client is free to restate PTT as often as it likes, and one
// does: through the rigctl server Nexus sent set_ptt 0 about sixteen times a
// second, and the Flex's own "xmit 1" landed between two of them and was
// overwritten inside a millisecond — a transmit request that simply did
// nothing. The same radio sits behind this server.
ptt bool
pttKnown bool
// last is what the clients have been told, so only changes are sent. TCI
// clients redraw on every command they receive; re-sending an unchanged
// frequency four times a second makes a VFO readout flicker and, in some
// clients, fights the operator's own tuning.
last state
}
// state is the part of the rig the clients are kept in step with.
type state struct {
rxHz int64
txHz int64
mode string
split bool
valid bool
}
// clientLogCap bounds how many of one client's commands reach the log.
const clientLogCap = 200
// client is one connected program.
type client struct {
conn *websocket.Conn
mu sync.Mutex // one writer at a time: gorilla panics on concurrent writes
// logged counts what has been written to the log for this connection. Only
// the reader goroutine touches it, so it needs no lock of its own.
logged int
}
func (c *client) send(s string) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn == nil {
return nil // a client with no socket: the tests exercise the protocol, not the transport
}
_ = c.conn.SetWriteDeadline(time.Now().Add(3 * time.Second))
return c.conn.WriteMessage(websocket.TextMessage, []byte(s))
}
func New(port int, rig Rig, logf func(string, ...any)) *Server {
if port <= 0 || port > 65535 {
port = DefaultPort
}
if logf == nil {
logf = func(string, ...any) {}
}
return &Server{port: port, rig: rig, log: logf, conns: map[*client]struct{}{}}
}
// Start binds the port and serves until Stop.
func (s *Server) Start() error {
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", s.port))
if err != nil {
return fmt.Errorf("tci server: port %d: %w", s.port, err)
}
up := websocket.Upgrader{
// Any origin: the clients are desktop programs on the same machine or
// LAN, and they send whatever Origin their toolkit happens to set. This
// is the same trust boundary as the rigctl server on 4532 — a plain TCP
// port with no authentication, which is what every logger expects.
CheckOrigin: func(*http.Request) bool { return true },
}
mux := http.NewServeMux()
// Any path: clients connect to ws://host:port/ but some append a name.
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
conn, err := up.Upgrade(w, r, nil)
if err != nil {
s.log("tci server: upgrade from %s failed: %v", r.RemoteAddr, err)
return
}
s.serve(&client{conn: conn}, r.RemoteAddr)
})
srv := &http.Server{Handler: mux}
s.mu.Lock()
s.ln, s.http, s.closed = ln, srv, false
s.mu.Unlock()
go func() { _ = srv.Serve(ln) }()
go s.pushLoop()
s.log("tci server: listening on :%d", s.port)
return nil
}
// Stop closes the listener and every client.
// releasePTT drops a PTT this server asserted, and does it once.
//
// A client that dies mid-over — or a settings save that closes the server —
// leaves the rig keyed with nobody left to un-key it, into an amplifier that has
// no idea the transmission ended. rigctld has had this guard for a while (a K3
// once sat in transmit for 29 s until the CAT link happened to be rebuilt); the
// TCI server was written without it, so an operator who moved from Hamlib to TCI
// silently lost the protection.
//
// pttKnown is cleared whatever happens: after an emergency unkey the radio's
// state is a guess, and the next command must reach it rather than be dismissed
// as a repeat.
func (s *Server) releasePTT(why string) {
s.mu.Lock()
keyed := s.ptt
s.ptt, s.pttKnown = false, false
s.mu.Unlock()
if !keyed {
return
}
s.log("tci server: %s while the rig was keyed — dropping PTT", why)
if err := s.rig.SetPTT(false); err != nil {
s.log("tci server: emergency unkey FAILED: %v", err)
return
}
// Any client still attached is told, so a second logger's transmit indicator
// does not stay lit over a rig that is back in receive.
s.broadcast("trx:0,false;")
}
func (s *Server) Stop() {
// Before anything is torn down: the CAT backend is still up here, so an unkey
// still lands. Same ordering as rigctld.Stop for the same reason.
s.releasePTT("TCI server stopped")
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return
}
s.closed = true
ln, srv := s.ln, s.http
conns := make([]*client, 0, len(s.conns))
for c := range s.conns {
conns = append(conns, c)
}
s.conns = map[*client]struct{}{}
s.last = state{}
s.mu.Unlock()
for _, c := range conns {
_ = c.conn.Close()
}
if srv != nil {
_ = srv.Close()
}
if ln != nil {
_ = ln.Close()
}
s.log("tci server: stopped")
}
// Clients reports how many programs are connected — the one thing an operator
// wants to know when a client says it cannot find the rig.
func (s *Server) Clients() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.conns)
}
// serve runs one connection: the initialisation block, then commands until it
// closes.
func (s *Server) serve(c *client, remote string) {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
_ = c.conn.Close()
return
}
s.conns[c] = struct{}{}
s.mu.Unlock()
s.log("tci server: %s connected", remote)
for _, line := range s.initBlock() {
if err := c.send(line); err != nil {
break
}
}
for {
_, data, err := c.conn.ReadMessage()
if err != nil {
break
}
// One frame may carry several ";"-terminated commands.
for _, cmd := range strings.Split(string(data), ";") {
if cmd = strings.TrimSpace(cmd); cmd == "" {
continue
}
// Every command the client sends, in the log.
//
// This is the only evidence there will ever be about a program on
// someone else's machine: "MSHV's PTT test does nothing" is
// unanswerable without knowing whether MSHV sent trx at all, and if
// so in what form. Cheap, because TCI is event-driven — a client
// speaks when the operator does something, not on a timer.
//
// Capped so a client that DOES poll cannot quietly fill the
// operator's log; the cap says so once and then stays quiet.
if c.logged < clientLogCap {
c.logged++
s.log("tci server: ← %s;", cmd)
} else if c.logged == clientLogCap {
c.logged++
s.log("tci server: (further commands from this client are not logged)")
}
s.handle(c, cmd)
}
}
s.mu.Lock()
delete(s.conns, c)
s.mu.Unlock()
_ = c.conn.Close()
s.log("tci server: %s disconnected", remote)
// A client that walks away mid-over must not leave the rig transmitting.
s.releasePTT("client " + remote + " left")
}
// initBlock is the initialisation set from §4.1 of the protocol document, in
// the documented order, followed by the current state so a client that has just
// connected shows the right frequency instead of waiting for the first change.
//
// A client will not proceed without these: they are how it learns the device
// exists, what it can do, and that the server has finished setting up.
func (s *Server) initBlock() []string {
rx, tx, mode, split := s.read()
return []string{
"protocol:ExpertSDR3,1.9;",
"device:OpsLog;",
"receive_only:false;",
"trx_count:1;",
"channel_count:2;",
// The whole HF/VHF/UHF span OpsLog itself works over. A client uses this
// to bound its own tuning; too narrow a range and it refuses to follow the
// rig onto 2 m.
"vfo_limits:10000,470000000;",
"if_limits:-48000,48000;",
"modulations_list:am,sam,dsb,lsb,usb,cw,nfm,digl,digu;",
"ready;",
"start;",
fmt.Sprintf("vfo:0,0,%d;", rx),
fmt.Sprintf("vfo:0,1,%d;", tx),
fmt.Sprintf("modulation:0,%s;", mode),
fmt.Sprintf("split_enable:0,%t;", split),
"trx:0,false;",
// TRANSMIT PERMISSION, and it is not optional in practice.
//
// The document files TX_ENABLE under unidirectional control rather than
// initialisation, but its own note says it is "sent to the client when
// connected". A client that models permission — and one written for
// ExpertSDR users has every reason to — starts out assuming it may NOT
// transmit, and without this it never even tries: PTT does nothing and
// the server never sees a trx command to refuse.
//
// Always true. OpsLog is not the thing that decides: the radio behind
// whichever backend is connected does, and its refusal comes back through
// SetPTT and into the log.
"tx_enable:0,true;",
fmt.Sprintf("tx_frequency:%d;", tx),
}
}
// read takes one consistent snapshot of the rig in TCI's terms: channel A is
// where we LISTEN and channel B where we transmit, which is the opposite way
// round from ADIF's RigState and the one mistake here that would make a client
// transmit on the DX's frequency.
func (s *Server) read() (rxHz, txHz int64, mode string, split bool) {
split, txHz = s.rig.Split()
rxHz = s.rig.RxFreq()
if !split {
txHz = s.rig.Freq()
if rxHz == 0 {
rxHz = txHz
}
}
if rxHz == 0 {
rxHz = s.rig.Freq()
}
if txHz == 0 {
txHz = rxHz
}
mode = adifToTCIMode(s.rig.Mode(), rxHz)
return rxHz, txHz, mode, split
}
// pushLoop tells the clients what has changed on the radio.
func (s *Server) pushLoop() {
t := time.NewTicker(pollInterval)
defer t.Stop()
for range t.C {
s.mu.Lock()
done := s.closed
s.mu.Unlock()
if done {
return
}
s.publish()
}
}
// publish sends only what moved. Returns the lines sent, for the tests.
func (s *Server) publish() []string {
rx, tx, mode, split := s.read()
cur := state{rxHz: rx, txHz: tx, mode: mode, split: split, valid: true}
s.mu.Lock()
prev := s.last
s.last = cur
s.mu.Unlock()
var lines []string
if !prev.valid || prev.rxHz != cur.rxHz {
lines = append(lines, fmt.Sprintf("vfo:0,0,%d;", cur.rxHz))
}
if !prev.valid || prev.txHz != cur.txHz {
lines = append(lines, fmt.Sprintf("vfo:0,1,%d;", cur.txHz))
// The transmit frequency has its own command, which is what a client
// showing "TX 14.080" reads. Channel B alone leaves that stale.
lines = append(lines, fmt.Sprintf("tx_frequency:%d;", cur.txHz))
}
if (!prev.valid || prev.mode != cur.mode) && cur.mode != "" {
lines = append(lines, fmt.Sprintf("modulation:0,%s;", cur.mode))
}
if !prev.valid || prev.split != cur.split {
lines = append(lines, fmt.Sprintf("split_enable:0,%t;", cur.split))
}
for _, l := range lines {
s.broadcast(l)
}
return lines
}
func (s *Server) broadcast(line string) {
s.mu.Lock()
conns := make([]*client, 0, len(s.conns))
for c := range s.conns {
conns = append(conns, c)
}
s.mu.Unlock()
for _, c := range conns {
_ = c.send(line)
}
}
// handle answers one command from a client. Returns what was sent back, which
// is "" for a command that only acts on the radio.
//
// A command that SETS something is echoed to every client, not just answered to
// the one that sent it: the protocol document is explicit that the server
// synchronises all connected clients, and two loggers that disagree about the
// frequency are worse than one that is merely slow.
func (s *Server) handle(c *client, cmd string) string {
name, args := cmd, ""
if i := strings.IndexByte(cmd, ':'); i >= 0 {
name, args = cmd[:i], cmd[i+1:]
}
f := strings.Split(args, ",")
arg := func(i int) string {
if i < len(f) {
return strings.TrimSpace(f[i])
}
return ""
}
num := func(i int) int64 {
v, _ := strconv.ParseInt(arg(i), 10, 64)
return v
}
reply := func(line string) string {
_ = c.send(line)
return line
}
rx, tx, mode, split := s.read()
switch strings.ToLower(strings.TrimSpace(name)) {
case "vfo":
// Read form: two arguments. Set form: three.
if len(f) < 3 || arg(2) == "" {
if arg(1) == "1" {
return reply(fmt.Sprintf("vfo:0,1,%d;", tx))
}
return reply(fmt.Sprintf("vfo:0,0,%d;", rx))
}
hz := num(2)
if hz <= 0 {
return ""
}
if arg(1) == "1" {
// Channel B is the transmit frequency. Setting it while simplex must
// not move the rig's only VFO — the client asked to prepare a split
// transmit frequency, not to QSY — but it must not be thrown away
// either: it is where the split will be armed a moment from now.
s.mu.Lock()
s.pendingTxHz = hz
s.mu.Unlock()
if !split {
s.broadcast(fmt.Sprintf("vfo:0,1,%d;", hz))
return ""
}
if err := s.rig.SetSplit(true, hz); err != nil {
s.log("tci server: split TX %d Hz refused: %v", hz, err)
return ""
}
} else if err := s.rig.SetFreq(hz); err != nil {
s.log("tci server: tune to %d Hz refused: %v", hz, err)
return ""
}
s.broadcast(fmt.Sprintf("vfo:0,%s,%d;", orZero(arg(1)), hz))
return ""
case "modulation":
if len(f) < 2 || arg(1) == "" {
return reply(fmt.Sprintf("modulation:0,%s;", mode))
}
m := tciModeToADIF(arg(1))
if m == "" {
return ""
}
if err := s.rig.SetMode(m); err != nil {
s.log("tci server: mode %s refused: %v", m, err)
return ""
}
s.broadcast(fmt.Sprintf("modulation:0,%s;", strings.ToLower(arg(1))))
return ""
case "trx":
if len(f) < 2 || arg(1) == "" {
return reply("trx:0,false;")
}
on := strings.EqualFold(arg(1), "true")
// Only touch the radio on a CHANGE — restating a state is not a request
// to change it. The first command always goes through, since there is no
// knowing how the radio was left.
s.mu.Lock()
known, prev := s.pttKnown, s.ptt
s.mu.Unlock()
if known && prev == on {
s.broadcast(fmt.Sprintf("trx:0,%t;", on))
return ""
}
if err := s.rig.SetPTT(on); err != nil {
// The cache is stamped ONLY on success, and a failure clears "known"
// outright so the NEXT command — whatever it is — reaches the radio.
//
// It used to be written before the radio was commanded and left in
// place when the command failed. That is how a rig got stuck keyed for
// good: the un-key failed on a lost CI-V acknowledgement, the cache
// recorded "off" regardless, and from then on every trx:0,false was
// dismissed as a repeat of a state the radio had never reached. Not
// even reconnecting the client cleared it — this cache is per-server,
// not per-connection — so the transmitter stayed keyed into the
// amplifier until the operator switched the radio off. A cache must
// never claim something the radio refused.
s.mu.Lock()
s.pttKnown = false
s.mu.Unlock()
s.log("tci server: PTT %v refused: %v", on, err)
return ""
}
s.mu.Lock()
s.ptt, s.pttKnown = on, true
s.mu.Unlock()
s.log("tci server: PTT %s", map[bool]string{true: "ON", false: "off"}[on])
s.broadcast(fmt.Sprintf("trx:0,%t;", on))
return ""
case "split_enable":
if len(f) < 2 || arg(1) == "" {
return reply(fmt.Sprintf("split_enable:0,%t;", split))
}
on := strings.EqualFold(arg(1), "true")
// Already in the state asked for? Then it is done, and nothing goes to
// the radio. This is the lesson the rigctl server paid for: JTDX in "Fake
// It" uses no split but still says so to be sure, and a backend that
// cannot set split answered an error to a request that was already true.
// JTDX read that as rig control failing and abandoned the transmission a
// second into the frame. A refusal is only honest when something actually
// needed doing.
if on == split {
s.broadcast(fmt.Sprintf("split_enable:0,%t;", on))
return ""
}
// Arm on the frequency the client gave for channel B, which it is free to
// have sent before this command rather than after.
s.mu.Lock()
pending := s.pendingTxHz
s.mu.Unlock()
txHz := tx
if on && pending > 0 {
txHz = pending
}
if err := s.rig.SetSplit(on, txHz); err != nil {
// The refusal is the useful part: a backend that cannot split says
// so, and the client can tell the operator instead of transmitting
// on the wrong frequency believing all is well.
s.log("tci server: split %v refused: %v", on, err)
return ""
}
s.log("tci server: split %s, TX %d Hz", map[bool]string{true: "ON", false: "off"}[on], txHz)
s.broadcast(fmt.Sprintf("split_enable:0,%t;", on))
return ""
case "dds":
// The panorama's centre frequency. OpsLog has no panorama, so it answers
// with the receive frequency — which is where a client draws its own.
return reply(fmt.Sprintf("dds:0,%d;", rx))
case "if":
// Offset of the tuning filter inside the panorama: zero, since our "dds"
// is the receive frequency itself.
return reply("if:0,0,0;")
case "start", "stop", "ready":
return ""
default:
// Everything else — audio streams, CW macros, the E-Coder, the
// panorama's own settings — belongs to a radio, not to a CAT link.
// Silence rather than an error: a client sends these hopefully at
// connect, and a refusal it did not ask for reads as a fault.
return ""
}
}
func orZero(s string) string {
if s == "" {
return "0"
}
return s
}
// adifToTCIMode maps an ADIF mode to a TCI modulation.
//
// SSB carries no sideband, so it is resolved from the frequency the way every
// operator does: below 10 MHz lower, above it upper. A client told "ssb" would
// not recognise it — the modulation list is the vocabulary.
func adifToTCIMode(mode string, hz int64) string {
switch strings.ToUpper(strings.TrimSpace(mode)) {
case "":
return ""
case "CW", "CWR":
return "cw"
case "USB":
return "usb"
case "LSB":
return "lsb"
case "SSB":
if hz > 0 && hz < 10_000_000 {
return "lsb"
}
return "usb"
case "AM":
return "am"
case "FM", "NFM":
return "nfm"
case "RTTY":
return "digl"
}
// Everything else is a data mode: FT8, FT4, JT65, PSK31, MSK144, VARA…
// TCI has one pair for the whole family, and the sideband follows the same
// rule the data modes themselves use — upper, but for the few HF corners
// where LSB is conventional the radio is already there.
return "digu"
}
// tciModeToADIF maps a TCI modulation back to an ADIF mode.
func tciModeToADIF(m string) string {
switch strings.ToLower(strings.TrimSpace(m)) {
case "cw":
return "CW"
case "usb":
return "USB"
case "lsb":
return "LSB"
case "am", "sam":
return "AM"
case "nfm", "fm", "wfm":
return "FM"
case "digl", "digu", "dsb", "drm":
// The data family: the mode the operator is actually running (FT8, RTTY)
// is chosen in OpsLog, and a client switching to "digital" must not
// overwrite it with a guess. DATA is the honest ADIF answer.
return "DATA"
}
return ""
}
+386
View File
@@ -0,0 +1,386 @@
package tciserver
import (
"fmt"
"strings"
"testing"
)
// fakeRig is a radio that remembers what it was told. Everything here is about
// what OpsLog does with a client's command, so the rig only has to answer and
// record.
type fakeRig struct {
freq, rxFreq int64
mode string
split bool
txHz int64
ptt bool
splitErr error
pttErr error
calls []string
}
func (r *fakeRig) Freq() int64 { return r.freq }
func (r *fakeRig) RxFreq() int64 { return r.rxFreq }
func (r *fakeRig) Mode() string { return r.mode }
func (r *fakeRig) Split() (bool, int64) { return r.split, r.txHz }
func (r *fakeRig) SetFreq(hz int64) error {
r.calls = append(r.calls, fmt.Sprintf("freq=%d", hz))
r.freq, r.rxFreq = hz, hz
return nil
}
func (r *fakeRig) SetMode(m string) error {
r.calls = append(r.calls, "mode="+m)
r.mode = m
return nil
}
func (r *fakeRig) SetPTT(on bool) error {
// Refused BEFORE the state moves, like a radio that never got the frame.
if r.pttErr != nil {
return r.pttErr
}
r.calls = append(r.calls, fmt.Sprintf("ptt=%v", on))
r.ptt = on
return nil
}
func (r *fakeRig) SetSplit(on bool, txHz int64) error {
if r.splitErr != nil {
return r.splitErr
}
r.calls = append(r.calls, fmt.Sprintf("split=%v,%d", on, txHz))
r.split, r.txHz = on, txHz
return nil
}
// srv builds a server with no listener — handle() and publish() are the whole
// protocol, and neither needs a socket.
func srv(r *fakeRig) *Server { return New(0, r, nil) }
// A client with no connection: send() would need one, so reads are checked
// through the returned line instead. This is why handle returns what it sent.
func ask(t *testing.T, s *Server, cmd string) string {
t.Helper()
return s.handle(&client{}, cmd)
}
// The initialisation block is what a client needs before it will believe there
// is a radio at all. Its contents come from §4.1 of the protocol document, and
// a client that does not see ready; simply waits for ever.
func TestInitBlockCarriesTheDocumentedInitialisationSet(t *testing.T) {
s := srv(&fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB"})
block := strings.Join(s.initBlock(), "")
for _, want := range []string{
"protocol:ExpertSDR3,", "device:", "receive_only:false;", "trx_count:1;",
"channel_count:2;", "vfo_limits:", "if_limits:", "modulations_list:",
"ready;", "start;",
// Transmit permission. A client that models it starts out assuming it
// may NOT transmit, and without this never even tries — PTT does
// nothing and the server never sees a trx command at all.
"tx_enable:0,true;",
} {
if !strings.Contains(block, want) {
t.Errorf("the initialisation block is missing %q — a client would not proceed past connect", want)
}
}
// And the current state, so a client that connects mid-session shows the
// right frequency instead of waiting for the operator to touch something.
if !strings.Contains(block, "vfo:0,0,14074000;") {
t.Errorf("no current frequency in the block:\n%s", block)
}
if !strings.Contains(block, "modulation:0,usb;") {
t.Errorf("no current mode in the block:\n%s", block)
}
}
// Channel A is where we LISTEN, channel B where we transmit. Handing these to a
// client the wrong way round is the one mistake here that puts a station on the
// DX's own frequency, so it is pinned in both directions.
func TestSplitPutsTheListeningFrequencyOnChannelA(t *testing.T) {
// OpsLog's RigState is ADIF: Freq is the TRANSMIT frequency, RxFreq where we
// listen. A DX transmitting on 14025 and listening up 2.
r := &fakeRig{freq: 14027000, rxFreq: 14025000, mode: "CW", split: true, txHz: 14027000}
s := srv(r)
block := strings.Join(s.initBlock(), "")
if !strings.Contains(block, "vfo:0,0,14025000;") {
t.Errorf("channel A is not the receive frequency:\n%s", block)
}
if !strings.Contains(block, "vfo:0,1,14027000;") {
t.Errorf("channel B is not the transmit frequency:\n%s", block)
}
if !strings.Contains(block, "split_enable:0,true;") {
t.Errorf("split was not announced:\n%s", block)
}
}
// Simplex: both channels report the one frequency, so a client reading either
// gets the right answer.
func TestSimplexReportsTheSameFrequencyOnBothChannels(t *testing.T) {
s := srv(&fakeRig{freq: 7100000, rxFreq: 7100000, mode: "SSB"})
if got := ask(t, s, "vfo:0,0"); got != "vfo:0,0,7100000;" {
t.Errorf("read of channel A = %q", got)
}
if got := ask(t, s, "vfo:0,1"); got != "vfo:0,1,7100000;" {
t.Errorf("read of channel B = %q", got)
}
// 7 MHz is below 10, so SSB is lower sideband — a client told "ssb" would
// not recognise it at all, the modulation list is the vocabulary.
if got := ask(t, s, "modulation:0"); got != "modulation:0,lsb;" {
t.Errorf("read of the mode = %q, want lsb below 10 MHz", got)
}
}
// The client tunes the radio.
func TestAClientCanTuneAndSetModeAndKey(t *testing.T) {
r := &fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB"}
s := srv(r)
ask(t, s, "vfo:0,0,14200000")
ask(t, s, "modulation:0,cw")
ask(t, s, "trx:0,true")
ask(t, s, "trx:0,false")
want := []string{"freq=14200000", "mode=CW", "ptt=true", "ptt=false"}
if strings.Join(r.calls, " ") != strings.Join(want, " ") {
t.Errorf("the radio was told %v, want %v", r.calls, want)
}
}
// Channel B is the SPLIT transmit frequency. Writing it while the rig is
// simplex must not move the only VFO there is: the client asked to prepare a
// transmit frequency, not to QSY — and a logger that did this on every spot
// click would drag the operator off the station they were listening to.
func TestWritingChannelBWhileSimplexLeavesTheRigAlone(t *testing.T) {
r := &fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB"}
s := srv(r)
ask(t, s, "vfo:0,1,14080000")
if len(r.calls) != 0 {
t.Errorf("the radio was told %v — a split TX frequency moved a simplex rig", r.calls)
}
// With split armed it means what it says.
r.split, r.txHz = true, 14074000
ask(t, s, "vfo:0,1,14080000")
if len(r.calls) != 1 || r.calls[0] != "split=true,14080000" {
t.Errorf("with split on the radio was told %v, want the new transmit frequency", r.calls)
}
}
// A backend that cannot split says so, and the refusal must not be dressed up
// as success: the client can then tell the operator to use Fake It, where
// before it would transmit on the receive frequency believing all was well.
func TestARefusedSplitIsNotAnnouncedAsDone(t *testing.T) {
r := &fakeRig{freq: 14025000, rxFreq: 14025000, mode: "CW", splitErr: fmt.Errorf("this backend cannot split")}
s := srv(r)
if got := ask(t, s, "split_enable:0,true"); got != "" {
t.Errorf("a refused split answered %q", got)
}
if r.split {
t.Error("the rig was recorded as split after the backend refused")
}
}
// Only what moved is sent. TCI clients redraw on every command they receive, so
// re-sending an unchanged frequency four times a second makes a VFO readout
// flicker and, in some clients, fights the operator's own tuning.
func TestOnlyChangesAreSent(t *testing.T) {
r := &fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB"}
s := srv(r)
first := s.publish()
if len(first) == 0 {
t.Fatal("the first pass sent nothing — a client would never learn the state")
}
if got := s.publish(); len(got) != 0 {
t.Errorf("an unchanged radio produced %v", got)
}
r.freq, r.rxFreq = 14200000, 14200000
got := strings.Join(s.publish(), "")
// Both channels move together on a simplex rig, and the transmit frequency
// has its own command besides — a client showing "TX 14.200" reads that one,
// and channel B alone leaves it stale.
for _, want := range []string{"vfo:0,0,14200000;", "vfo:0,1,14200000;", "tx_frequency:14200000;"} {
if !strings.Contains(got, want) {
t.Errorf("after a QSY the clients were not told %q — got %q", want, got)
}
}
if got := s.publish(); len(got) != 0 {
t.Errorf("the QSY was re-sent: %v", got)
}
}
// Modes travel both ways, and the data family is the interesting half: a client
// switching to "digital" must not overwrite the mode the operator chose in
// OpsLog with a guess at which data mode it was.
func TestModeMapping(t *testing.T) {
up := []struct {
adif string
hz int64
want string
}{
{"CW", 14025000, "cw"},
{"SSB", 14200000, "usb"},
{"SSB", 7100000, "lsb"},
{"USB", 7100000, "usb"}, // an explicit sideband is never second-guessed
{"FT8", 14074000, "digu"},
{"RTTY", 14080000, "digl"},
{"AM", 3700000, "am"},
{"FM", 145500000, "nfm"},
{"", 14074000, ""},
}
for _, c := range up {
if got := adifToTCIMode(c.adif, c.hz); got != c.want {
t.Errorf("adifToTCIMode(%q, %d) = %q, want %q", c.adif, c.hz, got, c.want)
}
}
down := map[string]string{
"cw": "CW", "usb": "USB", "lsb": "LSB", "am": "AM", "sam": "AM",
"nfm": "FM", "digu": "DATA", "digl": "DATA", "": "",
}
for in, want := range down {
if got := tciModeToADIF(in); got != want {
t.Errorf("tciModeToADIF(%q) = %q, want %q", in, got, want)
}
}
}
// A command for something OpsLog is not — audio streams, CW macros, the
// panorama's settings — is met with silence rather than an error. A client
// sends these hopefully at connect, and a refusal it did not ask for reads as a
// fault with the rig.
func TestUnknownCommandsAreQuiet(t *testing.T) {
s := srv(&fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB"})
for _, cmd := range []string{"audio_start:0", "cw_macros_speed:25", "rx_filter_band:0,-2700,-100", "iq_start:0"} {
if got := ask(t, s, cmd); got != "" {
t.Errorf("%q answered %q", cmd, got)
}
}
}
// Split, with the client sending the two commands in the order it prefers.
//
// A client working split has to say two things: where to transmit, and that
// split is on. Nothing obliges it to say them in that order, and the frequency
// arriving first is the dangerous case: discarding it and then arming split
// leaves the transmit VFO on whatever it held — the RECEIVE frequency — so the
// operator transmits straight onto the DX while their software shows exactly
// what they asked for.
func TestSplitIsArmedOnTheFrequencyTheClientGaveWhicheverOrderItCame(t *testing.T) {
// Frequency first, then split — the order that used to lose the frequency.
r := &fakeRig{freq: 14025000, rxFreq: 14025000, mode: "CW"}
s := srv(r)
ask(t, s, "vfo:0,1,14027000")
ask(t, s, "split_enable:0,true")
if len(r.calls) != 1 || r.calls[0] != "split=true,14027000" {
t.Errorf("frequency first: the radio was told %v, want split armed on 14027000", r.calls)
}
// Split first, then the frequency — the order that always worked.
r2 := &fakeRig{freq: 14025000, rxFreq: 14025000, mode: "CW"}
s2 := srv(r2)
ask(t, s2, "split_enable:0,true")
ask(t, s2, "vfo:0,1,14027000")
if len(r2.calls) == 0 || r2.calls[len(r2.calls)-1] != "split=true,14027000" {
t.Errorf("split first: the radio was told %v, want it to end on 14027000", r2.calls)
}
}
// "Fake It" uses no split at all: the client shifts the DIAL at the start of
// transmit and shifts it back at the end. All it needs is channel A, and it
// must reach the radio both ways.
func TestFakeItIsJustTheDialMoving(t *testing.T) {
r := &fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB"}
s := srv(r)
ask(t, s, "vfo:0,0,14075300") // up for the over
ask(t, s, "vfo:0,0,14074000") // and back
want := []string{"freq=14075300", "freq=14074000"}
if strings.Join(r.calls, " ") != strings.Join(want, " ") {
t.Errorf("the radio was told %v, want %v", r.calls, want)
}
}
// A client in Fake It still says "split off" to be sure. The rig is already
// simplex, so there is nothing to do — and saying so beats asking a backend
// that may not be able to set split at all.
//
// This is what broke JTDX through the rigctl server: an error answered to a
// request that was already true, read as rig control failing, and the
// transmission abandoned a second into the frame.
func TestSayingSplitOffWhenAlreadySimplexTouchesNothing(t *testing.T) {
r := &fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB",
splitErr: fmt.Errorf("this backend cannot split")}
s := srv(r)
ask(t, s, "split_enable:0,false")
if len(r.calls) != 0 {
t.Errorf("the radio was told %v for a state it was already in", r.calls)
}
}
// A client restating PTT must not re-command the radio. Through the rigctl
// server, one sent set_ptt 0 sixteen times a second and the Flex's own transmit
// request was overwritten between two of them inside a millisecond.
func TestRepeatedPTTIsNotResentToTheRadio(t *testing.T) {
r := &fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB"}
s := srv(r)
for i := 0; i < 5; i++ {
ask(t, s, "trx:0,false")
}
if len(r.calls) != 1 || r.calls[0] != "ptt=false" {
// The FIRST one always goes through: there is no knowing how the radio
// was left.
t.Errorf("the radio was told %v, want one unkey and no repeats", r.calls)
}
ask(t, s, "trx:0,true")
ask(t, s, "trx:0,true")
if len(r.calls) != 2 || r.calls[1] != "ptt=true" {
t.Errorf("the radio was told %v, want the change through and the repeat dropped", r.calls)
}
}
// A refused un-key must never be remembered as done.
//
// The failure an operator hit running JTDX over TCI with an Icom on CI-V: the
// rig went to transmit, the un-key was refused on a lost acknowledgement, and
// from then on NOTHING could take it out of transmit. The cache had stamped
// "off" before the radio was even commanded and kept it after the refusal, so
// every later trx:0,false was dismissed as a repeat of a state the radio had
// never reached. It is per-server, not per-connection, so reconnecting the
// client changed nothing either — the transmitter stayed keyed into the
// amplifier, with no drive, until the radio was switched off by hand.
func TestARefusedUnkeyIsNotRememberedAsDone(t *testing.T) {
r := &fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB"}
s := srv(r)
ask(t, s, "trx:0,true")
if !r.ptt {
t.Fatal("the rig was never keyed — the test would prove nothing")
}
r.pttErr = fmt.Errorf("icom: timeout waiting for response")
ask(t, s, "trx:0,false")
if !r.ptt {
t.Fatal("the fake rig un-keyed on a refusal — the test would prove nothing")
}
// The client asks again, and this time the radio answers. It MUST be told.
r.pttErr = nil
ask(t, s, "trx:0,false")
if r.ptt {
t.Error("still keyed: the refused un-key was cached as done and the retry was dropped as a repeat")
}
}
// A client that walks away mid-over must not leave the rig transmitting, and
// the release must be once-only — a second call has nothing to un-key and must
// not re-command a radio that is already receiving.
func TestReleasePTTUnkeysOnceWhenTheClientLeaves(t *testing.T) {
r := &fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB"}
s := srv(r)
ask(t, s, "trx:0,true")
s.releasePTT("client left")
if r.ptt {
t.Error("the rig is still keyed after the client left")
}
n := len(r.calls)
s.releasePTT("client left")
if len(r.calls) != n {
t.Errorf("released twice — the radio was told %v", r.calls)
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const (
// appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.25.7"
appVersion = "0.25.9"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project.