Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7be6f64596 | ||
|
|
0bab7f05b9 | ||
|
|
dc898ce2af | ||
|
|
e5c9ca1a7d | ||
|
|
21a0d560de | ||
|
|
2941121f4b | ||
|
|
6c75ff345b | ||
|
|
d5e25244ee | ||
|
|
4095455e66 | ||
|
|
3f95763ca6 | ||
|
|
72ec3cbb97 | ||
|
|
a8bca8c316 | ||
|
|
8b75166dbe |
+76
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ import (
|
||||
"hamlog/internal/spe"
|
||||
"hamlog/internal/steppir"
|
||||
"hamlog/internal/syncfolder"
|
||||
"hamlog/internal/tciserver"
|
||||
"hamlog/internal/tunergenius"
|
||||
"hamlog/internal/uls"
|
||||
"hamlog/internal/ultrabeam"
|
||||
@@ -120,6 +121,8 @@ const (
|
||||
keyCATDigitalDefault = "cat.digital_default" // mode to use when CAT reports DATA
|
||||
keyCATShareEnabled = "cat.share.enabled" // expose CAT to other programs (Hamlib NET rigctl)
|
||||
keyCATSharePort = "cat.share.port" // TCP port for that server (rigctld default 4532)
|
||||
keyCATShareProto = "cat.share.proto" // which sharing server runs: "rigctl" (Hamlib NET) or "tci"
|
||||
keyCATShareTCIPort = "cat.share.tci_port" // WebSocket port for the TCI server (TCI default 40001)
|
||||
keyCATXieguPort = "cat.xiegu.port" // Xiegu CI-V serial port (G90/X6100…)
|
||||
keyCATXieguBaud = "cat.xiegu.baud" // Xiegu CI-V baud (G90 default 19200)
|
||||
keyCATXieguAddr = "cat.xiegu.addr" // Xiegu CI-V address (factory 0x70)
|
||||
@@ -457,8 +460,13 @@ type CATSettings struct {
|
||||
PollMs int `json:"poll_ms"` // poll interval in ms (default 250)
|
||||
DelayMs int `json:"delay_ms"` // pause between commands (default 0)
|
||||
DigitalDefault string `json:"digital_default"` // when CAT says DATA, surface this mode (FT8/FT4/RTTY/…)
|
||||
ShareEnabled bool `json:"share_enabled"` // serve CAT to other programs (Hamlib NET rigctl)
|
||||
SharePort int `json:"share_port"` // TCP port for it (default 4532)
|
||||
ShareEnabled bool `json:"share_enabled"` // serve CAT to other programs
|
||||
SharePort int `json:"share_port"` // TCP port for the rigctl server (default 4532)
|
||||
// ShareProto picks WHICH server runs — "rigctl" or "tci". One or the other,
|
||||
// not both: they are two ways of asking the same radio the same questions,
|
||||
// and a second listener is only a second thing to go wrong.
|
||||
ShareProto string `json:"share_proto"`
|
||||
ShareTCIPort int `json:"share_tci_port"` // WebSocket port for the TCI server (default 40001)
|
||||
// PTT hotkey — a keyboard key that keys the transmitter while OpsLog is
|
||||
// focused (hold-to-talk, or toggle). Uses the configured Audio → PTT method,
|
||||
// falling back to CAT keying when that is VOX/none.
|
||||
@@ -596,8 +604,12 @@ type App struct {
|
||||
// port: without it, choosing native CAT locks WSJT-X and friends out of the
|
||||
// radio entirely. nil when the operator has not enabled sharing.
|
||||
catShare *rigctld.Server
|
||||
dxcc *dxcc.Manager
|
||||
cluster *cluster.Manager
|
||||
// catShareTCI serves the same link to programs built around Expert
|
||||
// Electronics' TCI instead. One or the other runs, never both — they answer
|
||||
// the same questions about the same radio, and nothing speaks both.
|
||||
catShareTCI *tciserver.Server
|
||||
dxcc *dxcc.Manager
|
||||
cluster *cluster.Manager
|
||||
// Cluster spots/lines are processed OFF the socket-read goroutine. Enriching a
|
||||
// spot (DXCC/POTA), emitting it to the UI, running alert rules — which can hit
|
||||
// a remote MySQL via isWorkedBandMode — and mirroring it to the Flex all used
|
||||
@@ -1691,6 +1703,10 @@ func (a *App) shutdown(ctx context.Context) {
|
||||
a.catShare.Stop()
|
||||
a.catShare = nil
|
||||
}
|
||||
if a.catShareTCI != nil {
|
||||
a.catShareTCI.Stop()
|
||||
a.catShareTCI = nil
|
||||
}
|
||||
if a.cat != nil {
|
||||
a.cat.Stop()
|
||||
}
|
||||
@@ -2654,8 +2670,14 @@ func (a *App) reloadLookupProviders() {
|
||||
fmt.Println("OpsLog: settings load error:", err)
|
||||
return
|
||||
}
|
||||
if days, _ := strconv.Atoi(m[keyCacheTTL]); days > 0 {
|
||||
a.cache.SetTTL(time.Duration(days) * 24 * time.Hour)
|
||||
// An EXPLICIT zero switches the cache off; an ABSENT key leaves the thirty-day
|
||||
// default the cache was built with. The difference matters: every operator who
|
||||
// has never opened this setting has no value stored, and reading that blank as
|
||||
// a zero would silently turn the cache off for all of them.
|
||||
if raw := strings.TrimSpace(m[keyCacheTTL]); raw != "" {
|
||||
if days, err := strconv.Atoi(raw); err == nil && days >= 0 {
|
||||
a.cache.SetTTL(time.Duration(days) * 24 * time.Hour)
|
||||
}
|
||||
}
|
||||
|
||||
build := func(name string) lookup.Provider {
|
||||
@@ -5218,6 +5240,37 @@ func (a *App) SaveAwardReference(code string, ref awardref.Ref) error {
|
||||
}
|
||||
a.markAwardEdited(code)
|
||||
a.mirrorAwards()
|
||||
// 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 in the log, exactly as deleting or replacing the list does.
|
||||
// Those already recomputed; this did not, and left the grid showing the old
|
||||
// label until something else happened to trigger a pass.
|
||||
a.recomputeAwardRefsAsync()
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenameAwardReference changes a reference's code on an award the operator
|
||||
// already runs.
|
||||
//
|
||||
// The one field the editor could not touch, and the one that was wrong: WAJA
|
||||
// shipped numbered by the Japanese state rather than by the JARL. Correcting it
|
||||
// meant deleting the whole list and importing another — throwing away anything
|
||||
// the operator had adjusted in it.
|
||||
func (a *App) RenameAwardReference(code, oldRef, newRef string) error {
|
||||
if a.awardRefs == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
if err := a.awardRefs.Rename(a.ctx, code, oldRef, newRef); err != nil {
|
||||
return err
|
||||
}
|
||||
a.markAwardEdited(code)
|
||||
a.mirrorAwards()
|
||||
// The materialised award columns hold a LABEL computed from the definition —
|
||||
// the reference code for most awards, the name for those displaying by name.
|
||||
// A renumbered reference changes the first kind, so the log is recomputed
|
||||
// exactly as it is for every other reference-list change.
|
||||
a.recomputeAwardRefsAsync()
|
||||
applog.Printf("awards: %s reference %s renumbered to %s", code, oldRef, newRef)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7237,6 +7290,19 @@ func (a *App) lookupCallsign(callsign string, force bool, qsoDate string) (looku
|
||||
}
|
||||
}
|
||||
a.enrichFromULS(&r, callsign)
|
||||
// An entity that IS a single island group answers for itself. QRZ carries
|
||||
// <iota> only for the operators who filled it in, and most have not; but a
|
||||
// station in Ascension is on AF-003 whatever its callbook record says, and
|
||||
// the entity is known for every callsign from cty.dat alone.
|
||||
//
|
||||
// So this reaches the operator with no QRZ subscription, and the station
|
||||
// that has never touched a callbook — and it is right when the two
|
||||
// disagree only in the sense that the callbook wins: filled ONLY when the
|
||||
// callbook gave nothing, because an operator who typed their own reference
|
||||
// knows something a table cannot (an IOTA-heavy entity, a rare island).
|
||||
if r.IOTA == "" && r.DXCC != 0 {
|
||||
r.IOTA = awardref.IOTAForDXCC(r.DXCC)
|
||||
}
|
||||
// Custom outbound rows bound to a lookup. After the enrichment, so a
|
||||
// template sees the same grid and county the entry panel is about to show —
|
||||
// two answers for one callsign is how a rotator ends up pointed elsewhere
|
||||
@@ -7338,9 +7404,13 @@ func (a *App) GetLookupSettings() (LookupSettings, error) {
|
||||
if err != nil {
|
||||
return LookupSettings{}, err
|
||||
}
|
||||
ttl, _ := strconv.Atoi(m[keyCacheTTL])
|
||||
if ttl <= 0 {
|
||||
ttl = 30
|
||||
// Same rule as reloadLookupProviders: blank means "never set" and gets the
|
||||
// default, while a stored zero is the operator asking for no cache at all.
|
||||
ttl := 30
|
||||
if raw := strings.TrimSpace(m[keyCacheTTL]); raw != "" {
|
||||
if n, err := strconv.Atoi(raw); err == nil && n >= 0 {
|
||||
ttl = n
|
||||
}
|
||||
}
|
||||
return LookupSettings{
|
||||
QRZUser: m[keyQRZUser],
|
||||
@@ -7360,8 +7430,9 @@ func (a *App) SaveLookupSettings(s LookupSettings) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
if s.CacheTTLDays <= 0 {
|
||||
s.CacheTTLDays = 30
|
||||
// Zero is kept — it means no cache. Only a negative number is nonsense.
|
||||
if s.CacheTTLDays < 0 {
|
||||
s.CacheTTLDays = 0
|
||||
}
|
||||
// Reject a primary == failsafe routing combo — would just hit the same
|
||||
// provider twice. Frontend should prevent this but defend in depth.
|
||||
@@ -7436,7 +7507,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
||||
if a.settings == nil {
|
||||
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
|
||||
}
|
||||
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATYaesuLowLines, keyCATKenwoodLowLines, keyCATKenwoodDataMode, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPttHotkeyEnabled, keyCATPttHotkey, keyCATPttHotkeyToggle, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort)
|
||||
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATYaesuLowLines, keyCATKenwoodLowLines, keyCATKenwoodDataMode, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPttHotkeyEnabled, keyCATPttHotkey, keyCATPttHotkeyToggle, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort, keyCATShareProto, keyCATShareTCIPort)
|
||||
if err != nil {
|
||||
return CATSettings{}, err
|
||||
}
|
||||
@@ -7478,6 +7549,8 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
||||
DigitalDefault: m[keyCATDigitalDefault],
|
||||
ShareEnabled: m[keyCATShareEnabled] == "1",
|
||||
SharePort: 4532,
|
||||
ShareProto: "rigctl",
|
||||
ShareTCIPort: tciserver.DefaultPort,
|
||||
}
|
||||
if n, _ := strconv.Atoi(m[keyCATFlexPort]); n > 0 && n <= 65535 {
|
||||
out.FlexPort = n
|
||||
@@ -7491,6 +7564,12 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
||||
if n, _ := strconv.Atoi(m[keyCATSharePort]); n > 0 && n <= 65535 {
|
||||
out.SharePort = n
|
||||
}
|
||||
if p := strings.ToLower(strings.TrimSpace(m[keyCATShareProto])); p == "tci" {
|
||||
out.ShareProto = p
|
||||
}
|
||||
if n, _ := strconv.Atoi(m[keyCATShareTCIPort]); n > 0 && n <= 65535 {
|
||||
out.ShareTCIPort = n
|
||||
}
|
||||
if n, _ := strconv.Atoi(m[keyCATXieguBaud]); n > 0 {
|
||||
out.XieguBaud = n
|
||||
}
|
||||
@@ -7548,6 +7627,12 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
||||
if s.SharePort <= 0 || s.SharePort > 65535 {
|
||||
s.SharePort = 4532
|
||||
}
|
||||
if s.ShareProto != "tci" {
|
||||
s.ShareProto = "rigctl"
|
||||
}
|
||||
if s.ShareTCIPort <= 0 || s.ShareTCIPort > 65535 {
|
||||
s.ShareTCIPort = tciserver.DefaultPort
|
||||
}
|
||||
if s.XieguBaud <= 0 {
|
||||
s.XieguBaud = 19200
|
||||
}
|
||||
@@ -7648,6 +7733,8 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
||||
keyCATDigitalDefault: strings.ToUpper(strings.TrimSpace(s.DigitalDefault)),
|
||||
keyCATShareEnabled: shareEnabled,
|
||||
keyCATSharePort: strconv.Itoa(s.SharePort),
|
||||
keyCATShareProto: s.ShareProto,
|
||||
keyCATShareTCIPort: strconv.Itoa(s.ShareTCIPort),
|
||||
} {
|
||||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||
return err
|
||||
@@ -10289,6 +10376,15 @@ func (a *App) TestCloudlogUpload() (string, error) {
|
||||
// ── QSL Manager (manual upload) ────────────────────────────────────────
|
||||
|
||||
// uploadColumnFor maps a service id to its QSO sent-status column.
|
||||
// manualUploadPace is the shortest gap between two consecutive single-QSO
|
||||
// uploads in a bulk "Send to …" run, for the services with no batch endpoint
|
||||
// (QRZ.com, HRDLog). Selecting twenty-five thousand QSOs in the QSL Manager and
|
||||
// firing them off as fast as the link allows is exactly the traffic a logbook
|
||||
// service reads as a robot rather than an operator — Club Log threatens to block
|
||||
// an IP for it. The gap is free in practice: a round trip to either already
|
||||
// takes longer than it.
|
||||
const manualUploadPace = 200 * time.Millisecond
|
||||
|
||||
func uploadColumnFor(service string) string {
|
||||
switch extsvc.Service(service) {
|
||||
case extsvc.ServiceQRZ:
|
||||
@@ -10383,10 +10479,13 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if svc == extsvc.ServiceClublog || svc == extsvc.ServiceHRDLog {
|
||||
} else if svc == extsvc.ServiceClublog || svc == extsvc.ServiceHRDLog || svc == extsvc.ServiceEQSL {
|
||||
statusCol, dateCol := "clublog_qso_upload_status", "clublog_qso_upload_date"
|
||||
if svc == extsvc.ServiceHRDLog {
|
||||
switch svc {
|
||||
case extsvc.ServiceHRDLog:
|
||||
statusCol, dateCol = "hrdlog_qso_upload_status", "hrdlog_qso_upload_date"
|
||||
case extsvc.ServiceEQSL:
|
||||
statusCol, dateCol = "eqsl_sent", "eqsl_sent_date"
|
||||
}
|
||||
type item struct {
|
||||
id int64
|
||||
@@ -10450,10 +10549,62 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
|
||||
applog.Printf("extsvc: Club Log batch FAILED (%s) — QSOs: %s", msg, strings.Join(who, ", "))
|
||||
}
|
||||
}
|
||||
} else if svc == extsvc.ServiceEQSL {
|
||||
// eQSL's ImportADIF.cfm is a file importer — it answers "X out of Y
|
||||
// records added" — so send a whole chunk per request instead of one
|
||||
// request per contact. eQSL asks that an upload stay under about a
|
||||
// thousand records; 100 keeps a single refused record from taking the
|
||||
// rest of the chunk with it.
|
||||
const chunk = 100
|
||||
emit(fmt.Sprintf("eQSL: uploading %d QSO(s) in batches of %d…", len(items), chunk))
|
||||
for start := 0; start < len(items); start += chunk {
|
||||
end := start + chunk
|
||||
if end > len(items) {
|
||||
end = len(items)
|
||||
}
|
||||
batch := items[start:end]
|
||||
recs := make([]string, len(batch))
|
||||
batchIDs := make([]int64, len(batch))
|
||||
for i, it := range batch {
|
||||
recs[i] = it.rec
|
||||
batchIDs[i] = it.id
|
||||
}
|
||||
res, err := extsvc.UploadEQSLBatch(ctx, nil, cfg.EQSL.Username, cfg.EQSL.Password, cfg.EQSL.QTHNickname, recs)
|
||||
if err == nil && res.OK {
|
||||
if merr := a.qso.MarkUploadedBatch(ctx, statusCol, dateCol, date, batchIDs); merr != nil {
|
||||
applog.Printf("extsvc: eQSL batch mark: %v", merr)
|
||||
}
|
||||
uploaded += len(batch)
|
||||
// eQSL took the file but left records out. It never says WHICH
|
||||
// — normally they are QSOs it already holds — so quote its own
|
||||
// count rather than claim a clean run.
|
||||
if res.Ignored {
|
||||
emit(fmt.Sprintf("eQSL: %d/%d uploaded — %s", end, len(items), res.Message))
|
||||
} else {
|
||||
emit(fmt.Sprintf("eQSL: %d/%d uploaded", end, len(items)))
|
||||
}
|
||||
} else {
|
||||
msg := res.Message
|
||||
if err != nil {
|
||||
msg = err.Error()
|
||||
}
|
||||
// Name the QSOs in the failing batch, same as Club Log: a
|
||||
// per-record rejection is otherwise impossible to locate.
|
||||
who := make([]string, 0, len(batch))
|
||||
for _, it := range batch {
|
||||
who = append(who, fmt.Sprintf("%s#%d", it.call, it.id))
|
||||
}
|
||||
emit(fmt.Sprintf("eQSL: batch of %d FAILED: %s", len(batch), msg))
|
||||
applog.Printf("extsvc: eQSL batch FAILED (%s) — QSOs: %s", msg, strings.Join(who, ", "))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// HRDLog's NewEntry.aspx inserts only the FIRST record of a multi-
|
||||
// record ADIF, so upload ONE record per request. The DB stays cheap:
|
||||
// bulk fetch above + the marks flushed in batches (not one per QSO).
|
||||
// Paced: HRDLog is the one service here with no way to batch, and a
|
||||
// few thousand requests as fast as the link allows is what a logbook
|
||||
// reads as a robot.
|
||||
emit(fmt.Sprintf("HRDLog: uploading %d QSO(s) (one request each)…", len(items)))
|
||||
var doneIDs []int64
|
||||
flush := func() {
|
||||
@@ -10466,6 +10617,9 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
|
||||
doneIDs = doneIDs[:0]
|
||||
}
|
||||
for i, it := range items {
|
||||
if i > 0 {
|
||||
time.Sleep(manualUploadPace)
|
||||
}
|
||||
res, err := extsvc.UploadHRDLog(ctx, nil, cfg.HRDLog.Callsign, cfg.HRDLog.Code, it.rec)
|
||||
if err == nil && res.OK {
|
||||
doneIDs = append(doneIDs, it.id)
|
||||
@@ -10487,34 +10641,26 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
|
||||
flush()
|
||||
}
|
||||
} else {
|
||||
// QRZ.com: one record per request (its logbook API has no batch upload).
|
||||
for _, id := range ids {
|
||||
// QRZ.com: one record per request (its logbook API has no batch upload),
|
||||
// paced for the same reason as HRDLog above.
|
||||
for i, id := range ids {
|
||||
if i > 0 {
|
||||
time.Sleep(manualUploadPace)
|
||||
}
|
||||
q, gerr := a.qso.GetByID(ctx, id)
|
||||
call := ""
|
||||
if gerr == nil {
|
||||
call = q.Callsign
|
||||
}
|
||||
force := ""
|
||||
if svc == extsvc.ServiceQRZ {
|
||||
force = cfg.QRZ.ForceStationCallsign
|
||||
}
|
||||
rec, ok := a.buildUploadADIF(id, force)
|
||||
// QRZ rewrites STATION_CALLSIGN to the registered call.
|
||||
rec, ok := a.buildUploadADIF(id, cfg.QRZ.ForceStationCallsign)
|
||||
if !ok {
|
||||
emit(call + " — skipped (no record)")
|
||||
continue
|
||||
}
|
||||
var res extsvc.UploadResult
|
||||
var err error
|
||||
switch svc {
|
||||
case extsvc.ServiceQRZ:
|
||||
res, err = extsvc.UploadQRZ(ctx, nil, cfg.QRZ.APIKey, rec)
|
||||
case extsvc.ServiceHRDLog:
|
||||
res, err = extsvc.UploadHRDLog(ctx, nil, cfg.HRDLog.Callsign, cfg.HRDLog.Code, rec)
|
||||
case extsvc.ServiceEQSL:
|
||||
res, err = extsvc.UploadEQSL(ctx, nil, cfg.EQSL.Username, cfg.EQSL.Password, cfg.EQSL.QTHNickname, rec)
|
||||
default:
|
||||
res, err = extsvc.UploadClublog(ctx, nil, cfg.Clublog, rec)
|
||||
}
|
||||
// Only QRZ reaches this branch: LoTW, Club Log, HRDLog and eQSL are
|
||||
// all handled above, and UploadQSOsManual rejects anything else.
|
||||
res, err := extsvc.UploadQRZ(ctx, nil, cfg.QRZ.APIKey, rec)
|
||||
if err == nil && res.OK {
|
||||
a.markExtUploaded(svc, id, "")
|
||||
uploaded++
|
||||
@@ -14853,6 +14999,13 @@ type StationDevice struct {
|
||||
OffURLs []string `json:"off_urls,omitempty"`
|
||||
OnPat string `json:"on_pattern,omitempty"` // fallback, {relay} substituted
|
||||
OffPat string `json:"off_pattern,omitempty"`
|
||||
// InsecureTLS accepts an HTTPS certificate that cannot be verified — which
|
||||
// is the only kind a relay board on the LAN can present, having signed it
|
||||
// itself. Off by default, because the other HTTPS case is the opposite one:
|
||||
// a board reached from outside through a proxy with a real certificate,
|
||||
// where verification is what stands between an antenna switch and the
|
||||
// internet.
|
||||
InsecureTLS bool `json:"insecure_tls,omitempty"`
|
||||
}
|
||||
|
||||
// deviceRelayCount is the relay count for a configured device — fixed by type,
|
||||
@@ -14913,7 +15066,7 @@ func buildDeviceDriver(d StationDevice) relaydev.Device {
|
||||
// generic board fall through to the WebSwitch driver below: it answered
|
||||
// the WebSwitch's own address, never sent one configured URL, and
|
||||
// reported itself offline so every relay button stayed greyed out.
|
||||
return relaydev.NewHTTPGeneric(d.OnURLs, d.OffURLs, d.OnPat, d.OffPat, d.User, d.Pass, deviceRelayCount(d), d.Labels)
|
||||
return relaydev.NewHTTPGeneric(d.OnURLs, d.OffURLs, d.OnPat, d.OffPat, d.User, d.Pass, deviceRelayCount(d), d.Labels, d.InsecureTLS)
|
||||
default:
|
||||
return relaydev.NewWebswitch(d.Host)
|
||||
}
|
||||
@@ -14933,7 +15086,10 @@ func deviceKey(d StationDevice) string {
|
||||
// The labels are part of the wire format here: {value} sends them.
|
||||
// Renaming a relay re-addresses it, and the cached driver would keep
|
||||
// commanding the old name.
|
||||
"|" + strings.Join(d.Labels, "\x1f")
|
||||
"|" + strings.Join(d.Labels, "\x1f") +
|
||||
// Ticking the box has to rebuild the driver: the cached one holds the
|
||||
// verifying client and would go on refusing the certificate.
|
||||
fmt.Sprintf("|%t", d.InsecureTLS)
|
||||
}
|
||||
return k
|
||||
}
|
||||
@@ -16653,13 +16809,55 @@ func (a *App) AmpOperate(id string, on bool) error {
|
||||
// Fan out here rather than in the UI: the card and the docked widget both
|
||||
// call this, and a coupling implemented in one of them would be missing from
|
||||
// the other — which on a combiner means one amplifier keyed and one not.
|
||||
var firstErr error
|
||||
for _, tid := range a.ampTargets(id, a.GetLinkedAmps()) {
|
||||
if err := a.ampOperateOne(tid, on); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
return a.ampFanOut(a.ampTargets(id, a.GetLinkedAmps()), func(tid string) error {
|
||||
return a.ampOperateOne(tid, on)
|
||||
})
|
||||
}
|
||||
|
||||
// ampFanOut runs one command against every amplifier AT THE SAME TIME.
|
||||
//
|
||||
// Sequentially, the second amplifier of a combined pair 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
|
||||
// complained about it, on every OFF and every ON.
|
||||
//
|
||||
// So each target gets a goroutine, and they are all parked on the same channel
|
||||
// until every one of them is ready. Closing it releases them together: 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.
|
||||
//
|
||||
// A single target — the ordinary case of one amplifier — runs inline. No
|
||||
// goroutine, no barrier, nothing to go wrong for the operators who have one amp.
|
||||
func (a *App) ampFanOut(targets []string, do func(id string) error) error {
|
||||
if len(targets) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(targets) == 1 {
|
||||
return do(targets[0])
|
||||
}
|
||||
errs := make([]error, len(targets))
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
for i, id := range targets {
|
||||
wg.Add(1)
|
||||
go func(i int, id string) {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
errs[i] = do(id)
|
||||
}(i, id)
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
// The amplifier the operator actually clicked is first, and its failure is
|
||||
// the one worth reporting: "the amp I pressed did not respond" beats the
|
||||
// same message about its silent partner.
|
||||
for _, err := range errs {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) ampOperateOne(id string, on bool) error {
|
||||
@@ -16687,15 +16885,11 @@ func (a *App) AmpPower(id string, on bool) (err error) {
|
||||
applog.Printf("amp %s: power %v failed: %v", id, on, err)
|
||||
}
|
||||
}()
|
||||
linked := a.GetLinkedAmps()
|
||||
targets := a.ampTargets(id, linked)
|
||||
var firstErr error
|
||||
for _, tid := range targets {
|
||||
if e := a.ampPowerOne(tid, on, len(targets) > 1); e != nil && firstErr == nil {
|
||||
firstErr = e
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
targets := a.ampTargets(id, a.GetLinkedAmps())
|
||||
multi := len(targets) > 1
|
||||
return a.ampFanOut(targets, func(tid string) error {
|
||||
return a.ampPowerOne(tid, on, multi)
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) ampPowerOne(id string, on, linked bool) error {
|
||||
@@ -16725,12 +16919,31 @@ func (a *App) ampPowerOne(id string, on, linked bool) error {
|
||||
}
|
||||
|
||||
// AmpPowerLevel selects the output power level — SPE only (L/M/H).
|
||||
//
|
||||
// Coupled like ON, OFF and OPERATE, which it was not: two combined amplifiers
|
||||
// left at different power levels feed the combiner unevenly, which is the thing
|
||||
// the coupling exists to prevent. It was simply the command nobody had linked.
|
||||
//
|
||||
// Running the pair concurrently matters more here than anywhere else. Setting a
|
||||
// level is not one command: an SPE has no "set level" at all, so the driver taps
|
||||
// the POWER key and waits for the amp to report the new level before tapping
|
||||
// again — up to three taps, up to two seconds each. One after the other, the two
|
||||
// amplifiers would sit at different levels for as long as six seconds.
|
||||
func (a *App) AmpPowerLevel(id, level string) error {
|
||||
inst := a.ampInstByID(id)
|
||||
if inst == nil || inst.spe == nil {
|
||||
return fmt.Errorf("power level is an SPE feature")
|
||||
}
|
||||
return inst.spe.SetPowerLevel(level)
|
||||
targets := a.ampTargets(id, a.GetLinkedAmps())
|
||||
multi := len(targets) > 1
|
||||
return a.ampFanOut(targets, func(tid string) error {
|
||||
inst := a.ampInstByID(tid)
|
||||
if inst == nil || inst.spe == nil {
|
||||
// A PowerGenius sitting in the group has no L/M/H, and saying so
|
||||
// would make a successful SPE pair look like a failure.
|
||||
if multi {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("power level is an SPE feature")
|
||||
}
|
||||
return inst.spe.SetPowerLevel(level)
|
||||
})
|
||||
}
|
||||
|
||||
// AmpFanMode sets the fan mode — PGXL only (STANDARD/CONTEST/BROADCAST).
|
||||
@@ -17559,16 +17772,26 @@ func (a *App) SendClusterCommand(cmd string) error {
|
||||
if cmd == "" {
|
||||
return fmt.Errorf("empty command")
|
||||
}
|
||||
servers, err := a.listClusterServers()
|
||||
srv, err := a.masterClusterServer()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.cluster.SendCommand(srv.ID, cmd)
|
||||
}
|
||||
|
||||
// masterClusterServer returns the master node — the first ENABLED server in
|
||||
// sort order, which is where commands and spots go.
|
||||
func (a *App) masterClusterServer() (cluster.ServerConfig, error) {
|
||||
servers, err := a.listClusterServers()
|
||||
if err != nil {
|
||||
return cluster.ServerConfig{}, err
|
||||
}
|
||||
for _, s := range servers {
|
||||
if s.Enabled {
|
||||
return a.cluster.SendCommand(s.ID, cmd)
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("no enabled cluster server to send to")
|
||||
return cluster.ServerConfig{}, fmt.Errorf("no enabled cluster server to send to")
|
||||
}
|
||||
|
||||
// SendClusterSpot announces a DX spot on the **master** cluster (first
|
||||
@@ -17590,8 +17813,26 @@ func (a *App) SendClusterSpot(call string, freqKHz float64, comment string) erro
|
||||
if c := strings.TrimSpace(comment); c != "" {
|
||||
cmd += " " + c
|
||||
}
|
||||
if a.cluster == nil {
|
||||
return fmt.Errorf("cluster not initialized")
|
||||
}
|
||||
srv, err := a.masterClusterServer()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
applog.Printf("cluster: send spot — freqKHz=%v → command %q", freqKHz, cmd)
|
||||
return a.SendClusterCommand(cmd)
|
||||
if err := a.cluster.SendCommand(srv.ID, cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
// Show it in OUR OWN spot list straight away. Most nodes never broadcast a
|
||||
// spot back to the station that sent it, so the operator saw nothing appear
|
||||
// and reported the spot as not sent — several times. It goes through the same
|
||||
// queue as a spot off the wire, so it gets the same DXCC/POTA enrichment,
|
||||
// alert evaluation and panadapter mirroring, and the UI de-dupes it against
|
||||
// the node's echo when there is one.
|
||||
sp := cluster.NewLocalSpot(srv, a.resolveClusterLogin(srv.LoginOverride), call, freqKHz, comment)
|
||||
a.enqueueClusterEvent(clusterEvent{spot: &sp})
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetClusterStatus returns a snapshot of every active session. Used by
|
||||
@@ -18164,6 +18405,18 @@ func (r catShareRig) Split() (bool, int64) {
|
||||
return true, st.FreqHz
|
||||
}
|
||||
|
||||
// RxFreq is where we LISTEN. Only the TCI server asks for it: TCI's channel A
|
||||
// is the receive frequency and channel B the transmit one, the opposite way
|
||||
// round from RigState, and a client handed these two the wrong way about would
|
||||
// transmit on the DX's own frequency.
|
||||
func (r catShareRig) RxFreq() int64 {
|
||||
st := r.a.cat.State()
|
||||
if st.Split && st.RxFreqHz > 0 {
|
||||
return st.RxFreqHz
|
||||
}
|
||||
return st.FreqHz
|
||||
}
|
||||
|
||||
func (r catShareRig) SetFreq(hz int64) error { return r.a.cat.SetFrequency(hz) }
|
||||
func (r catShareRig) SetMode(m string) error { return r.a.cat.SetMode(m) }
|
||||
func (r catShareRig) SetPTT(on bool) error { return r.a.cat.SetPTT(on) }
|
||||
@@ -18178,17 +18431,35 @@ func (r catShareRig) SetSplit(on bool, txHz int64) error {
|
||||
|
||||
// reloadCATShare starts, stops or restarts the sharing server to match the
|
||||
// settings. Called from reloadCAT so one "Save & Close" settles both.
|
||||
//
|
||||
// One server or the other, never both. rigctl and TCI are two ways of asking
|
||||
// the same radio the same questions; running both would only double the ways a
|
||||
// port clash or a confused client can go wrong, and no program speaks both.
|
||||
func (a *App) reloadCATShare(s CATSettings) {
|
||||
want := s.Enabled && s.ShareEnabled
|
||||
// Always tear down first: the port may have changed, and a listener bound to
|
||||
// the old one would keep answering while the client is told to use the new.
|
||||
// Always tear down first: the port — or the protocol — may have changed, and
|
||||
// a listener bound to the old one would keep answering while the client is
|
||||
// told to use the new.
|
||||
if a.catShare != nil {
|
||||
a.catShare.Stop()
|
||||
a.catShare = nil
|
||||
}
|
||||
if a.catShareTCI != nil {
|
||||
a.catShareTCI.Stop()
|
||||
a.catShareTCI = nil
|
||||
}
|
||||
if !want {
|
||||
return
|
||||
}
|
||||
if s.ShareProto == "tci" {
|
||||
srv := tciserver.New(s.ShareTCIPort, catShareRig{a: a}, applog.Printf)
|
||||
if err := srv.Start(); err != nil {
|
||||
applog.Printf("cat share: %v", err)
|
||||
return
|
||||
}
|
||||
a.catShareTCI = srv
|
||||
return
|
||||
}
|
||||
srv := rigctld.New(s.SharePort, catShareRig{a: a}, applog.Printf)
|
||||
if err := srv.Start(); err != nil {
|
||||
// The usual cause is another rigctld — or a previous OpsLog — already on
|
||||
|
||||
@@ -1,4 +1,40 @@
|
||||
[
|
||||
{
|
||||
"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 Japan’s civil prefecture numbers instead of the JARL’s: 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 qu’elle 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 d’un diplôme se renumérotent dans l’éditeur : le numéro était le seul champ qu’il refusait de corriger.",
|
||||
"La boussole se remplit dès l’ouverture de Station Control, au lieu d’attendre la fin d’un intervalle d’interrogation.",
|
||||
"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 : l’envoi à la fermeture part désormais en un lot — envoyer des centaines de contacts un par un faisait bloquer l’opérateur.",
|
||||
"eQSL : les envois partent aussi par lots de 100, et QRZ.com et HRDLog — qui n’ont pas d’envoi 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 l’accueil est « login: » puis qui réclame un mot de passe se connecte : les deux invites étaient ignorées."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.25.7",
|
||||
"date": "",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1403,7 +1403,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
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,
|
||||
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.
|
||||
@@ -2368,6 +2368,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 +2509,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>
|
||||
</>
|
||||
);
|
||||
@@ -3149,15 +3169,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. */}
|
||||
|
||||
@@ -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
@@ -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,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.8';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+2
@@ -861,6 +861,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>;
|
||||
|
||||
@@ -1662,6 +1662,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);
|
||||
}
|
||||
|
||||
@@ -2050,6 +2050,8 @@ export namespace main {
|
||||
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;
|
||||
@@ -2096,6 +2098,8 @@ export namespace main {
|
||||
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"];
|
||||
@@ -3375,6 +3379,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 +3399,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 {
|
||||
|
||||
+145
-144
@@ -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,48 +426,8 @@
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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] }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package cat
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -535,11 +536,36 @@ func (b *IcomSerial) SetMode(mode string) error {
|
||||
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")
|
||||
|
||||
// SetPTT keys or unkeys the transmitter (CI-V 0x1C 0x00), retrying ONCE when 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 immediately before every key-down, so the PTT ack queues behind that
|
||||
// traffic, and one lost ack was fatal: rigctld answered RPRT -9, JTDX read that
|
||||
// as losing rig control and tore the connection down mid-over, reopening it a
|
||||
// moment later (an operator's log shows exactly that, twice, a new rigctld client
|
||||
// within 300 ms of each failure). The same session over TCI never failed, because
|
||||
// TCI carries no CI-V and needs no Fake It.
|
||||
//
|
||||
// Re-sending is safe: asking for a state the rig is already in changes nothing.
|
||||
func (b *IcomSerial) SetPTT(on bool) error {
|
||||
state := byte(0)
|
||||
if on {
|
||||
state = 1
|
||||
}
|
||||
err := b.exec(civ.CmdPTT, civ.SubPTT, state)
|
||||
if err == nil || !errors.Is(err, errIcomAckLost) {
|
||||
return err
|
||||
}
|
||||
applog.Printf("icom: PTT %v — no acknowledgement in %s, sending it once more", on, icomCmdTimeout)
|
||||
return b.exec(civ.CmdPTT, civ.SubPTT, state)
|
||||
}
|
||||
|
||||
@@ -619,7 +645,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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:") ||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -432,11 +432,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 +454,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 +539,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",
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,620 @@
|
||||
// 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.
|
||||
func (s *Server) Stop() {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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.ptt, s.pttKnown = on, true
|
||||
s.mu.Unlock()
|
||||
if known && prev == on {
|
||||
s.broadcast(fmt.Sprintf("trx:0,%t;", on))
|
||||
return ""
|
||||
}
|
||||
if err := s.rig.SetPTT(on); err != nil {
|
||||
s.log("tci server: PTT %v refused: %v", on, err)
|
||||
return ""
|
||||
}
|
||||
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 ""
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
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
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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.8"
|
||||
|
||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||
// to https://us.i.posthog.com for a US project.
|
||||
|
||||
Reference in New Issue
Block a user