Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9599c3e0b9 | ||
|
|
a81125eab1 | ||
|
|
bbe1b3ce80 | ||
|
|
5e80c27f61 | ||
|
|
7be6f64596 | ||
|
|
0bab7f05b9 | ||
|
|
dc898ce2af | ||
|
|
e5c9ca1a7d | ||
|
|
21a0d560de | ||
|
|
2941121f4b | ||
|
|
6c75ff345b | ||
|
|
d5e25244ee | ||
|
|
4095455e66 | ||
|
|
3f95763ca6 | ||
|
|
72ec3cbb97 | ||
|
|
a8bca8c316 | ||
|
|
8b75166dbe |
+76
-1
@@ -1,6 +1,11 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
// The coupling is a SET, not a global switch.
|
// 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/spe"
|
||||||
"hamlog/internal/steppir"
|
"hamlog/internal/steppir"
|
||||||
"hamlog/internal/syncfolder"
|
"hamlog/internal/syncfolder"
|
||||||
|
"hamlog/internal/tciserver"
|
||||||
"hamlog/internal/tunergenius"
|
"hamlog/internal/tunergenius"
|
||||||
"hamlog/internal/uls"
|
"hamlog/internal/uls"
|
||||||
"hamlog/internal/ultrabeam"
|
"hamlog/internal/ultrabeam"
|
||||||
@@ -113,6 +114,7 @@ const (
|
|||||||
keyCATFlexHost = "cat.flex.host" // FlexRadio IP (native backend)
|
keyCATFlexHost = "cat.flex.host" // FlexRadio IP (native backend)
|
||||||
keyCATFlexPort = "cat.flex.port" // FlexRadio TCP port (default 4992)
|
keyCATFlexPort = "cat.flex.port" // FlexRadio TCP port (default 4992)
|
||||||
keyCATFlexSpots = "cat.flex.spots" // push cluster spots to the panadapter
|
keyCATFlexSpots = "cat.flex.spots" // push cluster spots to the panadapter
|
||||||
|
keyCATFlexDVKDax = "cat.flex.dvk_dax" // raise the transmit-bar DAX button while a voice message plays
|
||||||
keyCATFlexDecodeSpots = "cat.flex.decode_spots" // push WSJT-X decodes (heard stations) to the panadapter
|
keyCATFlexDecodeSpots = "cat.flex.decode_spots" // push WSJT-X decodes (heard stations) to the panadapter
|
||||||
keyCATFlexDecodeSecs = "cat.flex.decode_secs" // decode spot display duration (seconds) before auto-removal
|
keyCATFlexDecodeSecs = "cat.flex.decode_secs" // decode spot display duration (seconds) before auto-removal
|
||||||
keyCATPollMs = "cat.poll_ms"
|
keyCATPollMs = "cat.poll_ms"
|
||||||
@@ -120,6 +122,8 @@ const (
|
|||||||
keyCATDigitalDefault = "cat.digital_default" // mode to use when CAT reports DATA
|
keyCATDigitalDefault = "cat.digital_default" // mode to use when CAT reports DATA
|
||||||
keyCATShareEnabled = "cat.share.enabled" // expose CAT to other programs (Hamlib NET rigctl)
|
keyCATShareEnabled = "cat.share.enabled" // expose CAT to other programs (Hamlib NET rigctl)
|
||||||
keyCATSharePort = "cat.share.port" // TCP port for that server (rigctld default 4532)
|
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…)
|
keyCATXieguPort = "cat.xiegu.port" // Xiegu CI-V serial port (G90/X6100…)
|
||||||
keyCATXieguBaud = "cat.xiegu.baud" // Xiegu CI-V baud (G90 default 19200)
|
keyCATXieguBaud = "cat.xiegu.baud" // Xiegu CI-V baud (G90 default 19200)
|
||||||
keyCATXieguAddr = "cat.xiegu.addr" // Xiegu CI-V address (factory 0x70)
|
keyCATXieguAddr = "cat.xiegu.addr" // Xiegu CI-V address (factory 0x70)
|
||||||
@@ -242,6 +246,10 @@ const (
|
|||||||
keyMotorBandFreqs = "motor.band_freqs" // per-band tune frequency: "40m=7100,20m=14150"
|
keyMotorBandFreqs = "motor.band_freqs" // per-band tune frequency: "40m=7100,20m=14150"
|
||||||
keyChaseNewGrids = "cluster.chase_grids" // "1" → persist learnt locators across restarts
|
keyChaseNewGrids = "cluster.chase_grids" // "1" → persist learnt locators across restarts
|
||||||
keyRowColors = "appearance.row_colors"
|
keyRowColors = "appearance.row_colors"
|
||||||
|
keyRotorPresets = "rotator.presets" // quick-turn buttons on the rotor widget
|
||||||
|
keyCATOffsetHz = "cat.offset_hz" // transverter offset: real frequency − rig frequency
|
||||||
|
keyCATOffsetOn = "cat.offset_on" // "1" → apply the transverter offset
|
||||||
|
keyMatrixColors = "appearance.matrix_colors" // band/mode matrix palette overrides
|
||||||
keyMotorType = "ultrabeam.type" // "ultrabeam" | "steppir" (default ultrabeam)
|
keyMotorType = "ultrabeam.type" // "ultrabeam" | "steppir" (default ultrabeam)
|
||||||
keyMotorTransport = "ultrabeam.transport" // "tcp" | "serial" (default tcp)
|
keyMotorTransport = "ultrabeam.transport" // "tcp" | "serial" (default tcp)
|
||||||
keyMotorCOM = "ultrabeam.com" // serial device name (COM3, /dev/ttyUSB0)
|
keyMotorCOM = "ultrabeam.com" // serial device name (COM3, /dev/ttyUSB0)
|
||||||
@@ -423,6 +431,7 @@ type CATSettings struct {
|
|||||||
FlexHost string `json:"flex_host"` // FlexRadio IP (native backend)
|
FlexHost string `json:"flex_host"` // FlexRadio IP (native backend)
|
||||||
FlexPort int `json:"flex_port"` // FlexRadio TCP port (default 4992)
|
FlexPort int `json:"flex_port"` // FlexRadio TCP port (default 4992)
|
||||||
FlexSpots bool `json:"flex_spots"` // push cluster spots to the panadapter
|
FlexSpots bool `json:"flex_spots"` // push cluster spots to the panadapter
|
||||||
|
FlexDVKDax bool `json:"flex_dvk_dax"` // raise transmit DAX while a voice message plays, lower it after
|
||||||
FlexDecodeSpots bool `json:"flex_decode_spots"` // push WSJT-X decodes (heard stations) to the panadapter
|
FlexDecodeSpots bool `json:"flex_decode_spots"` // push WSJT-X decodes (heard stations) to the panadapter
|
||||||
FlexDecodeSecs int `json:"flex_decode_secs"` // decode spot display duration (s) before removal (default 120)
|
FlexDecodeSecs int `json:"flex_decode_secs"` // decode spot display duration (s) before removal (default 120)
|
||||||
XieguPort string `json:"xiegu_port"` // Xiegu CI-V serial port (G90/X6100…)
|
XieguPort string `json:"xiegu_port"` // Xiegu CI-V serial port (G90/X6100…)
|
||||||
@@ -456,9 +465,19 @@ type CATSettings struct {
|
|||||||
TCISpots bool `json:"tci_spots"` // push cluster spots to the TCI panorama
|
TCISpots bool `json:"tci_spots"` // push cluster spots to the TCI panorama
|
||||||
PollMs int `json:"poll_ms"` // poll interval in ms (default 250)
|
PollMs int `json:"poll_ms"` // poll interval in ms (default 250)
|
||||||
DelayMs int `json:"delay_ms"` // pause between commands (default 0)
|
DelayMs int `json:"delay_ms"` // pause between commands (default 0)
|
||||||
|
// Transverter offset: real frequency − rig frequency, in Hz. A 28 MHz IF
|
||||||
|
// driving a 144 MHz transverter is +116000000. Kept as a separate on/off so
|
||||||
|
// the number survives switching the transverter out of line for an evening.
|
||||||
|
OffsetOn bool `json:"offset_on"`
|
||||||
|
OffsetHz int64 `json:"offset_hz"`
|
||||||
DigitalDefault string `json:"digital_default"` // when CAT says DATA, surface this mode (FT8/FT4/RTTY/…)
|
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)
|
ShareEnabled bool `json:"share_enabled"` // serve CAT to other programs
|
||||||
SharePort int `json:"share_port"` // TCP port for it (default 4532)
|
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
|
// PTT hotkey — a keyboard key that keys the transmitter while OpsLog is
|
||||||
// focused (hold-to-talk, or toggle). Uses the configured Audio → PTT method,
|
// focused (hold-to-talk, or toggle). Uses the configured Audio → PTT method,
|
||||||
// falling back to CAT keying when that is VOX/none.
|
// falling back to CAT keying when that is VOX/none.
|
||||||
@@ -596,6 +615,10 @@ type App struct {
|
|||||||
// port: without it, choosing native CAT locks WSJT-X and friends out of the
|
// port: without it, choosing native CAT locks WSJT-X and friends out of the
|
||||||
// radio entirely. nil when the operator has not enabled sharing.
|
// radio entirely. nil when the operator has not enabled sharing.
|
||||||
catShare *rigctld.Server
|
catShare *rigctld.Server
|
||||||
|
// 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
|
dxcc *dxcc.Manager
|
||||||
cluster *cluster.Manager
|
cluster *cluster.Manager
|
||||||
// Cluster spots/lines are processed OFF the socket-read goroutine. Enriching a
|
// Cluster spots/lines are processed OFF the socket-read goroutine. Enriching a
|
||||||
@@ -727,6 +750,7 @@ type App struct {
|
|||||||
startupProfile string // --profile <name> from the command line (activate at startup)
|
startupProfile string // --profile <name> from the command line (activate at startup)
|
||||||
dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord)
|
dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord)
|
||||||
dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends
|
dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends
|
||||||
|
flexDaxRaised bool // we pressed the Flex transmit DAX button for it; restore when it ends
|
||||||
pttMu sync.Mutex
|
pttMu sync.Mutex
|
||||||
// confDLMu/confDLCancel cancel the in-flight confirmation download (LoTW/QRZ)
|
// confDLMu/confDLCancel cancel the in-flight confirmation download (LoTW/QRZ)
|
||||||
// so closing the QSL Manager — or starting another download — stops the previous
|
// so closing the QSL Manager — or starting another download — stops the previous
|
||||||
@@ -761,6 +785,7 @@ type App struct {
|
|||||||
offlineMode bool // last write failed because the DB was unreachable
|
offlineMode bool // last write failed because the DB was unreachable
|
||||||
|
|
||||||
catFlexSpots bool // push cluster spots to the FlexRadio panadapter
|
catFlexSpots bool // push cluster spots to the FlexRadio panadapter
|
||||||
|
catFlexDVKDax bool // raise the Flex transmit DAX button around a voice message
|
||||||
catFlexDecodeSpots bool // push WSJT-X decodes (heard stations) to the panadapter
|
catFlexDecodeSpots bool // push WSJT-X decodes (heard stations) to the panadapter
|
||||||
catFlexDecodeSecs int // decode spot display duration (seconds)
|
catFlexDecodeSecs int // decode spot display duration (seconds)
|
||||||
liveActMu sync.Mutex // guards the entry-strip activity reported for live status
|
liveActMu sync.Mutex // guards the entry-strip activity reported for live status
|
||||||
@@ -1387,6 +1412,10 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
if keyed {
|
if keyed {
|
||||||
go a.dvkUnkeyPTT(gen)
|
go a.dvkUnkeyPTT(gen)
|
||||||
}
|
}
|
||||||
|
// And give the microphone back, if we took it (see raiseFlexDVKDax).
|
||||||
|
// Off the callback's goroutine: this talks to the radio, and the
|
||||||
|
// audio manager is reporting a state change, not waiting on us.
|
||||||
|
go a.lowerFlexDVKDax()
|
||||||
}
|
}
|
||||||
if a.ctx != nil {
|
if a.ctx != nil {
|
||||||
wruntime.EventsEmit(a.ctx, "audio:status", st)
|
wruntime.EventsEmit(a.ctx, "audio:status", st)
|
||||||
@@ -1691,6 +1720,10 @@ func (a *App) shutdown(ctx context.Context) {
|
|||||||
a.catShare.Stop()
|
a.catShare.Stop()
|
||||||
a.catShare = nil
|
a.catShare = nil
|
||||||
}
|
}
|
||||||
|
if a.catShareTCI != nil {
|
||||||
|
a.catShareTCI.Stop()
|
||||||
|
a.catShareTCI = nil
|
||||||
|
}
|
||||||
if a.cat != nil {
|
if a.cat != nil {
|
||||||
a.cat.Stop()
|
a.cat.Stop()
|
||||||
}
|
}
|
||||||
@@ -2654,9 +2687,15 @@ func (a *App) reloadLookupProviders() {
|
|||||||
fmt.Println("OpsLog: settings load error:", err)
|
fmt.Println("OpsLog: settings load error:", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if days, _ := strconv.Atoi(m[keyCacheTTL]); days > 0 {
|
// 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)
|
a.cache.SetTTL(time.Duration(days) * 24 * time.Hour)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
build := func(name string) lookup.Provider {
|
build := func(name string) lookup.Provider {
|
||||||
switch name {
|
switch name {
|
||||||
@@ -5218,6 +5257,37 @@ func (a *App) SaveAwardReference(code string, ref awardref.Ref) error {
|
|||||||
}
|
}
|
||||||
a.markAwardEdited(code)
|
a.markAwardEdited(code)
|
||||||
a.mirrorAwards()
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7237,6 +7307,19 @@ func (a *App) lookupCallsign(callsign string, force bool, qsoDate string) (looku
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
a.enrichFromULS(&r, callsign)
|
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
|
// 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 —
|
// 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
|
// two answers for one callsign is how a rotator ends up pointed elsewhere
|
||||||
@@ -7338,9 +7421,13 @@ func (a *App) GetLookupSettings() (LookupSettings, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return LookupSettings{}, err
|
return LookupSettings{}, err
|
||||||
}
|
}
|
||||||
ttl, _ := strconv.Atoi(m[keyCacheTTL])
|
// Same rule as reloadLookupProviders: blank means "never set" and gets the
|
||||||
if ttl <= 0 {
|
// default, while a stored zero is the operator asking for no cache at all.
|
||||||
ttl = 30
|
ttl := 30
|
||||||
|
if raw := strings.TrimSpace(m[keyCacheTTL]); raw != "" {
|
||||||
|
if n, err := strconv.Atoi(raw); err == nil && n >= 0 {
|
||||||
|
ttl = n
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return LookupSettings{
|
return LookupSettings{
|
||||||
QRZUser: m[keyQRZUser],
|
QRZUser: m[keyQRZUser],
|
||||||
@@ -7360,8 +7447,9 @@ func (a *App) SaveLookupSettings(s LookupSettings) error {
|
|||||||
if a.settings == nil {
|
if a.settings == nil {
|
||||||
return fmt.Errorf("db not initialized")
|
return fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
if s.CacheTTLDays <= 0 {
|
// Zero is kept — it means no cache. Only a negative number is nonsense.
|
||||||
s.CacheTTLDays = 30
|
if s.CacheTTLDays < 0 {
|
||||||
|
s.CacheTTLDays = 0
|
||||||
}
|
}
|
||||||
// Reject a primary == failsafe routing combo — would just hit the same
|
// Reject a primary == failsafe routing combo — would just hit the same
|
||||||
// provider twice. Frontend should prevent this but defend in depth.
|
// provider twice. Frontend should prevent this but defend in depth.
|
||||||
@@ -7436,7 +7524,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
|||||||
if a.settings == nil {
|
if a.settings == nil {
|
||||||
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
|
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, keyCATFlexDVKDax, 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, keyCATOffsetOn, keyCATOffsetHz, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort, keyCATShareProto, keyCATShareTCIPort)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return CATSettings{}, err
|
return CATSettings{}, err
|
||||||
}
|
}
|
||||||
@@ -7447,6 +7535,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
|||||||
FlexHost: m[keyCATFlexHost],
|
FlexHost: m[keyCATFlexHost],
|
||||||
FlexPort: 4992,
|
FlexPort: 4992,
|
||||||
FlexSpots: m[keyCATFlexSpots] == "1",
|
FlexSpots: m[keyCATFlexSpots] == "1",
|
||||||
|
FlexDVKDax: m[keyCATFlexDVKDax] == "1",
|
||||||
FlexDecodeSpots: m[keyCATFlexDecodeSpots] == "1",
|
FlexDecodeSpots: m[keyCATFlexDecodeSpots] == "1",
|
||||||
FlexDecodeSecs: 120,
|
FlexDecodeSecs: 120,
|
||||||
XieguPort: m[keyCATXieguPort],
|
XieguPort: m[keyCATXieguPort],
|
||||||
@@ -7475,13 +7564,21 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
|||||||
PTTHotkeyToggle: m[keyCATPttHotkeyToggle] == "1",
|
PTTHotkeyToggle: m[keyCATPttHotkeyToggle] == "1",
|
||||||
PollMs: 250,
|
PollMs: 250,
|
||||||
DelayMs: 0,
|
DelayMs: 0,
|
||||||
|
OffsetOn: m[keyCATOffsetOn] == "1",
|
||||||
DigitalDefault: m[keyCATDigitalDefault],
|
DigitalDefault: m[keyCATDigitalDefault],
|
||||||
ShareEnabled: m[keyCATShareEnabled] == "1",
|
ShareEnabled: m[keyCATShareEnabled] == "1",
|
||||||
SharePort: 4532,
|
SharePort: 4532,
|
||||||
|
ShareProto: "rigctl",
|
||||||
|
ShareTCIPort: tciserver.DefaultPort,
|
||||||
}
|
}
|
||||||
if n, _ := strconv.Atoi(m[keyCATFlexPort]); n > 0 && n <= 65535 {
|
if n, _ := strconv.Atoi(m[keyCATFlexPort]); n > 0 && n <= 65535 {
|
||||||
out.FlexPort = n
|
out.FlexPort = n
|
||||||
}
|
}
|
||||||
|
// Signed: a transverter can sit either side of its IF (a 144 MHz IF feeding a
|
||||||
|
// 28 MHz receive converter is a negative offset).
|
||||||
|
if n, err := strconv.ParseInt(strings.TrimSpace(m[keyCATOffsetHz]), 10, 64); err == nil {
|
||||||
|
out.OffsetHz = n
|
||||||
|
}
|
||||||
if n, _ := strconv.Atoi(m[keyCATFlexDecodeSecs]); n >= 10 && n <= 3600 {
|
if n, _ := strconv.Atoi(m[keyCATFlexDecodeSecs]); n >= 10 && n <= 3600 {
|
||||||
out.FlexDecodeSecs = n
|
out.FlexDecodeSecs = n
|
||||||
}
|
}
|
||||||
@@ -7491,6 +7588,12 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
|||||||
if n, _ := strconv.Atoi(m[keyCATSharePort]); n > 0 && n <= 65535 {
|
if n, _ := strconv.Atoi(m[keyCATSharePort]); n > 0 && n <= 65535 {
|
||||||
out.SharePort = n
|
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 {
|
if n, _ := strconv.Atoi(m[keyCATXieguBaud]); n > 0 {
|
||||||
out.XieguBaud = n
|
out.XieguBaud = n
|
||||||
}
|
}
|
||||||
@@ -7548,6 +7651,12 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
|||||||
if s.SharePort <= 0 || s.SharePort > 65535 {
|
if s.SharePort <= 0 || s.SharePort > 65535 {
|
||||||
s.SharePort = 4532
|
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 {
|
if s.XieguBaud <= 0 {
|
||||||
s.XieguBaud = 19200
|
s.XieguBaud = 19200
|
||||||
}
|
}
|
||||||
@@ -7580,6 +7689,14 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
|||||||
if s.FlexSpots {
|
if s.FlexSpots {
|
||||||
flexSpots = "1"
|
flexSpots = "1"
|
||||||
}
|
}
|
||||||
|
flexDVKDax := "0"
|
||||||
|
if s.FlexDVKDax {
|
||||||
|
flexDVKDax = "1"
|
||||||
|
}
|
||||||
|
offsetOn := "0"
|
||||||
|
if s.OffsetOn {
|
||||||
|
offsetOn = "1"
|
||||||
|
}
|
||||||
flexDecodeSpots := "0"
|
flexDecodeSpots := "0"
|
||||||
if s.FlexDecodeSpots {
|
if s.FlexDecodeSpots {
|
||||||
flexDecodeSpots = "1"
|
flexDecodeSpots = "1"
|
||||||
@@ -7616,6 +7733,7 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
|||||||
keyCATFlexHost: strings.TrimSpace(s.FlexHost),
|
keyCATFlexHost: strings.TrimSpace(s.FlexHost),
|
||||||
keyCATFlexPort: strconv.Itoa(s.FlexPort),
|
keyCATFlexPort: strconv.Itoa(s.FlexPort),
|
||||||
keyCATFlexSpots: flexSpots,
|
keyCATFlexSpots: flexSpots,
|
||||||
|
keyCATFlexDVKDax: flexDVKDax,
|
||||||
keyCATFlexDecodeSpots: flexDecodeSpots,
|
keyCATFlexDecodeSpots: flexDecodeSpots,
|
||||||
keyCATFlexDecodeSecs: strconv.Itoa(s.FlexDecodeSecs),
|
keyCATFlexDecodeSecs: strconv.Itoa(s.FlexDecodeSecs),
|
||||||
keyCATXieguPort: strings.TrimSpace(s.XieguPort),
|
keyCATXieguPort: strings.TrimSpace(s.XieguPort),
|
||||||
@@ -7645,9 +7763,13 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
|||||||
keyCATPttHotkeyToggle: b01(s.PTTHotkeyToggle),
|
keyCATPttHotkeyToggle: b01(s.PTTHotkeyToggle),
|
||||||
keyCATPollMs: strconv.Itoa(s.PollMs),
|
keyCATPollMs: strconv.Itoa(s.PollMs),
|
||||||
keyCATDelayMs: strconv.Itoa(s.DelayMs),
|
keyCATDelayMs: strconv.Itoa(s.DelayMs),
|
||||||
|
keyCATOffsetOn: offsetOn,
|
||||||
|
keyCATOffsetHz: strconv.FormatInt(s.OffsetHz, 10),
|
||||||
keyCATDigitalDefault: strings.ToUpper(strings.TrimSpace(s.DigitalDefault)),
|
keyCATDigitalDefault: strings.ToUpper(strings.TrimSpace(s.DigitalDefault)),
|
||||||
keyCATShareEnabled: shareEnabled,
|
keyCATShareEnabled: shareEnabled,
|
||||||
keyCATSharePort: strconv.Itoa(s.SharePort),
|
keyCATSharePort: strconv.Itoa(s.SharePort),
|
||||||
|
keyCATShareProto: s.ShareProto,
|
||||||
|
keyCATShareTCIPort: strconv.Itoa(s.ShareTCIPort),
|
||||||
} {
|
} {
|
||||||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -8503,6 +8625,9 @@ func (a *App) QSOAudioPlayOnAir() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cfg := cfgEarly
|
cfg := cfgEarly
|
||||||
|
// Same as the voice keyer: this goes out over the air, so on a Flex the
|
||||||
|
// transmit source has to be DAX before the carrier comes up.
|
||||||
|
a.raiseFlexDVKDax()
|
||||||
if err := a.pttKey(cfg); err != nil {
|
if err := a.pttKey(cfg); err != nil {
|
||||||
applog.Printf("qso-rec: PTT on failed before playback: %v", err)
|
applog.Printf("qso-rec: PTT on failed before playback: %v", err)
|
||||||
// Keep going — the audio still reaches the rig and the operator may use VOX.
|
// Keep going — the audio still reaches the rig and the operator may use VOX.
|
||||||
@@ -8521,6 +8646,7 @@ func (a *App) QSOAudioPlayOnAir() error {
|
|||||||
if keyed {
|
if keyed {
|
||||||
go a.dvkUnkeyPTT(gen)
|
go a.dvkUnkeyPTT(gen)
|
||||||
}
|
}
|
||||||
|
a.lowerFlexDVKDax() // nothing went out — give the microphone straight back
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
applog.Printf("qso-rec: playing the recording on the air (%d bytes)", len(pcm))
|
applog.Printf("qso-rec: playing the recording on the air (%d bytes)", len(pcm))
|
||||||
@@ -9441,6 +9567,10 @@ func (a *App) DVKPlay(slot int) error {
|
|||||||
return fmt.Errorf("no recording in slot %d", slot)
|
return fmt.Errorf("no recording in slot %d", slot)
|
||||||
}
|
}
|
||||||
cfg, _ := a.GetAudioSettings()
|
cfg, _ := a.GetAudioSettings()
|
||||||
|
// Before PTT: on a Flex the transmit audio comes from the microphone or from
|
||||||
|
// DAX, so the source has to be right before the carrier goes up — otherwise
|
||||||
|
// the first syllables are transmitted as silence.
|
||||||
|
a.raiseFlexDVKDax()
|
||||||
if err := a.pttKey(cfg); err != nil {
|
if err := a.pttKey(cfg); err != nil {
|
||||||
applog.Printf("dvk: PTT on failed: %v", err)
|
applog.Printf("dvk: PTT on failed: %v", err)
|
||||||
// Keep going — the audio still reaches the rig; the user may use VOX.
|
// Keep going — the audio still reaches the rig; the user may use VOX.
|
||||||
@@ -9458,11 +9588,77 @@ func (a *App) DVKPlay(slot int) error {
|
|||||||
if keyed {
|
if keyed {
|
||||||
go a.dvkUnkeyPTT(gen)
|
go a.dvkUnkeyPTT(gen)
|
||||||
}
|
}
|
||||||
|
// Nothing is going out, so the microphone must come straight back — the
|
||||||
|
// status callback that normally does it never fires for a play that
|
||||||
|
// never started.
|
||||||
|
a.lowerFlexDVKDax()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// raiseFlexDVKDax presses SmartSDR's transmit-bar DAX button before a voice
|
||||||
|
// message goes out, and remembers whether it was already down.
|
||||||
|
//
|
||||||
|
// On a FlexRadio the transmit audio comes from EITHER the microphone or DAX,
|
||||||
|
// never both. A voice keyer plays into the DAX TX stream, so with the button up
|
||||||
|
// the message is transmitted as silence; with it left down afterwards the
|
||||||
|
// operator's own microphone is dead. Pressing it by hand around every message is
|
||||||
|
// exactly the kind of thing that gets forgotten mid-pileup, hence the option.
|
||||||
|
//
|
||||||
|
// The PREVIOUS state is what gets restored, not a blanket "off": an operator
|
||||||
|
// running WSJT-X has DAX down on purpose, and lowering it for them after a voice
|
||||||
|
// message would break the digital setup to solve a problem they do not have. In
|
||||||
|
// the case this option exists for — microphone operating, DAX up — restoring the
|
||||||
|
// previous state is precisely "give me my microphone back".
|
||||||
|
//
|
||||||
|
// Silent no-op when the option is off or the radio is not a Flex: this sits on
|
||||||
|
// the voice-keyer path, which must not fail because a rig cannot do DAX.
|
||||||
|
func (a *App) raiseFlexDVKDax() {
|
||||||
|
if a.cat == nil || !a.catFlexDVKDax {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var was bool
|
||||||
|
err := a.cat.FlexDo(func(fc cat.FlexController) error {
|
||||||
|
was = fc.FlexState().TXDAX
|
||||||
|
if was {
|
||||||
|
return nil // already down — nothing to press, nothing to restore
|
||||||
|
}
|
||||||
|
return fc.SetTXDAX(true)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("dvk: could not raise the Flex TX DAX button: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if was {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.pttMu.Lock()
|
||||||
|
a.flexDaxRaised = true
|
||||||
|
a.pttMu.Unlock()
|
||||||
|
applog.Printf("dvk: Flex TX DAX on for the voice message")
|
||||||
|
}
|
||||||
|
|
||||||
|
// lowerFlexDVKDax puts the transmit-bar DAX button back where it was, and only
|
||||||
|
// when raiseFlexDVKDax actually moved it. Called where the voice-message PTT is
|
||||||
|
// released, so the microphone comes back at the moment the message ends.
|
||||||
|
func (a *App) lowerFlexDVKDax() {
|
||||||
|
a.pttMu.Lock()
|
||||||
|
raised := a.flexDaxRaised
|
||||||
|
a.flexDaxRaised = false
|
||||||
|
a.pttMu.Unlock()
|
||||||
|
if !raised || a.cat == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetTXDAX(false) }); err != nil {
|
||||||
|
// Worth saying loudly: the microphone stays dead until it is pressed by
|
||||||
|
// hand, and an operator whose voice is not going out has no other clue.
|
||||||
|
applog.Printf("dvk: could not lower the Flex TX DAX button — the microphone stays off air until you press it in SmartSDR: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
applog.Printf("dvk: Flex TX DAX off — microphone back on air")
|
||||||
|
}
|
||||||
|
|
||||||
// dvkUnkeyPTT releases PTT after a short tail so the rig doesn't clip the end
|
// dvkUnkeyPTT releases PTT after a short tail so the rig doesn't clip the end
|
||||||
// of the message — but ONLY if no newer key happened since (gen unchanged). A
|
// of the message — but ONLY if no newer key happened since (gen unchanged). A
|
||||||
// rapid replay (or a Test PTT) starts a fresh transmission whose key must not
|
// rapid replay (or a Test PTT) starts a fresh transmission whose key must not
|
||||||
@@ -10289,6 +10485,15 @@ func (a *App) TestCloudlogUpload() (string, error) {
|
|||||||
// ── QSL Manager (manual upload) ────────────────────────────────────────
|
// ── QSL Manager (manual upload) ────────────────────────────────────────
|
||||||
|
|
||||||
// uploadColumnFor maps a service id to its QSO sent-status column.
|
// 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 {
|
func uploadColumnFor(service string) string {
|
||||||
switch extsvc.Service(service) {
|
switch extsvc.Service(service) {
|
||||||
case extsvc.ServiceQRZ:
|
case extsvc.ServiceQRZ:
|
||||||
@@ -10383,10 +10588,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"
|
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"
|
statusCol, dateCol = "hrdlog_qso_upload_status", "hrdlog_qso_upload_date"
|
||||||
|
case extsvc.ServiceEQSL:
|
||||||
|
statusCol, dateCol = "eqsl_sent", "eqsl_sent_date"
|
||||||
}
|
}
|
||||||
type item struct {
|
type item struct {
|
||||||
id int64
|
id int64
|
||||||
@@ -10450,10 +10658,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, ", "))
|
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 {
|
} else {
|
||||||
// HRDLog's NewEntry.aspx inserts only the FIRST record of a multi-
|
// HRDLog's NewEntry.aspx inserts only the FIRST record of a multi-
|
||||||
// record ADIF, so upload ONE record per request. The DB stays cheap:
|
// record ADIF, so upload ONE record per request. The DB stays cheap:
|
||||||
// bulk fetch above + the marks flushed in batches (not one per QSO).
|
// 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)))
|
emit(fmt.Sprintf("HRDLog: uploading %d QSO(s) (one request each)…", len(items)))
|
||||||
var doneIDs []int64
|
var doneIDs []int64
|
||||||
flush := func() {
|
flush := func() {
|
||||||
@@ -10466,6 +10726,9 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
|
|||||||
doneIDs = doneIDs[:0]
|
doneIDs = doneIDs[:0]
|
||||||
}
|
}
|
||||||
for i, it := range items {
|
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)
|
res, err := extsvc.UploadHRDLog(ctx, nil, cfg.HRDLog.Callsign, cfg.HRDLog.Code, it.rec)
|
||||||
if err == nil && res.OK {
|
if err == nil && res.OK {
|
||||||
doneIDs = append(doneIDs, it.id)
|
doneIDs = append(doneIDs, it.id)
|
||||||
@@ -10487,34 +10750,26 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
|
|||||||
flush()
|
flush()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// QRZ.com: one record per request (its logbook API has no batch upload).
|
// QRZ.com: one record per request (its logbook API has no batch upload),
|
||||||
for _, id := range ids {
|
// 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)
|
q, gerr := a.qso.GetByID(ctx, id)
|
||||||
call := ""
|
call := ""
|
||||||
if gerr == nil {
|
if gerr == nil {
|
||||||
call = q.Callsign
|
call = q.Callsign
|
||||||
}
|
}
|
||||||
force := ""
|
// QRZ rewrites STATION_CALLSIGN to the registered call.
|
||||||
if svc == extsvc.ServiceQRZ {
|
rec, ok := a.buildUploadADIF(id, cfg.QRZ.ForceStationCallsign)
|
||||||
force = cfg.QRZ.ForceStationCallsign
|
|
||||||
}
|
|
||||||
rec, ok := a.buildUploadADIF(id, force)
|
|
||||||
if !ok {
|
if !ok {
|
||||||
emit(call + " — skipped (no record)")
|
emit(call + " — skipped (no record)")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
var res extsvc.UploadResult
|
// Only QRZ reaches this branch: LoTW, Club Log, HRDLog and eQSL are
|
||||||
var err error
|
// all handled above, and UploadQSOsManual rejects anything else.
|
||||||
switch svc {
|
res, err := extsvc.UploadQRZ(ctx, nil, cfg.QRZ.APIKey, rec)
|
||||||
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)
|
|
||||||
}
|
|
||||||
if err == nil && res.OK {
|
if err == nil && res.OK {
|
||||||
a.markExtUploaded(svc, id, "")
|
a.markExtUploaded(svc, id, "")
|
||||||
uploaded++
|
uploaded++
|
||||||
@@ -11097,6 +11352,12 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service,
|
|||||||
done(matched, total)
|
done(matched, total)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Second pass, at mode-CLASS granularity: LoTW answers with the mode
|
||||||
|
// GROUP ("DATA"), never the submode it was given, so an FT4/FT8/FT2
|
||||||
|
// contact can never match on the exact string and the operator is told
|
||||||
|
// their own QSO is missing from their log.
|
||||||
|
classIDs, _ := a.qso.DedupeClassKeyIDs(ctx)
|
||||||
|
byClass := 0
|
||||||
// Snapshot award-valid confirmations (LoTW + paper QSL — the only two
|
// Snapshot award-valid confirmations (LoTW + paper QSL — the only two
|
||||||
// that count for ARRL awards) so each incoming one is flagged NEW.
|
// that count for ARRL awards) so each incoming one is flagged NEW.
|
||||||
sets, _ := a.qso.ConfirmedSlots(ctx, []string{"lotw_rcvd", "qsl_rcvd"})
|
sets, _ := a.qso.ConfirmedSlots(ctx, []string{"lotw_rcvd", "qsl_rcvd"})
|
||||||
@@ -11116,8 +11377,20 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service,
|
|||||||
date = time.Now().UTC().Format("20060102")
|
date = time.Now().UTC().Format("20060102")
|
||||||
}
|
}
|
||||||
a.enrichContactedFromCty(&q) // country/dxcc/zones from cty.dat
|
a.enrichContactedFromCty(&q) // country/dxcc/zones from cty.dat
|
||||||
key := qso.DedupeKey(q.Callsign, q.QSODate.UTC().Format("2006-01-02T15:04"), q.Band, q.Mode)
|
minute := q.QSODate.UTC().Format("2006-01-02T15:04")
|
||||||
if id, found := keyIDs[key]; found {
|
key := qso.DedupeKey(q.Callsign, minute, q.Band, q.Mode)
|
||||||
|
classKey := qso.DedupeClassKey(q.Callsign, minute, q.Band, q.Mode)
|
||||||
|
id, found := keyIDs[key]
|
||||||
|
if !found {
|
||||||
|
// The mode differs but the station, minute, band and mode class
|
||||||
|
// agree — the same contact under LoTW's group name. An ambiguous
|
||||||
|
// class key (id 0) is deliberately NOT taken: see DedupeClassKeyIDs.
|
||||||
|
if cid, ok := classIDs[classKey]; ok && cid != 0 {
|
||||||
|
id, found = cid, true
|
||||||
|
byClass++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found {
|
||||||
if e := a.qso.MarkLoTWConfirmed(ctx, id, date); e == nil {
|
if e := a.qso.MarkLoTWConfirmed(ctx, id, date); e == nil {
|
||||||
matched++
|
matched++
|
||||||
}
|
}
|
||||||
@@ -11127,6 +11400,7 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service,
|
|||||||
q.LOTWRcvdDate = date
|
q.LOTWRcvdDate = date
|
||||||
if newID, e := a.qso.Add(ctx, q); e == nil {
|
if newID, e := a.qso.Add(ctx, q); e == nil {
|
||||||
keyIDs[key] = newID // guard against dup records in the report
|
keyIDs[key] = newID // guard against dup records in the report
|
||||||
|
classIDs[classKey] = newID
|
||||||
added++
|
added++
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -11177,6 +11451,14 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service,
|
|||||||
} else {
|
} else {
|
||||||
emit(fmt.Sprintf("Matched %d of %d confirmed QSO(s)", matched, total))
|
emit(fmt.Sprintf("Matched %d of %d confirmed QSO(s)", matched, total))
|
||||||
}
|
}
|
||||||
|
if byClass > 0 {
|
||||||
|
// Said out loud rather than folded silently into the total: these
|
||||||
|
// matched on the mode CLASS, not the mode. LoTW hands back "DATA" for
|
||||||
|
// every digital submode, so this is the normal case for FT4/FT8 — but
|
||||||
|
// an operator reconciling a log deserves to know the match was looser
|
||||||
|
// than call+minute+band+mode.
|
||||||
|
emit(fmt.Sprintf(" (%d matched on mode class — LoTW reports the group, e.g. DATA, where your log has the exact mode)", byClass))
|
||||||
|
}
|
||||||
// Surface confirmations with no local match so the user sees WHICH one
|
// Surface confirmations with no local match so the user sees WHICH one
|
||||||
// and why (time off by a minute, FT4 logged as MFSK, portable call, or
|
// and why (time off by a minute, FT4 logged as MFSK, portable call, or
|
||||||
// never logged). Tick "Add not-found" to import them instead.
|
// never logged). Tick "Add not-found" to import them instead.
|
||||||
@@ -13867,9 +14149,18 @@ func (a *App) reloadCAT() {
|
|||||||
}
|
}
|
||||||
a.cat.SetPollInterval(time.Duration(s.PollMs) * time.Millisecond)
|
a.cat.SetPollInterval(time.Duration(s.PollMs) * time.Millisecond)
|
||||||
a.cat.SetCommandDelay(time.Duration(s.DelayMs) * time.Millisecond)
|
a.cat.SetCommandDelay(time.Duration(s.DelayMs) * time.Millisecond)
|
||||||
|
// Transverter offset. Zero when switched off — the manager treats "no offset"
|
||||||
|
// and "disabled" as the same thing, so the number stays in the settings for
|
||||||
|
// the next time the transverter goes back in line.
|
||||||
|
if s.OffsetOn {
|
||||||
|
a.cat.SetFreqOffset(s.OffsetHz)
|
||||||
|
} else {
|
||||||
|
a.cat.SetFreqOffset(0)
|
||||||
|
}
|
||||||
a.catFlexSpots = s.Enabled && ((s.Backend == "flex" && s.FlexSpots) || (s.Backend == "tci" && s.TCISpots))
|
a.catFlexSpots = s.Enabled && ((s.Backend == "flex" && s.FlexSpots) || (s.Backend == "tci" && s.TCISpots))
|
||||||
a.catFlexDecodeSpots = s.Enabled && s.Backend == "flex" && s.FlexDecodeSpots
|
a.catFlexDecodeSpots = s.Enabled && s.Backend == "flex" && s.FlexDecodeSpots
|
||||||
a.catFlexDecodeSecs = s.FlexDecodeSecs
|
a.catFlexDecodeSecs = s.FlexDecodeSecs
|
||||||
|
a.catFlexDVKDax = s.Enabled && s.Backend == "flex" && s.FlexDVKDax
|
||||||
a.reloadCATShare(s)
|
a.reloadCATShare(s)
|
||||||
if !s.Enabled {
|
if !s.Enabled {
|
||||||
a.cat.Stop()
|
a.cat.Stop()
|
||||||
@@ -14749,6 +15040,115 @@ func (a *App) RotatorStop() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RotorPreset is one quick-turn button on the rotor widget: a short label and
|
||||||
|
// the azimuth it swings to.
|
||||||
|
type RotorPreset struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
Azimuth int `json:"azimuth"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// rotorPresetMax bounds both the number of buttons and the label length. The
|
||||||
|
// widget lays them out in a fixed grid beside the dial, and a label that does not
|
||||||
|
// fit is a button whose meaning is guessed rather than read.
|
||||||
|
const (
|
||||||
|
rotorPresetMax = 8
|
||||||
|
rotorPresetLabelMax = 6
|
||||||
|
)
|
||||||
|
|
||||||
|
// rotorPresetRegions are the out-of-the-box buttons, each with a representative
|
||||||
|
// point rather than a ready-made azimuth.
|
||||||
|
//
|
||||||
|
// The bearing is COMPUTED from the operator's own square. A bearing is only
|
||||||
|
// meaningful from somewhere: a table of degrees that suits one station points
|
||||||
|
// every other one at the wrong continent, and "EU 090" is worse than no default
|
||||||
|
// at all for a station in Japan. With no square known they come out at 0 and the
|
||||||
|
// operator fills them in — which is what the editor in Settings is for.
|
||||||
|
var rotorPresetRegions = []struct {
|
||||||
|
label string
|
||||||
|
lat, lon float64
|
||||||
|
}{
|
||||||
|
{"EU", 50, 15}, // central Europe
|
||||||
|
{"NA", 40, -95}, // central United States
|
||||||
|
{"SA", -15, -60}, // Brazil
|
||||||
|
{"VK", -25, 135}, // central Australia
|
||||||
|
{"JA", 36, 138}, // Japan
|
||||||
|
{"AF", 0, 20}, // central Africa
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultRotorPresets builds the six regions as seen from the operator's grid.
|
||||||
|
func (a *App) defaultRotorPresets() []RotorPreset {
|
||||||
|
out := make([]RotorPreset, 0, len(rotorPresetRegions))
|
||||||
|
for _, r := range rotorPresetRegions {
|
||||||
|
az := 0
|
||||||
|
if a.opSet {
|
||||||
|
az = int(initialBearingDeg(a.opLat, a.opLon, r.lat, r.lon) + 0.5)
|
||||||
|
}
|
||||||
|
out = append(out, RotorPreset{Label: r.label, Azimuth: az % 360})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// normRotorPresets keeps the list within what the widget can draw and the rotor
|
||||||
|
// can accept. A blank label drops the button entirely — that is how an operator
|
||||||
|
// removes one without a delete control.
|
||||||
|
func normRotorPresets(in []RotorPreset) []RotorPreset {
|
||||||
|
out := make([]RotorPreset, 0, len(in))
|
||||||
|
for _, p := range in {
|
||||||
|
label := strings.TrimSpace(p.Label)
|
||||||
|
if label == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(label) > rotorPresetLabelMax {
|
||||||
|
label = label[:rotorPresetLabelMax]
|
||||||
|
}
|
||||||
|
az := p.Azimuth % 360
|
||||||
|
if az < 0 {
|
||||||
|
az += 360
|
||||||
|
}
|
||||||
|
out = append(out, RotorPreset{Label: label, Azimuth: az})
|
||||||
|
if len(out) == rotorPresetMax {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRotorPresets returns the quick-turn buttons, falling back to the regions
|
||||||
|
// computed from the operator's square so the widget is useful before anyone
|
||||||
|
// visits the settings.
|
||||||
|
func (a *App) GetRotorPresets() []RotorPreset {
|
||||||
|
if raw := a.settingOr(keyRotorPresets, ""); raw != "" {
|
||||||
|
var saved []RotorPreset
|
||||||
|
if err := json.Unmarshal([]byte(raw), &saved); err == nil {
|
||||||
|
// A saved-but-empty list is a deliberate "no buttons", not a missing
|
||||||
|
// setting — otherwise clearing them all would resurrect the defaults.
|
||||||
|
return normRotorPresets(saved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return a.defaultRotorPresets()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveRotorPresets persists the buttons.
|
||||||
|
func (a *App) SaveRotorPresets(list []RotorPreset) error {
|
||||||
|
b, err := json.Marshal(normRotorPresets(list))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.setSetting(keyRotorPresets, string(b))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetRotorPresets puts back the six regions, recomputed from the operator's
|
||||||
|
// current square — the way to fix them after moving, or after a first run with
|
||||||
|
// no grid set.
|
||||||
|
func (a *App) ResetRotorPresets() []RotorPreset {
|
||||||
|
def := a.defaultRotorPresets()
|
||||||
|
if b, err := json.Marshal(def); err == nil {
|
||||||
|
a.setSetting(keyRotorPresets, string(b))
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
// RotatorPark moves the active rotor to its parked position (PstRotator only;
|
// RotatorPark moves the active rotor to its parked position (PstRotator only;
|
||||||
// the 4O3A native and ARCO GS-232 protocols have no park command).
|
// the 4O3A native and ARCO GS-232 protocols have no park command).
|
||||||
func (a *App) RotatorPark() error {
|
func (a *App) RotatorPark() error {
|
||||||
@@ -14853,6 +15253,13 @@ type StationDevice struct {
|
|||||||
OffURLs []string `json:"off_urls,omitempty"`
|
OffURLs []string `json:"off_urls,omitempty"`
|
||||||
OnPat string `json:"on_pattern,omitempty"` // fallback, {relay} substituted
|
OnPat string `json:"on_pattern,omitempty"` // fallback, {relay} substituted
|
||||||
OffPat string `json:"off_pattern,omitempty"`
|
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,
|
// deviceRelayCount is the relay count for a configured device — fixed by type,
|
||||||
@@ -14913,7 +15320,7 @@ func buildDeviceDriver(d StationDevice) relaydev.Device {
|
|||||||
// generic board fall through to the WebSwitch driver below: it answered
|
// generic board fall through to the WebSwitch driver below: it answered
|
||||||
// the WebSwitch's own address, never sent one configured URL, and
|
// the WebSwitch's own address, never sent one configured URL, and
|
||||||
// reported itself offline so every relay button stayed greyed out.
|
// 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:
|
default:
|
||||||
return relaydev.NewWebswitch(d.Host)
|
return relaydev.NewWebswitch(d.Host)
|
||||||
}
|
}
|
||||||
@@ -14933,7 +15340,10 @@ func deviceKey(d StationDevice) string {
|
|||||||
// The labels are part of the wire format here: {value} sends them.
|
// The labels are part of the wire format here: {value} sends them.
|
||||||
// Renaming a relay re-addresses it, and the cached driver would keep
|
// Renaming a relay re-addresses it, and the cached driver would keep
|
||||||
// commanding the old name.
|
// 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
|
return k
|
||||||
}
|
}
|
||||||
@@ -16653,13 +17063,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
|
// 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
|
// 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.
|
// the other — which on a combiner means one amplifier keyed and one not.
|
||||||
var firstErr error
|
return a.ampFanOut(a.ampTargets(id, a.GetLinkedAmps()), func(tid string) error {
|
||||||
for _, tid := range a.ampTargets(id, a.GetLinkedAmps()) {
|
return a.ampOperateOne(tid, on)
|
||||||
if err := a.ampOperateOne(tid, on); err != nil && firstErr == nil {
|
})
|
||||||
firstErr = err
|
}
|
||||||
|
|
||||||
|
// 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 {
|
func (a *App) ampOperateOne(id string, on bool) error {
|
||||||
@@ -16687,15 +17139,11 @@ func (a *App) AmpPower(id string, on bool) (err error) {
|
|||||||
applog.Printf("amp %s: power %v failed: %v", id, on, err)
|
applog.Printf("amp %s: power %v failed: %v", id, on, err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
linked := a.GetLinkedAmps()
|
targets := a.ampTargets(id, a.GetLinkedAmps())
|
||||||
targets := a.ampTargets(id, linked)
|
multi := len(targets) > 1
|
||||||
var firstErr error
|
return a.ampFanOut(targets, func(tid string) error {
|
||||||
for _, tid := range targets {
|
return a.ampPowerOne(tid, on, multi)
|
||||||
if e := a.ampPowerOne(tid, on, len(targets) > 1); e != nil && firstErr == nil {
|
})
|
||||||
firstErr = e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return firstErr
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) ampPowerOne(id string, on, linked bool) error {
|
func (a *App) ampPowerOne(id string, on, linked bool) error {
|
||||||
@@ -16725,12 +17173,31 @@ func (a *App) ampPowerOne(id string, on, linked bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AmpPowerLevel selects the output power level — SPE only (L/M/H).
|
// 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 {
|
func (a *App) AmpPowerLevel(id, level string) error {
|
||||||
inst := a.ampInstByID(id)
|
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 {
|
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 fmt.Errorf("power level is an SPE feature")
|
||||||
}
|
}
|
||||||
return inst.spe.SetPowerLevel(level)
|
return inst.spe.SetPowerLevel(level)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// AmpFanMode sets the fan mode — PGXL only (STANDARD/CONTEST/BROADCAST).
|
// AmpFanMode sets the fan mode — PGXL only (STANDARD/CONTEST/BROADCAST).
|
||||||
@@ -17559,16 +18026,26 @@ func (a *App) SendClusterCommand(cmd string) error {
|
|||||||
if cmd == "" {
|
if cmd == "" {
|
||||||
return fmt.Errorf("empty command")
|
return fmt.Errorf("empty command")
|
||||||
}
|
}
|
||||||
servers, err := a.listClusterServers()
|
srv, err := a.masterClusterServer()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
for _, s := range servers {
|
||||||
if s.Enabled {
|
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
|
// SendClusterSpot announces a DX spot on the **master** cluster (first
|
||||||
@@ -17590,8 +18067,26 @@ func (a *App) SendClusterSpot(call string, freqKHz float64, comment string) erro
|
|||||||
if c := strings.TrimSpace(comment); c != "" {
|
if c := strings.TrimSpace(comment); c != "" {
|
||||||
cmd += " " + 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)
|
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
|
// GetClusterStatus returns a snapshot of every active session. Used by
|
||||||
@@ -18164,6 +18659,18 @@ func (r catShareRig) Split() (bool, int64) {
|
|||||||
return true, st.FreqHz
|
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) 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) SetMode(m string) error { return r.a.cat.SetMode(m) }
|
||||||
func (r catShareRig) SetPTT(on bool) error { return r.a.cat.SetPTT(on) }
|
func (r catShareRig) SetPTT(on bool) error { return r.a.cat.SetPTT(on) }
|
||||||
@@ -18178,17 +18685,35 @@ func (r catShareRig) SetSplit(on bool, txHz int64) error {
|
|||||||
|
|
||||||
// reloadCATShare starts, stops or restarts the sharing server to match the
|
// reloadCATShare starts, stops or restarts the sharing server to match the
|
||||||
// settings. Called from reloadCAT so one "Save & Close" settles both.
|
// 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) {
|
func (a *App) reloadCATShare(s CATSettings) {
|
||||||
want := s.Enabled && s.ShareEnabled
|
want := s.Enabled && s.ShareEnabled
|
||||||
// Always tear down first: the port may have changed, and a listener bound to
|
// Always tear down first: the port — or the protocol — may have changed, and
|
||||||
// the old one would keep answering while the client is told to use the new.
|
// a listener bound to the old one would keep answering while the client is
|
||||||
|
// told to use the new.
|
||||||
if a.catShare != nil {
|
if a.catShare != nil {
|
||||||
a.catShare.Stop()
|
a.catShare.Stop()
|
||||||
a.catShare = nil
|
a.catShare = nil
|
||||||
}
|
}
|
||||||
|
if a.catShareTCI != nil {
|
||||||
|
a.catShareTCI.Stop()
|
||||||
|
a.catShareTCI = nil
|
||||||
|
}
|
||||||
if !want {
|
if !want {
|
||||||
return
|
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)
|
srv := rigctld.New(s.SharePort, catShareRig{a: a}, applog.Printf)
|
||||||
if err := srv.Start(); err != nil {
|
if err := srv.Start(); err != nil {
|
||||||
// The usual cause is another rigctld — or a previous OpsLog — already on
|
// The usual cause is another rigctld — or a previous OpsLog — already on
|
||||||
|
|||||||
@@ -138,6 +138,70 @@ func normRowColors(s RowColorSettings) RowColorSettings {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MatrixColors recolours the band/mode matrix — the PH/CW/DIG grid in the Stats
|
||||||
|
// panel, whose five fills and current-entry ring are the fastest read in the
|
||||||
|
// whole app and the one an operator is most likely to want in their own colours.
|
||||||
|
//
|
||||||
|
// Every colour is OPTIONAL and an empty one keeps whatever the active theme
|
||||||
|
// paints. That is why this stores OVERRIDES rather than a palette: each of the
|
||||||
|
// twelve themes ships a matrix ramp tuned to its own background, and an operator
|
||||||
|
// who only wants a different green must not thereby freeze the other four to the
|
||||||
|
// theme they happened to be using the day they picked it.
|
||||||
|
type MatrixColors struct {
|
||||||
|
// Enabled off leaves the theme's own ramp untouched, so switching it off is a
|
||||||
|
// genuine revert and not "some other set of colours".
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
CallConfirmed string `json:"call_confirmed"`
|
||||||
|
CallWorked string `json:"call_worked"`
|
||||||
|
EntityConfirmed string `json:"entity_confirmed"`
|
||||||
|
EntityWorked string `json:"entity_worked"`
|
||||||
|
NotWorked string `json:"not_worked"`
|
||||||
|
CurrentEntry string `json:"current_entry"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// normMatrixColors keeps only plain hex values. Anything else becomes "" — i.e.
|
||||||
|
// "use the theme's" — because these are written straight into a CSS custom
|
||||||
|
// property, and the same rule as hexColor's own comment applies: what cannot be
|
||||||
|
// trusted into a stylesheet is refused rather than passed through.
|
||||||
|
func normMatrixColors(c MatrixColors) MatrixColors {
|
||||||
|
clean := func(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if hexColor.MatchString(s) {
|
||||||
|
return strings.ToLower(s)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return MatrixColors{
|
||||||
|
Enabled: c.Enabled,
|
||||||
|
CallConfirmed: clean(c.CallConfirmed),
|
||||||
|
CallWorked: clean(c.CallWorked),
|
||||||
|
EntityConfirmed: clean(c.EntityConfirmed),
|
||||||
|
EntityWorked: clean(c.EntityWorked),
|
||||||
|
NotWorked: clean(c.NotWorked),
|
||||||
|
CurrentEntry: clean(c.CurrentEntry),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMatrixColors returns the operator's matrix palette overrides. All-empty
|
||||||
|
// (the default) means "whatever the theme says".
|
||||||
|
func (a *App) GetMatrixColors() MatrixColors {
|
||||||
|
var c MatrixColors
|
||||||
|
if raw := a.settingOr(keyMatrixColors, ""); raw != "" {
|
||||||
|
_ = json.Unmarshal([]byte(raw), &c)
|
||||||
|
}
|
||||||
|
return normMatrixColors(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveMatrixColors persists them.
|
||||||
|
func (a *App) SaveMatrixColors(c MatrixColors) error {
|
||||||
|
b, err := json.Marshal(normMatrixColors(c))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.setSetting(keyMatrixColors, string(b))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetRowColors returns the row-colouring configuration, defaults included so the
|
// GetRowColors returns the row-colouring configuration, defaults included so the
|
||||||
// panel never has to invent one.
|
// panel never has to invent one.
|
||||||
func (a *App) GetRowColors() RowColorSettings {
|
func (a *App) GetRowColors() RowColorSettings {
|
||||||
|
|||||||
@@ -1,4 +1,66 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.25.9",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"The band/mode matrix colours can be chosen in Appearance, starting from the ones your theme already paints. Its legend is translated too.",
|
||||||
|
"TCI sharing: a refused un-key no longer leaves the rig stuck transmitting, and PTT is dropped if the client dies mid-over.",
|
||||||
|
"Icom: a frequency or mode change whose acknowledgement is lost is sent again, like PTT — losing one made JTDX drop the radio.",
|
||||||
|
"FlexRadio: transmit audio can switch to DAX for a voice message and back afterwards, so your microphone is not left off air.",
|
||||||
|
"Rotor widget: quick-turn buttons you can name and aim yourself, plus an azimuth box where Enter turns the antenna.",
|
||||||
|
"A station portable in another entity no longer inherits its home county, state and grid — TI8/W2RE was logged in New York.",
|
||||||
|
"LoTW confirmations for FT2, FT4 and FT8 match again: LoTW answers “DATA”, and the exact mode never lined up with the log.",
|
||||||
|
"CAT: a transverter offset, so a 28 MHz IF behind a 144 MHz transverter logs, spots and tunes on the band you are really on.",
|
||||||
|
"Band map: zoom goes down to 2 px/kHz for a whole band in one screen, and each band keeps the zoom you left it at."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Les couleurs de la matrice bandes/modes se choisissent dans Apparence, à partir de celles du thème. Sa légende est traduite aussi.",
|
||||||
|
"Partage TCI : un retour en réception refusé ne laisse plus le poste bloqué en émission, et le PTT retombe si le logiciel meurt.",
|
||||||
|
"Icom : un changement de fréquence ou de mode dont l’accusé se perd est renvoyé, comme le PTT — en perdre un faisait lâcher JTDX.",
|
||||||
|
"FlexRadio : l’audio d’émission peut basculer sur DAX le temps d’un message vocal et revenir après, pour ne pas perdre le micro.",
|
||||||
|
"Widget rotor : des boutons de rotation rapide que tu nommes et vises toi-même, et un champ azimut où Entrée lance l’antenne.",
|
||||||
|
"Une station portable dans une autre entité n’hérite plus de son comté, son état et son locator d’origine : TI8/W2RE sortait à New York.",
|
||||||
|
"Les confirmations LoTW des QSO FT2, FT4 et FT8 se retrouvent : LoTW répond « DATA », et le mode exact ne correspondait jamais au log.",
|
||||||
|
"CAT : un décalage transverter, pour qu’une FI 28 MHz derrière un transverter 144 MHz logue et accorde sur la vraie bande.",
|
||||||
|
"Carte de bande : le zoom descend à 2 px/kHz pour voir toute la bande d’un coup, et chaque bande retient son zoom."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.25.8",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"An entity that is a single island group now fills the IOTA reference on its own — no callbook subscription needed.",
|
||||||
|
"CAT sharing can now speak TCI instead of Hamlib, split included, so a TCI-only program reaches whatever radio you are on.",
|
||||||
|
"Lookup cache: a TTL of 0 switches it off, so a callbook record you are correcting is re-read every time.",
|
||||||
|
"TCI radios: when the rig forbids transmitting, PTT says so instead of doing nothing silently.",
|
||||||
|
"WAJA carried 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",
|
"version": "0.25.7",
|
||||||
"date": "",
|
"date": "",
|
||||||
|
|||||||
+15
-2
@@ -94,7 +94,8 @@ import { ShutdownProgress } from '@/components/ShutdownProgress';
|
|||||||
import { ClusterGrid } from '@/components/ClusterGrid';
|
import { ClusterGrid } from '@/components/ClusterGrid';
|
||||||
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
|
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
|
||||||
import { applySpotDisplay, readSpotDisplayOptions, spotIsWorked, SPOT_DISPLAY_OPTIONS_EXPOSED } from '@/lib/spotDisplay';
|
import { applySpotDisplay, readSpotDisplayOptions, spotIsWorked, SPOT_DISPLAY_OPTIONS_EXPOSED } from '@/lib/spotDisplay';
|
||||||
import { GetRowColors, GetSpotTTLMinutes, IsNewUSCounty } from '../wailsjs/go/main/App';
|
import { GetMatrixColors, GetRotorPresets, GetRowColors, GetSpotTTLMinutes, IsNewUSCounty } from '../wailsjs/go/main/App';
|
||||||
|
import { applyMatrixColors } from '@/lib/matrixColors';
|
||||||
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
|
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
|
||||||
import { NetControlPanel } from '@/components/NetControlPanel';
|
import { NetControlPanel } from '@/components/NetControlPanel';
|
||||||
import { ContestPanel, CONTEST_DEFAULT, type ContestSession } from '@/components/ContestPanel';
|
import { ContestPanel, CONTEST_DEFAULT, type ContestSession } from '@/components/ContestPanel';
|
||||||
@@ -2017,6 +2018,16 @@ export default function App() {
|
|||||||
// settings dialog closes, which is the only place it changes.
|
// settings dialog closes, which is the only place it changes.
|
||||||
const [rowColors, setRowColors] = useState<any>(null);
|
const [rowColors, setRowColors] = useState<any>(null);
|
||||||
useEffect(() => { GetRowColors().then(setRowColors).catch(() => {}); }, [showSettings]);
|
useEffect(() => { GetRowColors().then(setRowColors).catch(() => {}); }, [showSettings]);
|
||||||
|
// Rotor quick-turn buttons (Settings → Rotator). Same reload trigger as the
|
||||||
|
// row colours: the settings dialog is the only place they change.
|
||||||
|
const [rotorPresets, setRotorPresets] = useState<{ label: string; azimuth: number }[]>([]);
|
||||||
|
useEffect(() => { GetRotorPresets().then((p) => setRotorPresets((p ?? []) as any)).catch(() => {}); }, [showSettings]);
|
||||||
|
// Band/mode matrix palette overrides (Settings → Appearance). Stamped onto
|
||||||
|
// <html> rather than held in state: the matrix reads CSS custom properties, so
|
||||||
|
// nothing re-renders and no component has to be told about the colours. Same
|
||||||
|
// reload trigger as the row colours — the settings dialog is where they change,
|
||||||
|
// and the panel already previews live while it is open.
|
||||||
|
useEffect(() => { GetMatrixColors().then((c) => applyMatrixColors(c as any)).catch(() => {}); }, [showSettings]);
|
||||||
// Spot lifetime (Settings → DX Cluster). Spots are actually REMOVED rather
|
// Spot lifetime (Settings → DX Cluster). Spots are actually REMOVED rather
|
||||||
// than filtered at render: the cluster list, every band map and the counts all
|
// than filtered at render: the cluster list, every band map and the counts all
|
||||||
// read the same array, so pruning it once is what makes the setting mean the
|
// read the same array, so pruning it once is what makes the setting mean the
|
||||||
@@ -6303,8 +6314,10 @@ export default function App() {
|
|||||||
{/* Rotor compass: azimuth dial + needles + click-to-turn. Shows when a
|
{/* Rotor compass: azimuth dial + needles + click-to-turn. Shows when a
|
||||||
rotator is configured or a DX bearing exists. */}
|
rotator is configured or a DX bearing exists. */}
|
||||||
{showRotor && (rotatorHeading.enabled || dxPath) && (
|
{showRotor && (rotatorHeading.enabled || dxPath) && (
|
||||||
<div className="w-[186px] shrink-0 min-h-0">
|
<div className="w-[320px] shrink-0 min-h-0">
|
||||||
<RotorCompass
|
<RotorCompass
|
||||||
|
presets={rotorPresets}
|
||||||
|
onStop={() => { RotatorStop().then(pokeRotorHeading).catch((err) => setError(String(err?.message ?? err))); }}
|
||||||
bearing={dxPath?.bearingShort ?? null}
|
bearing={dxPath?.bearingShort ?? null}
|
||||||
headings={beamHeadings}
|
headings={beamHeadings}
|
||||||
boomHeading={boomHeading}
|
boomHeading={boomHeading}
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { GetRowColors, SaveRowColors } from '../../wailsjs/go/main/App';
|
import { GetMatrixColors, GetRowColors, SaveMatrixColors, SaveRowColors } from '../../wailsjs/go/main/App';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { RowColorSettings } from '@/lib/rowColors';
|
import type { RowColorSettings } from '@/lib/rowColors';
|
||||||
|
import {
|
||||||
|
MATRIX_VARS, applyMatrixColors, effectiveMatrixColor, emptyMatrixColors,
|
||||||
|
type MatrixColors,
|
||||||
|
} from '@/lib/matrixColors';
|
||||||
|
|
||||||
// A fixed palette plus a free picker. Muted values on purpose: they are
|
// A fixed palette plus a free picker. Muted values on purpose: they are
|
||||||
// composited at low opacity over a dark grid, where a saturated colour reads as
|
// composited at low opacity over a dark grid, where a saturated colour reads as
|
||||||
@@ -30,6 +34,111 @@ const CHANNEL_LABELS: Record<string, string> = {
|
|||||||
qsl: 'appr.chQsl', lotw: 'LoTW', eqsl: 'eQSL', qrz: 'QRZ.com',
|
qsl: 'appr.chQsl', lotw: 'LoTW', eqsl: 'eQSL', qrz: 'QRZ.com',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// MatrixColorsSection recolours the band/mode matrix — the PH/CW/DIG grid in the
|
||||||
|
// Stats panel.
|
||||||
|
//
|
||||||
|
// The pickers are seeded from what the matrix is painting RIGHT NOW (the active
|
||||||
|
// theme's ramp, or an existing override), not from a fixed palette: the operator
|
||||||
|
// starts from the colours in front of them and moves one, instead of being
|
||||||
|
// handed six values that may belong to a theme they stopped using. Every change
|
||||||
|
// is applied to the live document at once, so the sample row below is the real
|
||||||
|
// thing rather than a mock-up of it.
|
||||||
|
function MatrixColorsSection() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [cfg, setCfg] = useState<MatrixColors | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
setCfg((await GetMatrixColors()) as any);
|
||||||
|
} catch {
|
||||||
|
setCfg(emptyMatrixColors());
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const save = (next: MatrixColors) => {
|
||||||
|
setCfg(next);
|
||||||
|
applyMatrixColors(next); // live, before the round trip — the panel must not lag the choice
|
||||||
|
SaveMatrixColors(next as any).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Turning it ON with nothing stored would change nothing at all and read as a
|
||||||
|
// broken switch, so the empty slots are filled from the theme's current ramp:
|
||||||
|
// the operator sees six swatches that match the grid and edits from there.
|
||||||
|
const enable = (on: boolean) => {
|
||||||
|
if (!cfg) return;
|
||||||
|
if (!on) {
|
||||||
|
save({ ...cfg, enabled: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const seeded = { ...cfg, enabled: true };
|
||||||
|
for (const { key, cssVar } of MATRIX_VARS) {
|
||||||
|
if (!String(seeded[key] ?? '').trim()) seeded[key] = effectiveMatrixColor(cssVar);
|
||||||
|
}
|
||||||
|
save(seeded);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset clears the overrides but keeps the section switched on, then re-seeds
|
||||||
|
// from the theme — "back to the theme's colours", which is what an operator
|
||||||
|
// means by reset here, rather than "switch the whole feature off".
|
||||||
|
const reset = () => {
|
||||||
|
if (!cfg) return;
|
||||||
|
applyMatrixColors({ ...emptyMatrixColors(), enabled: false });
|
||||||
|
const seeded = { ...emptyMatrixColors(), enabled: true };
|
||||||
|
for (const { key, cssVar } of MATRIX_VARS) seeded[key] = effectiveMatrixColor(cssVar);
|
||||||
|
save(seeded);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!cfg) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 border-t border-border/60 pt-4">
|
||||||
|
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={cfg.enabled} className="mt-0.5" onCheckedChange={(c) => enable(!!c)} />
|
||||||
|
<span>
|
||||||
|
{t('appr.matrixEnable')}{' '}
|
||||||
|
<span className="text-xs text-muted-foreground">{t('appr.matrixHint')}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{cfg.enabled && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
|
||||||
|
{MATRIX_VARS.map(({ key, cssVar, label }) => (
|
||||||
|
<label key={key} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={String(cfg[key] || '').trim() || effectiveMatrixColor(cssVar)}
|
||||||
|
onChange={(e) => save({ ...cfg, [key]: e.target.value })}
|
||||||
|
className="size-6 rounded-md border border-border bg-transparent p-0 cursor-pointer shrink-0"
|
||||||
|
/>
|
||||||
|
{t(label)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* The matrix as it will actually look: same tokens, same shapes. */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-xs text-muted-foreground w-10 shrink-0">{t('appr.matrixSample')}</span>
|
||||||
|
<span className="inline-block w-7 h-5 rounded bg-mx-call-conf" />
|
||||||
|
<span className="inline-block w-7 h-5 rounded bg-mx-call-work" />
|
||||||
|
<span className="inline-block w-7 h-5 rounded bg-mx-dx-conf" />
|
||||||
|
<span className="inline-block w-7 h-5 rounded bg-mx-dx-work" />
|
||||||
|
<span className="inline-block w-7 h-5 rounded bg-mx-none" />
|
||||||
|
<span className="inline-block w-7 h-5 rounded bg-mx-none ring-2 ring-mx-cur ring-inset" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" onClick={reset}
|
||||||
|
className="text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground">
|
||||||
|
{t('appr.matrixReset')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AppearancePanel() {
|
export function AppearancePanel() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [cfg, setCfg] = useState<RowColorSettings | null>(null);
|
const [cfg, setCfg] = useState<RowColorSettings | null>(null);
|
||||||
@@ -148,6 +257,8 @@ export function AppearancePanel() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<MatrixColorsSection />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { useI18n } from '@/lib/i18n';
|
|||||||
import {
|
import {
|
||||||
GetAwardDefs, SaveAwardDefs, ResetAwardDefs, AwardFields,
|
GetAwardDefs, SaveAwardDefs, ResetAwardDefs, AwardFields,
|
||||||
GetAwardReferenceMeta, UpdateAwardReferenceList,
|
GetAwardReferenceMeta, UpdateAwardReferenceList,
|
||||||
ListAwardReferences, SearchAwardReferences, SaveAwardReference, DeleteAwardReference,
|
ListAwardReferences, SearchAwardReferences, SaveAwardReference, DeleteAwardReference, RenameAwardReference,
|
||||||
ImportAwardReferencesText, GetAwardPresets, ApplyAwardPreset,
|
ImportAwardReferencesText, GetAwardPresets, ApplyAwardPreset,
|
||||||
ListCountries, DXCCForCountry, DXCCName,
|
ListCountries, DXCCForCountry, DXCCName,
|
||||||
PopulateBuiltinReferences, HasBuiltinReferences,
|
PopulateBuiltinReferences, HasBuiltinReferences,
|
||||||
@@ -915,6 +915,10 @@ function ReferencesPanel({ code, presets, meta, awardValidFrom, awardValidTo, on
|
|||||||
const [refs, setRefs] = useState<AwardRef[]>([]);
|
const [refs, setRefs] = useState<AwardRef[]>([]);
|
||||||
const [q, setQ] = useState('');
|
const [q, setQ] = useState('');
|
||||||
const [selCode, setSelCode] = useState<string | null>(null);
|
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 [busy, setBusy] = useState(false);
|
||||||
const [bulk, setBulk] = useState('');
|
const [bulk, setBulk] = useState('');
|
||||||
const [showBulk, setShowBulk] = useState(false);
|
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;
|
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.
|
// Large lists are already filtered by the server; small lists filter locally.
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (large) return refs;
|
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(); }
|
try { await SaveAwardReference(code, r as any); load(); onChanged(); }
|
||||||
catch (e: any) { setErr(String(e?.message ?? e)); }
|
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() {
|
async function addRef() {
|
||||||
const c = prompt(t('awed.newRefCodePrompt'))?.trim().toUpperCase();
|
const c = prompt(t('awed.newRefCodePrompt'))?.trim().toUpperCase();
|
||||||
if (!c) return;
|
if (!c) return;
|
||||||
@@ -1046,7 +1072,13 @@ function ReferencesPanel({ code, presets, meta, awardValidFrom, awardValidTo, on
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-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>
|
<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" />
|
<div className="flex-1" />
|
||||||
<button className="text-muted-foreground hover:text-destructive" onClick={() => delRef(sel.code)}><Trash2 className="size-4" /></button>
|
<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.refValidHintAward', { from: openEnded(awardValidFrom), to: openEnded(awardValidTo) })
|
||||||
: t('awed.refValidHint')}
|
: t('awed.refValidHint')}
|
||||||
</p>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight } from 'lucide-react';
|
import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { spotStatusKey, inferSpotMode, spotModeCategory } from '@/lib/spot';
|
import { spotStatusKey, inferSpotMode, spotModeCategory } from '@/lib/spot';
|
||||||
import { SPOT_MARKERS, activeMarkers } from '@/lib/spotMarkers';
|
import { SPOT_MARKERS, activeMarkers } from '@/lib/spotMarkers';
|
||||||
import { applySpotDisplay, readSpotDisplayOptions } from '@/lib/spotDisplay';
|
import { applySpotDisplay, readSpotDisplayOptions } from '@/lib/spotDisplay';
|
||||||
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
|
||||||
// BandMap — vertical spectrum panel inspired by Log4OM.
|
// BandMap — vertical spectrum panel inspired by Log4OM.
|
||||||
// - Full band is always visible; zoom changes pixels-per-kHz, scroll
|
// - Full band is always visible; zoom changes pixels-per-kHz, scroll
|
||||||
@@ -217,7 +218,37 @@ function statusStyle(s: string): { pill: string; bar: string; line: string; dot:
|
|||||||
// Pixels-per-kHz at each zoom step. Base 8 px/kHz means a stacked spot
|
// Pixels-per-kHz at each zoom step. Base 8 px/kHz means a stacked spot
|
||||||
// every 2.75 kHz fits without anti-overlap kicking in — comfortable for
|
// every 2.75 kHz fits without anti-overlap kicking in — comfortable for
|
||||||
// most bands. Higher levels are for fine inspection of crowded sub-bands.
|
// most bands. Higher levels are for fine inspection of crowded sub-bands.
|
||||||
const PX_PER_KHZ = [8, 16, 32, 64, 128, 256];
|
// Zoom steps, px per kHz. 2 and 4 sit below the old floor of 8 so a whole band
|
||||||
|
// fits without FIT taking the scale away from you: 20 m end to end is 700 px at
|
||||||
|
// 2 px/kHz, which scrolls in one screen on most displays.
|
||||||
|
const PX_PER_KHZ = [2, 4, 8, 16, 32, 64, 128, 256];
|
||||||
|
const DEFAULT_ZOOM_IDX = PX_PER_KHZ.indexOf(32);
|
||||||
|
|
||||||
|
// Zoom is remembered PER BAND, in ONE json key rather than one key per band:
|
||||||
|
// lib/uiPref keeps an explicit list of the preferences that travel with data/,
|
||||||
|
// and thirteen entries there to say the same thing would be thirteen chances to
|
||||||
|
// forget one.
|
||||||
|
const ZOOM_KEY = 'opslog.bandMapZoom';
|
||||||
|
|
||||||
|
function readZoomMap(): Record<string, number> {
|
||||||
|
try {
|
||||||
|
const m = JSON.parse(localStorage.getItem(ZOOM_KEY) || '{}');
|
||||||
|
return m && typeof m === 'object' ? m : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readZoom(band: string): number {
|
||||||
|
const n = readZoomMap()[band];
|
||||||
|
return Number.isInteger(n) && n >= 0 && n < PX_PER_KHZ.length ? n : DEFAULT_ZOOM_IDX;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeZoom(band: string, idx: number): void {
|
||||||
|
const m = readZoomMap();
|
||||||
|
m[band] = idx;
|
||||||
|
writeUiPref(ZOOM_KEY, JSON.stringify(m));
|
||||||
|
}
|
||||||
const SCALE_W = 56;
|
const SCALE_W = 56;
|
||||||
const PILL_H = 22; // px — height of each callsign pill
|
const PILL_H = 22; // px — height of each callsign pill
|
||||||
const PILL_GAP = 32; // px between scale border and first pill (room for leader)
|
const PILL_GAP = 32; // px between scale border and first pill (room for leader)
|
||||||
@@ -252,7 +283,22 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
|||||||
}, [spotStatusRaw, dispOpts.muteWorked, dispOpts.slotHighlight]);
|
}, [spotStatusRaw, dispOpts.muteWorked, dispOpts.slotHighlight]);
|
||||||
const range = BAND_RANGES[band];
|
const range = BAND_RANGES[band];
|
||||||
const segments = SEGMENT_COLORS[band] ?? [];
|
const segments = SEGMENT_COLORS[band] ?? [];
|
||||||
const [zoomIdx, setZoomIdx] = useState(2); // default 32 px/kHz
|
const [zoomIdx, setZoomIdx] = useState(() => readZoom(band));
|
||||||
|
// The docked map follows the rig, so a band change must bring up THAT band's
|
||||||
|
// remembered zoom.
|
||||||
|
useEffect(() => { setZoomIdx(readZoom(band)); }, [band]);
|
||||||
|
// Stored from the interaction, not from an effect on zoomIdx: an effect would
|
||||||
|
// also fire on the band change above, and in the same commit it would still be
|
||||||
|
// holding the PREVIOUS band's index — writing it over the new band's. Doing it
|
||||||
|
// where the operator actually turns the wheel keeps the two apart. (Running
|
||||||
|
// twice under StrictMode is harmless: the same value is stored.)
|
||||||
|
const changeZoom = useCallback((delta: number) => {
|
||||||
|
setZoomIdx((z) => {
|
||||||
|
const n = Math.max(0, Math.min(PX_PER_KHZ.length - 1, z + delta));
|
||||||
|
if (n !== z) writeZoom(band, n);
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
}, [band]);
|
||||||
const scrollerRef = useRef<HTMLDivElement | null>(null);
|
const scrollerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [containerH, setContainerH] = useState(400);
|
const [containerH, setContainerH] = useState(400);
|
||||||
|
|
||||||
@@ -421,7 +467,7 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
|||||||
if (!range) return;
|
if (!range) return;
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setZoomIdx((z) => Math.max(0, Math.min(PX_PER_KHZ.length - 1, z + (e.deltaY > 0 ? -1 : 1))));
|
changeZoom(e.deltaY > 0 ? -1 : 1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
el.addEventListener('wheel', onWheel, { passive: false });
|
el.addEventListener('wheel', onWheel, { passive: false });
|
||||||
@@ -476,8 +522,10 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
|||||||
|
|
||||||
// Tick step (where small marks land) and label step (where the kHz
|
// Tick step (where small marks land) and label step (where the kHz
|
||||||
// number is printed) are decoupled so the scale shows ~10-20 numeric
|
// number is printed) are decoupled so the scale shows ~10-20 numeric
|
||||||
// labels per viewport regardless of zoom — at base 8 px/kHz we want
|
// labels per viewport regardless of zoom — at 8 px/kHz we want labels
|
||||||
// labels every 25 kHz, not every 250.
|
// every 25 kHz, not every 250. The bare values below are the floor, and
|
||||||
|
// they are what the 2 px/kHz step lands on: a whole band in one screen
|
||||||
|
// wants a number every 100 kHz, not every 25.
|
||||||
let tickStep = 50;
|
let tickStep = 50;
|
||||||
let labelStep = 100;
|
let labelStep = 100;
|
||||||
if (pxPerKHz >= 4) { tickStep = 25; labelStep = 50; }
|
if (pxPerKHz >= 4) { tickStep = 25; labelStep = 50; }
|
||||||
@@ -502,14 +550,17 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
|||||||
return (
|
return (
|
||||||
<div className="h-full w-full flex flex-col min-h-0 bg-card">
|
<div className="h-full w-full flex flex-col min-h-0 bg-card">
|
||||||
<div className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground bg-muted/40 border-b border-border flex flex-nowrap items-center gap-0.5 shrink-0">
|
<div className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground bg-muted/40 border-b border-border flex flex-nowrap items-center gap-0.5 shrink-0">
|
||||||
<span className="flex-1 min-w-0 truncate">{t('bmp.map')} · {band}</span>
|
{/* The band alone. "Map · 20M" spent a third of a narrow header saying
|
||||||
<button type="button" onClick={() => setZoomIdx((z) => Math.max(0, z - 1))} disabled={fitToBand || zoomIdx === 0}
|
what the panel obviously is, and with four band maps side by side it
|
||||||
|
was four times the same word. */}
|
||||||
|
<span className="flex-1 min-w-0 truncate">{band}</span>
|
||||||
|
<button type="button" onClick={() => changeZoom(-1)} disabled={fitToBand || zoomIdx === 0}
|
||||||
className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
|
className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
|
||||||
title={t('bmp.zoomOut')}>
|
title={t('bmp.zoomOut')}>
|
||||||
<Minus className="size-3" />
|
<Minus className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
<span className="shrink-0 font-mono text-[10px] normal-case tracking-normal whitespace-nowrap px-0.5">{fitToBand ? t('bmp.fit') : `${pxPerKHz}px/kHz`}</span>
|
<span className="shrink-0 font-mono text-[10px] normal-case tracking-normal whitespace-nowrap px-0.5">{fitToBand ? t('bmp.fit') : `${pxPerKHz}px/kHz`}</span>
|
||||||
<button type="button" onClick={() => setZoomIdx((z) => Math.min(PX_PER_KHZ.length - 1, z + 1))} disabled={fitToBand || zoomIdx === PX_PER_KHZ.length - 1}
|
<button type="button" onClick={() => changeZoom(1)} disabled={fitToBand || zoomIdx === PX_PER_KHZ.length - 1}
|
||||||
className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
|
className="size-5 shrink-0 inline-flex items-center justify-center rounded hover:bg-muted disabled:opacity-30"
|
||||||
title={t('bmp.zoomIn')}>
|
title={t('bmp.zoomIn')}>
|
||||||
<Plus className="size-3" />
|
<Plus className="size-3" />
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { sunTimes } from '@/lib/sun';
|
import { sunTimes } from '@/lib/sun';
|
||||||
import { isQSLConfirmed } from '@/lib/qsl';
|
import { isQSLConfirmed } from '@/lib/qsl';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { BandSlotQSOs } from '../../wailsjs/go/main/App';
|
import { BandSlotQSOs } from '../../wailsjs/go/main/App';
|
||||||
import type { WorkedBeforeView } from '@/types';
|
import type { WorkedBeforeView } from '@/types';
|
||||||
|
|
||||||
@@ -76,28 +77,31 @@ const STATUS_CLASSES: Record<string, string> = {
|
|||||||
dxcc_w: 'bg-mx-dx-work',
|
dxcc_w: 'bg-mx-dx-work',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Legend entries, in the same colour order as the cells. swatch = the
|
// Legend entries, in the same colour order as the cells — and the same order and
|
||||||
// background class (or a special ring marker for the current-entry cell).
|
// i18n keys the Appearance panel's colour pickers use, so the two can never
|
||||||
|
// disagree about which green is which. swatch = the background class (or a
|
||||||
|
// special ring marker for the current-entry cell).
|
||||||
const LEGEND: { swatch: string; ring?: boolean; label: string }[] = [
|
const LEGEND: { swatch: string; ring?: boolean; label: string }[] = [
|
||||||
{ swatch: 'bg-mx-call-conf', label: 'Call confirmed' },
|
{ swatch: 'bg-mx-call-conf', label: 'mx.callConf' },
|
||||||
{ swatch: 'bg-mx-call-work', label: 'Call worked' },
|
{ swatch: 'bg-mx-call-work', label: 'mx.callWork' },
|
||||||
{ swatch: 'bg-mx-dx-conf', label: 'Entity confirmed' },
|
{ swatch: 'bg-mx-dx-conf', label: 'mx.dxConf' },
|
||||||
{ swatch: 'bg-mx-dx-work', label: 'Entity worked' },
|
{ swatch: 'bg-mx-dx-work', label: 'mx.dxWork' },
|
||||||
{ swatch: 'bg-mx-none', label: 'Not worked' },
|
{ swatch: 'bg-mx-none', label: 'mx.none' },
|
||||||
{ swatch: 'bg-mx-none', ring: true, label: 'Current entry' },
|
{ swatch: 'bg-mx-none', ring: true, label: 'mx.current' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function cellTitle(band: string, cls: string, status: string, current: boolean): string {
|
function cellTitle(t: (k: string) => string, band: string, cls: string, status: string, current: boolean): string {
|
||||||
const desc =
|
const desc =
|
||||||
status === 'call_c' ? 'This callsign confirmed' :
|
status === 'call_c' ? t('mx.tipCallConf') :
|
||||||
status === 'call_w' ? 'This callsign worked (not confirmed)' :
|
status === 'call_w' ? t('mx.tipCallWork') :
|
||||||
status === 'dxcc_c' ? 'Entity confirmed (other callsign)' :
|
status === 'dxcc_c' ? t('mx.tipDxConf') :
|
||||||
status === 'dxcc_w' ? 'Entity worked (other callsign)' :
|
status === 'dxcc_w' ? t('mx.tipDxWork') :
|
||||||
'Never worked';
|
t('mx.tipNone');
|
||||||
return `${band} ${cls}: ${desc}${current ? ' — current entry' : ''}`;
|
return `${band} ${cls}: ${desc}${current ? ' — ' + t('mx.current') : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true, lat, lon, forCall, onEditQso }: Props) {
|
export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCall = true, lat, lon, forCall, onEditQso }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
// Cell drill-down: which band+class the operator clicked, or null.
|
// Cell drill-down: which band+class the operator clicked, or null.
|
||||||
const [slot, setSlot] = useState<{ band: string; cls: string } | null>(null);
|
const [slot, setSlot] = useState<{ band: string; cls: string } | null>(null);
|
||||||
// Columns from the operator's configured bands (so the matrix shows only the
|
// Columns from the operator's configured bands (so the matrix shows only the
|
||||||
@@ -308,7 +312,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
key={b.tag}
|
key={b.tag}
|
||||||
title={cellTitle(b.tag, cls, st, isCurrent) + (st ? ' — click to list the QSOs' : '')}
|
title={cellTitle(t, b.tag, cls, st, isCurrent) + (st ? ' — ' + t('mx.tipClick') : '')}
|
||||||
onClick={st ? () => setSlot({ band: b.tag, cls }) : undefined}
|
onClick={st ? () => setSlot({ band: b.tag, cls }) : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-[28px] h-[24px] rounded transition-colors p-0',
|
'w-[28px] h-[24px] rounded transition-colors p-0',
|
||||||
@@ -316,7 +320,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
// Only a filled cell has anything to show — an empty one
|
// Only a filled cell has anything to show — an empty one
|
||||||
// stays inert rather than opening a "no QSOs" dialog.
|
// stays inert rather than opening a "no QSOs" dialog.
|
||||||
st && 'cursor-pointer hover:brightness-110',
|
st && 'cursor-pointer hover:brightness-110',
|
||||||
isCurrent && 'ring-2 ring-warning ring-inset',
|
isCurrent && 'ring-2 ring-mx-cur ring-inset',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -335,10 +339,10 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode, bands, hasCal
|
|||||||
className={cn(
|
className={cn(
|
||||||
'inline-block size-3 rounded shrink-0',
|
'inline-block size-3 rounded shrink-0',
|
||||||
l.swatch,
|
l.swatch,
|
||||||
l.ring && 'ring-2 ring-warning ring-inset',
|
l.ring && 'ring-2 ring-mx-cur ring-inset',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{l.label}
|
{t(l.label)}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,12 +5,13 @@
|
|||||||
// when an Ultrabeam is bidirectional, the opposite one when reversed); a small
|
// when an Ultrabeam is bidirectional, the opposite one when reversed); a small
|
||||||
// red marker on the bezel shows the short-path bearing to the DX. Click the dial
|
// red marker on the bezel shows the short-path bearing to the DX. Click the dial
|
||||||
// to turn the antenna there.
|
// to turn the antenna there.
|
||||||
import { useMemo } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { geoAzimuthalEquidistant, geoPath, geoGraticule10 } from 'd3-geo';
|
import { geoAzimuthalEquidistant, geoPath, geoGraticule10 } from 'd3-geo';
|
||||||
import { feature } from 'topojson-client';
|
import { feature } from 'topojson-client';
|
||||||
import landTopo from 'world-atlas/land-110m.json';
|
import landTopo from 'world-atlas/land-110m.json';
|
||||||
import { Compass, X } from 'lucide-react';
|
import { Compass, X, Play, Square } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
// Decode the coastline outline once (≈110 m simplified land polygons).
|
// Decode the coastline outline once (≈110 m simplified land polygons).
|
||||||
const LAND = feature(landTopo as any, (landTopo as any).objects.land);
|
const LAND = feature(landTopo as any, (landTopo as any).objects.land);
|
||||||
@@ -29,6 +30,11 @@ interface Props {
|
|||||||
onSelectRotor?: (i: number) => void; // switch the active rotor
|
onSelectRotor?: (i: number) => void; // switch the active rotor
|
||||||
onGoto?: (az: number) => void; // click-to-turn
|
onGoto?: (az: number) => void; // click-to-turn
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
|
// Quick-turn buttons and the azimuth box, shown only where the caller wants
|
||||||
|
// them: Station Control draws its own GoTo/Stop around this compass, and two
|
||||||
|
// sets of the same controls side by side would be nothing but confusing.
|
||||||
|
presets?: { label: string; azimuth: number }[];
|
||||||
|
onStop?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SIZE = 168;
|
const SIZE = 168;
|
||||||
@@ -41,7 +47,43 @@ function pt(az: number, radius: number): [number, number] {
|
|||||||
return [C + radius * Math.cos(a), C + radius * Math.sin(a)];
|
return [C + radius * Math.cos(a), C + radius * Math.sin(a)];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled, rotors, activeRotor, onSelectRotor, onGoto, onClose }: Props) {
|
export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled, rotors, activeRotor, onSelectRotor, onGoto, onClose, presets, onStop }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
// Raw text, not a number: binding the input to a normalised value makes
|
||||||
|
// Backspace fight the operator on the way from "230" to "23". It is parsed
|
||||||
|
// when it is sent, and only then.
|
||||||
|
const [azText, setAzText] = useState('');
|
||||||
|
const showControls = !!(presets || onStop);
|
||||||
|
|
||||||
|
// Which preset was just pressed, so it can light up for a moment.
|
||||||
|
//
|
||||||
|
// A rotor takes seconds to start moving and the needle barely twitches at
|
||||||
|
// first, so without this the only answer to "did that register?" is to press
|
||||||
|
// it again — which is how an antenna ends up ordered somewhere twice. The
|
||||||
|
// acknowledgement has to come from the button itself, at once.
|
||||||
|
const [flashIdx, setFlashIdx] = useState<number | null>(null);
|
||||||
|
const flashTimer = useRef<number | undefined>(undefined);
|
||||||
|
useEffect(() => () => window.clearTimeout(flashTimer.current), []);
|
||||||
|
const pressPreset = (i: number, az: number) => {
|
||||||
|
if (!onGoto) return;
|
||||||
|
setFlashIdx(i);
|
||||||
|
window.clearTimeout(flashTimer.current);
|
||||||
|
flashTimer.current = window.setTimeout(() => setFlashIdx(null), 450);
|
||||||
|
onGoto(az);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 0-359 and nothing else. 360 is refused rather than folded to 0 — it is
|
||||||
|
// almost always a typo for 36 or 306, and a rotor swinging through north on a
|
||||||
|
// slip of the finger is worth one rejected keypress.
|
||||||
|
const sendAz = () => {
|
||||||
|
const s = azText.trim();
|
||||||
|
if (s === '' || !onGoto) return;
|
||||||
|
const n = Number(s);
|
||||||
|
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0 || n > 359) return;
|
||||||
|
onGoto(n);
|
||||||
|
setAzText('');
|
||||||
|
};
|
||||||
|
|
||||||
const cardinals = useMemo(
|
const cardinals = useMemo(
|
||||||
() => [ { d: 0, l: 'N' }, { d: 45, l: 'NE' }, { d: 90, l: 'E' }, { d: 135, l: 'SE' },
|
() => [ { d: 0, l: 'N' }, { d: 45, l: 'NE' }, { d: 90, l: 'E' }, { d: 135, l: 'SE' },
|
||||||
{ d: 180, l: 'S' }, { d: 225, l: 'SW' }, { d: 270, l: 'W' }, { d: 315, l: 'NW' } ],
|
{ d: 180, l: 'S' }, { d: 225, l: 'SW' }, { d: 270, l: 'W' }, { d: 315, l: 'NW' } ],
|
||||||
@@ -72,6 +114,35 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
|
|||||||
|
|
||||||
const headLabel = headings.length ? headings[0] : null;
|
const headLabel = headings.length ? headings[0] : null;
|
||||||
|
|
||||||
|
// Short and long path to the DX, in figures.
|
||||||
|
//
|
||||||
|
// The bezel already carries the short path as a red marker, but a marker is a
|
||||||
|
// direction, not a number — the same pair sits in the status bar at 10px and
|
||||||
|
// operators reported not being able to read it. It lives here because it
|
||||||
|
// belongs to the compass: every place that draws one gets the readout, instead
|
||||||
|
// of each caller inventing its own. Clickable when the caller can turn, like
|
||||||
|
// the status bar's. Built as a value because it is placed in one of two
|
||||||
|
// columns depending on whether the controls are shown.
|
||||||
|
const pathReadout = (
|
||||||
|
<div className="flex gap-1.5 mt-2 font-mono w-full">
|
||||||
|
{([['SP', bearing ?? null], ['LP', bearing == null ? null : (bearing + 180) % 360]] as const).map(([lbl, az]) => (
|
||||||
|
<button key={lbl} type="button" disabled={az == null || !onGoto}
|
||||||
|
onClick={() => { if (az != null && onGoto) onGoto(Math.round(az)); }}
|
||||||
|
title={az == null ? '' : `${lbl} ${Math.round(az)}°`}
|
||||||
|
className={
|
||||||
|
'flex-1 rounded-md border py-1 text-xs font-semibold tabular-nums transition-colors active:scale-95 ' +
|
||||||
|
(az == null
|
||||||
|
? 'border-border text-muted-foreground/50 cursor-not-allowed'
|
||||||
|
: onGoto
|
||||||
|
? 'border-info-border text-info-muted-foreground hover:bg-info-muted cursor-pointer'
|
||||||
|
: 'border-border text-muted-foreground cursor-default')
|
||||||
|
}>
|
||||||
|
{lbl} {az == null ? '—' : `${Math.round(az)}°`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="flex flex-col h-full min-h-0 rounded-lg border border-border bg-card overflow-hidden">
|
<section className="flex flex-col h-full min-h-0 rounded-lg border border-border bg-card overflow-hidden">
|
||||||
{/* Header — matches the WinKeyer / Voice keyer panels. */}
|
{/* Header — matches the WinKeyer / Voice keyer panels. */}
|
||||||
@@ -128,9 +199,13 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Dial on the left, controls on the right. The controls column is sized to
|
||||||
|
fit WITHIN the dial's height: the widget sits in a row whose height is
|
||||||
|
set by the entry strip, so it may grow sideways but never downwards. */}
|
||||||
|
<div className="flex items-start gap-2 p-2 min-h-0">
|
||||||
{/* flex-col: the readout goes BELOW the dial. This wrapper was a row, so a
|
{/* flex-col: the readout goes BELOW the dial. This wrapper was a row, so a
|
||||||
sibling of the <svg> landed beside it. */}
|
sibling of the <svg> landed beside it. */}
|
||||||
<div className="flex flex-col items-center justify-center p-2 min-h-0">
|
<div className="flex flex-col items-center justify-center min-h-0 shrink-0">
|
||||||
<svg
|
<svg
|
||||||
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
||||||
className={onGoto ? 'cursor-pointer select-none' : 'select-none'}
|
className={onGoto ? 'cursor-pointer select-none' : 'select-none'}
|
||||||
@@ -191,30 +266,85 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
|
|||||||
<circle cx={C} cy={C} r={3.5} fill="#15803d" stroke="#fff" strokeWidth={1} />
|
<circle cx={C} cy={C} r={3.5} fill="#15803d" stroke="#fff" strokeWidth={1} />
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
{/* Short and long path to the DX, in figures.
|
{/* With the controls column present the readout goes at the FOOT OF IT
|
||||||
The bezel already carries the short path as a red marker, but a
|
instead: the dial sets the widget's height, the controls are shorter
|
||||||
marker is a direction, not a number — the same pair sits in the
|
than the dial, and that leftover space is exactly the right size for
|
||||||
status bar at 10px and operators reported not being able to read it.
|
the pair. Under the dial it would push the whole widget taller. */}
|
||||||
Here because it belongs to the compass: every place that draws one
|
{!showControls && pathReadout}
|
||||||
gets the readout, instead of each caller inventing its own.
|
</div>
|
||||||
Clickable when the caller can turn, like the status bar's. */}
|
|
||||||
<div className="flex gap-1.5 mt-2 font-mono w-full">
|
{/* Quick turns + free azimuth + Stop. */}
|
||||||
{([['SP', bearing ?? null], ['LP', bearing == null ? null : (bearing + 180) % 360]] as const).map(([lbl, az]) => (
|
{showControls && (
|
||||||
<button key={lbl} type="button" disabled={az == null || !onGoto}
|
<div className="flex flex-col gap-1.5 flex-1 min-w-0">
|
||||||
onClick={() => { if (az != null && onGoto) onGoto(Math.round(az)); }}
|
{/* Two columns so six regions fit beside the dial rather than under
|
||||||
title={az == null ? '' : `${lbl} ${Math.round(az)}°`}
|
it. An operator with fewer keeps the same compact block. */}
|
||||||
className={
|
{!!presets?.length && (
|
||||||
'flex-1 rounded-md border py-1 text-xs font-semibold tabular-nums transition-colors ' +
|
<div className="grid grid-cols-2 gap-1">
|
||||||
(az == null
|
{presets.map((p, i) => (
|
||||||
? 'border-border text-muted-foreground/50 cursor-not-allowed'
|
<button
|
||||||
: onGoto
|
key={`${p.label}-${i}`}
|
||||||
? 'border-info-border text-info-muted-foreground hover:bg-info-muted cursor-pointer'
|
type="button"
|
||||||
: 'border-border text-muted-foreground cursor-default')
|
disabled={!onGoto}
|
||||||
}>
|
onClick={() => pressPreset(i, p.azimuth)}
|
||||||
{lbl} {az == null ? '—' : `${Math.round(az)}°`}
|
title={`${p.label} — ${p.azimuth}°`}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md border py-1 text-xs font-semibold truncate transition-all duration-150 active:scale-95',
|
||||||
|
flashIdx === i
|
||||||
|
// Lit, and showing the azimuth it just sent: the label alone
|
||||||
|
// would only say the press landed, not what was ordered.
|
||||||
|
? 'border-success bg-success text-success-foreground scale-95'
|
||||||
|
: 'border-border bg-muted/40',
|
||||||
|
onGoto && flashIdx !== i ? 'hover:bg-muted' : '',
|
||||||
|
!onGoto ? 'opacity-50 cursor-not-allowed' : '',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{flashIdx === i ? `${p.azimuth}°` : p.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Free azimuth: Enter sends, so the whole thing is type-three-digits
|
||||||
|
-and-go without reaching for the mouse. */}
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={azText}
|
||||||
|
onChange={(e) => setAzText(e.target.value.replace(/[^0-9]/g, '').slice(0, 3))}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); sendAz(); } }}
|
||||||
|
placeholder={t('rotor.azPh')}
|
||||||
|
title={t('rotor.azTitle')}
|
||||||
|
disabled={!onGoto}
|
||||||
|
className="min-w-0 flex-1 rounded-md border border-border bg-background px-2 py-1 text-xs font-mono tabular-nums text-center disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={sendAz}
|
||||||
|
disabled={!onGoto || azText.trim() === ''}
|
||||||
|
title={t('rotor.go')}
|
||||||
|
className="flex items-center gap-1 rounded-md border border-success/60 bg-success-muted px-2 py-1 text-xs font-bold text-success-muted-foreground transition-transform hover:bg-success/25 active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Play className="size-3" /> {t('rotor.go')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{onStop && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onStop}
|
||||||
|
title={t('rotor.stop')}
|
||||||
|
className="flex items-center justify-center gap-1.5 rounded-md border border-destructive/60 bg-destructive/15 py-1 text-xs font-bold text-destructive hover:bg-destructive/25"
|
||||||
|
>
|
||||||
|
<Square className="size-3 fill-current" /> {t('rotor.stop')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* mt-auto: the readout sits at the FOOT of the column, level with the
|
||||||
|
bottom of the dial, instead of floating under the Stop button. */}
|
||||||
|
<div className="mt-auto">{pathReadout}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
GetCATSettings, SaveCATSettings, DiscoverFlexRadios,
|
GetCATSettings, SaveCATSettings, DiscoverFlexRadios,
|
||||||
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
||||||
GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop,
|
GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop,
|
||||||
|
GetRotorPresets, SaveRotorPresets, ResetRotorPresets,
|
||||||
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
|
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
|
||||||
GetAntGeniusSettings, SaveAntGeniusSettings,
|
GetAntGeniusSettings, SaveAntGeniusSettings,
|
||||||
GetTunerGeniusSettings, SaveTunerGeniusSettings,
|
GetTunerGeniusSettings, SaveTunerGeniusSettings,
|
||||||
@@ -1399,16 +1400,25 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
const [bandDraft, setBandDraft] = useState('');
|
const [bandDraft, setBandDraft] = useState('');
|
||||||
const [modeDraft, setModeDraft] = useState('');
|
const [modeDraft, setModeDraft] = useState('');
|
||||||
const [catCfg, setCatCfg] = useState<CATSettings>({
|
const [catCfg, setCatCfg] = useState<CATSettings>({
|
||||||
enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120,
|
enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120, flex_dvk_dax: false,
|
||||||
yaesu_port: '', yaesu_baud: 38400, yaesu_low_lines: false, kenwood_low_lines: false, kenwood_port: '', kenwood_baud: 9600, kenwood_host: '', kenwood_data_mode: 'usb', xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70, xiegu_ptt_line: '',
|
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,
|
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,
|
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0, offset_on: false, offset_hz: 0,
|
||||||
digital_default: 'FT8', share_enabled: false, share_port: 4532,
|
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,
|
ptt_hotkey_enabled: false, ptt_hotkey: '', ptt_hotkey_toggle: false,
|
||||||
});
|
});
|
||||||
// While true, the next key press is captured as the PTT hotkey.
|
// While true, the next key press is captured as the PTT hotkey.
|
||||||
const [capturingPtt, setCapturingPtt] = useState(false);
|
const [capturingPtt, setCapturingPtt] = useState(false);
|
||||||
const [rotors, setRotors] = useState<RotatorDevice[]>([]);
|
const [rotors, setRotors] = useState<RotatorDevice[]>([]);
|
||||||
|
const [rotorPresets, setRotorPresets] = useState<{ label: string; azimuth: number }[]>([]);
|
||||||
|
// Whether the presets have actually been READ back yet.
|
||||||
|
//
|
||||||
|
// An empty list is a legitimate answer — an operator may want no quick-turn
|
||||||
|
// buttons at all — so the backend stores it as one. That makes "not loaded
|
||||||
|
// yet" and "deliberately none" the same value, and a Save landing in the first
|
||||||
|
// state wiped the buttons for good. Saving is gated on having read them, so
|
||||||
|
// the only empty list that can ever reach the store is one the operator made.
|
||||||
|
const [rotorPresetsLoaded, setRotorPresetsLoaded] = useState(false);
|
||||||
const [rotatorTesting, setRotatorTesting] = useState(false);
|
const [rotatorTesting, setRotatorTesting] = useState(false);
|
||||||
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
|
|
||||||
@@ -1825,6 +1835,11 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
await reloadClusterServers();
|
await reloadClusterServers();
|
||||||
setCatCfg(c);
|
setCatCfg(c);
|
||||||
setRotors((r ?? []) as any);
|
setRotors((r ?? []) as any);
|
||||||
|
// Loaded HERE, in the loader that runs on mount — not only in the
|
||||||
|
// event-driven one below. Missing from this one, the state stayed empty
|
||||||
|
// on a normal open and Save then wrote an empty list over the operator's
|
||||||
|
// buttons. See rotorPresetsLoaded for the belt to this brace.
|
||||||
|
try { setRotorPresets((((await GetRotorPresets()) ?? []) as any)); setRotorPresetsLoaded(true); } catch {}
|
||||||
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
||||||
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
||||||
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
|
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
|
||||||
@@ -1868,6 +1883,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
try { setLookup(await GetLookupSettings() as any); } catch {}
|
try { setLookup(await GetLookupSettings() as any); } catch {}
|
||||||
try { setCatCfg(await GetCATSettings() as any); } catch {}
|
try { setCatCfg(await GetCATSettings() as any); } catch {}
|
||||||
try { setRotors(((await GetRotators()) ?? []) as any); } catch {}
|
try { setRotors(((await GetRotators()) ?? []) as any); } catch {}
|
||||||
|
try { setRotorPresets((((await GetRotorPresets()) ?? []) as any)); setRotorPresetsLoaded(true); } catch {}
|
||||||
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
||||||
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
||||||
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
|
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
|
||||||
@@ -2062,6 +2078,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
await SaveLookupSettings(lookup as any);
|
await SaveLookupSettings(lookup as any);
|
||||||
await SaveCATSettings(catCfg as any);
|
await SaveCATSettings(catCfg as any);
|
||||||
await SaveRotators(rotors as any);
|
await SaveRotators(rotors as any);
|
||||||
|
// Only once they have been read back — see rotorPresetsLoaded.
|
||||||
|
if (rotorPresetsLoaded) await SaveRotorPresets(rotorPresets as any);
|
||||||
await SaveUltrabeamSettings(ultrabeam as any);
|
await SaveUltrabeamSettings(ultrabeam as any);
|
||||||
await SaveAntGeniusSettings(antgenius as any);
|
await SaveAntGeniusSettings(antgenius as any);
|
||||||
await SaveTunerGeniusSettings(tunergenius as any);
|
await SaveTunerGeniusSettings(tunergenius as any);
|
||||||
@@ -2368,6 +2386,11 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
}
|
}
|
||||||
|
|
||||||
function LookupPanel() {
|
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
|
// Per-row provider editor — kept inline because it's only used twice
|
||||||
// and needs closure access to the parent state.
|
// and needs closure access to the parent state.
|
||||||
const row = (
|
const row = (
|
||||||
@@ -2504,16 +2527,31 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<div className="flex gap-3 items-end">
|
<div className="flex gap-3 items-end">
|
||||||
<div className="space-y-1 w-40">
|
<div className="space-y-1 w-40">
|
||||||
<Label>{t('lk.ttl')}</Label>
|
<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
|
<Input
|
||||||
type="number" min={1} max={3650}
|
type="number" min={0} max={3650}
|
||||||
value={lookup.cache_ttl_days}
|
value={ttlText}
|
||||||
onChange={(e) => setLookup((s) => ({ ...s, cache_ttl_days: parseInt(e.target.value) || 30 }))}
|
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>
|
</div>
|
||||||
<Button variant="outline" onClick={clearCache} disabled={clearing}>
|
<Button variant="outline" onClick={clearCache} disabled={clearing}>
|
||||||
{clearing ? t('lk.clearing') : t('lk.clearCache')}
|
{clearing ? t('lk.clearing') : t('lk.clearCache')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{lookup.cache_ttl_days === 0 && (
|
||||||
|
<p className="text-[11px] text-warning mt-2">{t('lk.cacheOff')}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -2842,6 +2880,10 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<span className="text-xs text-muted-foreground">{t('cat.flexDecodeSecsHint')}</span>
|
<span className="text-xs text-muted-foreground">{t('cat.flexDecodeSecsHint')}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<label className="col-span-2 flex items-start gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox className="mt-0.5" checked={!!catCfg.flex_dvk_dax} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, flex_dvk_dax: !!c }))} />
|
||||||
|
<span>{t('cat.flexDvkDax')} <span className="text-xs text-muted-foreground">{t('cat.flexDvkDaxHint')}</span></span>
|
||||||
|
</label>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{catCfg.backend === 'xiegu' && (
|
{catCfg.backend === 'xiegu' && (
|
||||||
@@ -3122,6 +3164,28 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{/* Transverter offset. OUTSIDE the per-backend blocks on purpose: a
|
||||||
|
transverter hangs off the IF of whatever radio you own, and the
|
||||||
|
offset is applied in the CAT manager, above every backend. Tucked
|
||||||
|
under the OmniRig/Icom section it would have been invisible to the
|
||||||
|
Yaesu, Kenwood, Xiegu, Flex and TCI operators who need it just as
|
||||||
|
much. */}
|
||||||
|
<label className="col-span-2 flex items-start gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox className="mt-0.5" checked={!!catCfg.offset_on}
|
||||||
|
onCheckedChange={(c) => setCatCfg((s) => ({ ...s, offset_on: !!c }))} />
|
||||||
|
<span>{t('cat.offsetOn')} <span className="text-xs text-muted-foreground">{t('cat.offsetHint')}</span></span>
|
||||||
|
</label>
|
||||||
|
{catCfg.offset_on && (
|
||||||
|
<div className="col-span-2 flex items-center gap-2 pl-6">
|
||||||
|
<Label className="text-sm">{t('cat.offsetMhz')}</Label>
|
||||||
|
<Input
|
||||||
|
type="number" step="0.000001" className="w-40"
|
||||||
|
value={(catCfg.offset_hz ?? 0) / 1e6}
|
||||||
|
onChange={(e) => setCatCfg((s) => ({ ...s, offset_hz: Math.round((parseFloat(e.target.value) || 0) * 1e6) }))}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">{t('cat.offsetExample')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="space-y-1 col-span-2">
|
<div className="space-y-1 col-span-2">
|
||||||
<Label>{t('cat.digitalDefault')}</Label>
|
<Label>{t('cat.digitalDefault')}</Label>
|
||||||
<Select
|
<Select
|
||||||
@@ -3149,14 +3213,46 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</label>
|
</label>
|
||||||
<p className="text-[11px] text-muted-foreground">{t('cat.shareHint')}</p>
|
<p className="text-[11px] text-muted-foreground">{t('cat.shareHint')}</p>
|
||||||
{catCfg.share_enabled && (
|
{catCfg.share_enabled && (
|
||||||
|
<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]">
|
<div className="space-y-1 max-w-[200px]">
|
||||||
<Label>{t('cat.sharePort')}</Label>
|
<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
|
<PortInput
|
||||||
value={catCfg.share_port || 4532}
|
value={catCfg.share_port || 4532}
|
||||||
fallback={4532}
|
fallback={4532}
|
||||||
onChange={(n) => setCatCfg((s) => ({ ...s, share_port: n }))}
|
onChange={(n) => setCatCfg((s) => ({ ...s, share_port: n }))}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{/* PTT hotkey — a keyboard key that keys the rig while OpsLog is focused.
|
{/* PTT hotkey — a keyboard key that keys the rig while OpsLog is focused.
|
||||||
@@ -4083,6 +4179,41 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Quick-turn buttons for the rotor widget. Their azimuths start out
|
||||||
|
computed from the station square, so they are right for THIS QTH
|
||||||
|
rather than copied from someone else's. */}
|
||||||
|
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||||
|
<div className="text-sm font-semibold">{t('rot.presets')}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('rot.presetsHint')}</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{rotorPresets.map((p, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<Input className="w-24" maxLength={6} value={p.label}
|
||||||
|
placeholder={t('rot.presetName')}
|
||||||
|
onChange={(e) => setRotorPresets((list) => list.map((x, j) => (j === i ? { ...x, label: e.target.value } : x)))} />
|
||||||
|
<Input className="w-24" type="number" min={0} max={359} value={p.azimuth}
|
||||||
|
onChange={(e) => setRotorPresets((list) => list.map((x, j) => (j === i ? { ...x, azimuth: Math.max(0, Math.min(359, parseInt(e.target.value, 10) || 0)) } : x)))} />
|
||||||
|
<span className="text-xs text-muted-foreground">°</span>
|
||||||
|
<Button variant="ghost" size="sm" title={t('rot.remove')}
|
||||||
|
onClick={() => setRotorPresets((list) => list.filter((_, j) => j !== i))}>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{rotorPresets.length < 8 && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setRotorPresets((l) => [...l, { label: '', azimuth: 0 }])}>
|
||||||
|
<Plus className="size-3.5 mr-1" /> {t('rot.presetAdd')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="outline" size="sm"
|
||||||
|
onClick={() => { ResetRotorPresets().then((p) => setRotorPresets((p ?? []) as any)).catch((e) => setErr(String(e?.message ?? e))); }}>
|
||||||
|
{t('rot.presetsReset')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{rotatorTest && (
|
{rotatorTest && (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'text-xs rounded-md p-2.5 border',
|
'text-xs rounded-md p-2.5 border',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Squar
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
@@ -82,7 +83,7 @@ type Device = {
|
|||||||
id: string; type: string; name: string; host: string;
|
id: string; type: string; name: string; host: string;
|
||||||
user?: string; pass?: string; channels?: number; labels: string[];
|
user?: string; pass?: string; channels?: number; labels: string[];
|
||||||
// Generic HTTP board only. The per-relay URLs win over the patterns.
|
// 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 Relay = { number: number; label: string; on: boolean };
|
||||||
type DevStatus = { id: string; name: string; type: string; connected: boolean; error?: string; relays: Relay[] };
|
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 ?? '']
|
&& [...(device.on_urls ?? []), ...(device.off_urls ?? []), device.on_pattern ?? '', device.off_pattern ?? '']
|
||||||
.some((s) => (s ?? '').includes('{value}'))
|
.some((s) => (s ?? '').includes('{value}'))
|
||||||
&& device.labels.some((l) => !l.trim());
|
&& 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.
|
// COM ports for the generic USB-serial relay picker.
|
||||||
const [serialPorts, setSerialPorts] = useState<string[]>([]);
|
const [serialPorts, setSerialPorts] = useState<string[]>([]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -959,6 +966,19 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-muted-foreground">{t('station.patternHint')}</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">
|
<div className="space-y-1">
|
||||||
<Label>{t('station.perRelayUrls')}</Label>
|
<Label>{t('station.perRelayUrls')}</Label>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|||||||
+48
-12
File diff suppressed because one or more lines are too long
@@ -0,0 +1,66 @@
|
|||||||
|
// Operator overrides for the band/mode matrix palette.
|
||||||
|
//
|
||||||
|
// Applied as inline custom properties on <html>, which is what lets them be
|
||||||
|
// OVERRIDES rather than a palette: the twelve themes each define their own
|
||||||
|
// --mx-* ramp in style.css, an inline value wins over all of them, and removing
|
||||||
|
// it hands the colour straight back to the theme. Nothing has to know which
|
||||||
|
// theme is active, and switching theme with overrides off is a clean revert.
|
||||||
|
|
||||||
|
export type MatrixColors = {
|
||||||
|
enabled: boolean;
|
||||||
|
call_confirmed: string;
|
||||||
|
call_worked: string;
|
||||||
|
entity_confirmed: string;
|
||||||
|
entity_worked: string;
|
||||||
|
not_worked: string;
|
||||||
|
current_entry: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The six settings fields and the CSS custom property each one drives. Also the
|
||||||
|
// display order — the same order the legend under the matrix reads in, so the
|
||||||
|
// settings panel and the grid can never disagree about which green is which.
|
||||||
|
export const MATRIX_VARS: { key: keyof Omit<MatrixColors, 'enabled'>; cssVar: string; label: string }[] = [
|
||||||
|
{ key: 'call_confirmed', cssVar: '--mx-call-conf', label: 'mx.callConf' },
|
||||||
|
{ key: 'call_worked', cssVar: '--mx-call-work', label: 'mx.callWork' },
|
||||||
|
{ key: 'entity_confirmed', cssVar: '--mx-dx-conf', label: 'mx.dxConf' },
|
||||||
|
{ key: 'entity_worked', cssVar: '--mx-dx-work', label: 'mx.dxWork' },
|
||||||
|
{ key: 'not_worked', cssVar: '--mx-none', label: 'mx.none' },
|
||||||
|
{ key: 'current_entry', cssVar: '--mx-cur', label: 'mx.current' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const emptyMatrixColors = (): MatrixColors => ({
|
||||||
|
enabled: false,
|
||||||
|
call_confirmed: '', call_worked: '', entity_confirmed: '',
|
||||||
|
entity_worked: '', not_worked: '', current_entry: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// applyMatrixColors stamps (or clears) the overrides on <html>. Safe to call as
|
||||||
|
// often as you like — it is the whole rendering path, so the settings panel uses
|
||||||
|
// it for a live preview and the app uses it once at startup.
|
||||||
|
export function applyMatrixColors(c?: MatrixColors | null): void {
|
||||||
|
const root = document.documentElement;
|
||||||
|
for (const { key, cssVar } of MATRIX_VARS) {
|
||||||
|
const v = c?.enabled ? String(c[key] ?? '').trim() : '';
|
||||||
|
if (v) root.style.setProperty(cssVar, v);
|
||||||
|
else root.style.removeProperty(cssVar);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// effectiveMatrixColor reads what the matrix is ACTUALLY painting right now —
|
||||||
|
// the override if there is one, else the active theme's value. It is what seeds
|
||||||
|
// the colour pickers, so the operator starts from the colours in front of them
|
||||||
|
// instead of from a hardcoded palette that may belong to a different theme.
|
||||||
|
//
|
||||||
|
// A custom property's computed value has its var() references substituted, so
|
||||||
|
// --mx-cur resolves to the theme's --warning rather than to the literal text.
|
||||||
|
export function effectiveMatrixColor(cssVar: string): string {
|
||||||
|
try {
|
||||||
|
const v = getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim();
|
||||||
|
// <input type="color"> only accepts #rrggbb. Anything else (a theme that
|
||||||
|
// ever moves to oklch, an empty read during boot) falls back to mid grey
|
||||||
|
// rather than silently resetting the picker to black.
|
||||||
|
return /^#[0-9a-f]{6}$/i.test(v) ? v.toLowerCase() : '#808080';
|
||||||
|
} catch {
|
||||||
|
return '#808080';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,14 @@ let timer: number | undefined;
|
|||||||
let inFlight = false;
|
let inFlight = false;
|
||||||
let lastAz: number | null = null;
|
let lastAz: number | null = null;
|
||||||
let lastMoveAt = 0;
|
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) {
|
function schedule(delay: number) {
|
||||||
if (timer !== undefined) window.clearTimeout(timer);
|
if (timer !== undefined) window.clearTimeout(timer);
|
||||||
@@ -54,6 +62,7 @@ async function tick() {
|
|||||||
if (lastAz !== null && h.azimuth !== lastAz) lastMoveAt = Date.now();
|
if (lastAz !== null && h.azimuth !== lastAz) lastMoveAt = Date.now();
|
||||||
lastAz = h.azimuth;
|
lastAz = h.azimuth;
|
||||||
}
|
}
|
||||||
|
last = h;
|
||||||
subs.forEach((fn) => { try { fn(h); } catch { /* a subscriber must not stop the loop */ } });
|
subs.forEach((fn) => { try { fn(h); } catch { /* a subscriber must not stop the loop */ } });
|
||||||
} catch {
|
} catch {
|
||||||
// Leave the last heading alone: a single failed poll on a shared serial port
|
// 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.
|
// unsubscribe. The loop stops when the last subscriber leaves.
|
||||||
export function subscribeRotorHeading(fn: (h: RotorHeading) => void): () => void {
|
export function subscribeRotorHeading(fn: (h: RotorHeading) => void): () => void {
|
||||||
subs.add(fn);
|
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();
|
if (timer === undefined && !inFlight) void tick();
|
||||||
return () => {
|
return () => {
|
||||||
subs.delete(fn);
|
subs.delete(fn);
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ const PORTABLE_KEYS = [
|
|||||||
'opslog.clusterSlotHighlight', // cluster/band map: colour calls not worked on this band+mode
|
'opslog.clusterSlotHighlight', // cluster/band map: colour calls not worked on this band+mode
|
||||||
'opslog.bandMapWidth', // docked band map: column width (px)
|
'opslog.bandMapWidth', // docked band map: column width (px)
|
||||||
'opslog.bandMapTabWidth', // Band map tab: shared card width (px)
|
'opslog.bandMapTabWidth', // Band map tab: shared card width (px)
|
||||||
|
'opslog.bandMapZoom', // band map zoom (px/kHz step) remembered per band, as one {band: index} map
|
||||||
// NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here.
|
// NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here.
|
||||||
// They are handled by lib/gridPrefs, which scopes the localStorage cache PER
|
// They are handled by lib/gridPrefs, which scopes the localStorage cache PER
|
||||||
// PROFILE and mirrors to the DB (already per-profile) itself — mirroring them
|
// PROFILE and mirrors to the DB (already per-profile) itself — mirroring them
|
||||||
|
|||||||
@@ -85,6 +85,12 @@
|
|||||||
--mx-dx-conf: #3730a3; /* entity confirmed*/
|
--mx-dx-conf: #3730a3; /* entity confirmed*/
|
||||||
--mx-dx-work: #a5b4fc; /* entity worked */
|
--mx-dx-work: #a5b4fc; /* entity worked */
|
||||||
--mx-none: #e7e5e4; /* never worked */
|
--mx-none: #e7e5e4; /* never worked */
|
||||||
|
/* Ring on the cell the operator is entering. Declared ONCE, on :root, and
|
||||||
|
deliberately not repeated per theme: it follows --warning, which every theme
|
||||||
|
already tunes to its own background. The token exists so the matrix ring can
|
||||||
|
be recoloured on its own without dragging every other warning in the app
|
||||||
|
with it (Appearance → matrix colours). */
|
||||||
|
--mx-cur: var(--warning);
|
||||||
|
|
||||||
--scrollbar-thumb: #b8a880;
|
--scrollbar-thumb: #b8a880;
|
||||||
--scrollbar-thumb-hover: #968455;
|
--scrollbar-thumb-hover: #968455;
|
||||||
@@ -974,6 +980,7 @@
|
|||||||
--color-mx-dx-conf: var(--mx-dx-conf);
|
--color-mx-dx-conf: var(--mx-dx-conf);
|
||||||
--color-mx-dx-work: var(--mx-dx-work);
|
--color-mx-dx-work: var(--mx-dx-work);
|
||||||
--color-mx-none: var(--mx-none);
|
--color-mx-none: var(--mx-none);
|
||||||
|
--color-mx-cur: var(--mx-cur);
|
||||||
|
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Single source of truth for the app version shown in the UI (header + About).
|
// 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).
|
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||||
export const APP_VERSION = '0.25.7';
|
export const APP_VERSION = '0.25.9';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+12
@@ -477,6 +477,8 @@ export function GetLogbookRevision():Promise<string>;
|
|||||||
|
|
||||||
export function GetLookupSettings():Promise<main.LookupSettings>;
|
export function GetLookupSettings():Promise<main.LookupSettings>;
|
||||||
|
|
||||||
|
export function GetMatrixColors():Promise<main.MatrixColors>;
|
||||||
|
|
||||||
export function GetMySQLSettings():Promise<main.MySQLSettings>;
|
export function GetMySQLSettings():Promise<main.MySQLSettings>;
|
||||||
|
|
||||||
export function GetOfflineStatus():Promise<main.OfflineStatus>;
|
export function GetOfflineStatus():Promise<main.OfflineStatus>;
|
||||||
@@ -511,6 +513,8 @@ export function GetRotatorHeading():Promise<main.RotatorHeading>;
|
|||||||
|
|
||||||
export function GetRotators():Promise<Array<main.RotatorDevice>>;
|
export function GetRotators():Promise<Array<main.RotatorDevice>>;
|
||||||
|
|
||||||
|
export function GetRotorPresets():Promise<Array<main.RotorPreset>>;
|
||||||
|
|
||||||
export function GetRowColors():Promise<main.RowColorSettings>;
|
export function GetRowColors():Promise<main.RowColorSettings>;
|
||||||
|
|
||||||
export function GetSPEStatus():Promise<spe.Status>;
|
export function GetSPEStatus():Promise<spe.Status>;
|
||||||
@@ -861,6 +865,8 @@ export function ReloadUDPIntegrations():Promise<Array<string>>;
|
|||||||
|
|
||||||
export function RemovePassphrase(arg1:string):Promise<void>;
|
export function 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 RenameDatabase(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function RenameLogbook(arg1:string):Promise<void>;
|
export function RenameLogbook(arg1:string):Promise<void>;
|
||||||
@@ -879,6 +885,8 @@ export function ResetAwardDefs():Promise<Array<award.Def>>;
|
|||||||
|
|
||||||
export function ResetDatabaseToDefault():Promise<void>;
|
export function ResetDatabaseToDefault():Promise<void>;
|
||||||
|
|
||||||
|
export function ResetRotorPresets():Promise<Array<main.RotorPreset>>;
|
||||||
|
|
||||||
export function RestartApp():Promise<void>;
|
export function RestartApp():Promise<void>;
|
||||||
|
|
||||||
export function RestartQSORecorder():Promise<void>;
|
export function RestartQSORecorder():Promise<void>;
|
||||||
@@ -947,6 +955,8 @@ export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
|
|||||||
|
|
||||||
export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
|
export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
|
||||||
|
|
||||||
|
export function SaveMatrixColors(arg1:main.MatrixColors):Promise<void>;
|
||||||
|
|
||||||
export function SaveMySQLSettings(arg1:main.MySQLSettings):Promise<void>;
|
export function SaveMySQLSettings(arg1:main.MySQLSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveOperatingAntenna(arg1:operating.Antenna):Promise<operating.Antenna>;
|
export function SaveOperatingAntenna(arg1:operating.Antenna):Promise<operating.Antenna>;
|
||||||
@@ -967,6 +977,8 @@ export function SaveRelayAuto(arg1:main.RelayAutoConfig):Promise<void>;
|
|||||||
|
|
||||||
export function SaveRotators(arg1:Array<main.RotatorDevice>):Promise<void>;
|
export function SaveRotators(arg1:Array<main.RotatorDevice>):Promise<void>;
|
||||||
|
|
||||||
|
export function SaveRotorPresets(arg1:Array<main.RotorPreset>):Promise<void>;
|
||||||
|
|
||||||
export function SaveRowColors(arg1:main.RowColorSettings):Promise<void>;
|
export function SaveRowColors(arg1:main.RowColorSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveSelfSpotSettings(arg1:main.SelfSpotSettings):Promise<void>;
|
export function SaveSelfSpotSettings(arg1:main.SelfSpotSettings):Promise<void>;
|
||||||
|
|||||||
@@ -894,6 +894,10 @@ export function GetLookupSettings() {
|
|||||||
return window['go']['main']['App']['GetLookupSettings']();
|
return window['go']['main']['App']['GetLookupSettings']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetMatrixColors() {
|
||||||
|
return window['go']['main']['App']['GetMatrixColors']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetMySQLSettings() {
|
export function GetMySQLSettings() {
|
||||||
return window['go']['main']['App']['GetMySQLSettings']();
|
return window['go']['main']['App']['GetMySQLSettings']();
|
||||||
}
|
}
|
||||||
@@ -962,6 +966,10 @@ export function GetRotators() {
|
|||||||
return window['go']['main']['App']['GetRotators']();
|
return window['go']['main']['App']['GetRotators']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetRotorPresets() {
|
||||||
|
return window['go']['main']['App']['GetRotorPresets']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetRowColors() {
|
export function GetRowColors() {
|
||||||
return window['go']['main']['App']['GetRowColors']();
|
return window['go']['main']['App']['GetRowColors']();
|
||||||
}
|
}
|
||||||
@@ -1662,6 +1670,10 @@ export function RemovePassphrase(arg1) {
|
|||||||
return window['go']['main']['App']['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) {
|
export function RenameDatabase(arg1) {
|
||||||
return window['go']['main']['App']['RenameDatabase'](arg1);
|
return window['go']['main']['App']['RenameDatabase'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1698,6 +1710,10 @@ export function ResetDatabaseToDefault() {
|
|||||||
return window['go']['main']['App']['ResetDatabaseToDefault']();
|
return window['go']['main']['App']['ResetDatabaseToDefault']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ResetRotorPresets() {
|
||||||
|
return window['go']['main']['App']['ResetRotorPresets']();
|
||||||
|
}
|
||||||
|
|
||||||
export function RestartApp() {
|
export function RestartApp() {
|
||||||
return window['go']['main']['App']['RestartApp']();
|
return window['go']['main']['App']['RestartApp']();
|
||||||
}
|
}
|
||||||
@@ -1834,6 +1850,10 @@ export function SaveLookupSettings(arg1) {
|
|||||||
return window['go']['main']['App']['SaveLookupSettings'](arg1);
|
return window['go']['main']['App']['SaveLookupSettings'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveMatrixColors(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveMatrixColors'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveMySQLSettings(arg1) {
|
export function SaveMySQLSettings(arg1) {
|
||||||
return window['go']['main']['App']['SaveMySQLSettings'](arg1);
|
return window['go']['main']['App']['SaveMySQLSettings'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1874,6 +1894,10 @@ export function SaveRotators(arg1) {
|
|||||||
return window['go']['main']['App']['SaveRotators'](arg1);
|
return window['go']['main']['App']['SaveRotators'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveRotorPresets(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveRotorPresets'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveRowColors(arg1) {
|
export function SaveRowColors(arg1) {
|
||||||
return window['go']['main']['App']['SaveRowColors'](arg1);
|
return window['go']['main']['App']['SaveRowColors'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2021,6 +2021,7 @@ export namespace main {
|
|||||||
flex_host: string;
|
flex_host: string;
|
||||||
flex_port: number;
|
flex_port: number;
|
||||||
flex_spots: boolean;
|
flex_spots: boolean;
|
||||||
|
flex_dvk_dax: boolean;
|
||||||
flex_decode_spots: boolean;
|
flex_decode_spots: boolean;
|
||||||
flex_decode_secs: number;
|
flex_decode_secs: number;
|
||||||
xiegu_port: string;
|
xiegu_port: string;
|
||||||
@@ -2047,9 +2048,13 @@ export namespace main {
|
|||||||
tci_spots: boolean;
|
tci_spots: boolean;
|
||||||
poll_ms: number;
|
poll_ms: number;
|
||||||
delay_ms: number;
|
delay_ms: number;
|
||||||
|
offset_on: boolean;
|
||||||
|
offset_hz: number;
|
||||||
digital_default: string;
|
digital_default: string;
|
||||||
share_enabled: boolean;
|
share_enabled: boolean;
|
||||||
share_port: number;
|
share_port: number;
|
||||||
|
share_proto: string;
|
||||||
|
share_tci_port: number;
|
||||||
ptt_hotkey_enabled: boolean;
|
ptt_hotkey_enabled: boolean;
|
||||||
ptt_hotkey: string;
|
ptt_hotkey: string;
|
||||||
ptt_hotkey_toggle: boolean;
|
ptt_hotkey_toggle: boolean;
|
||||||
@@ -2067,6 +2072,7 @@ export namespace main {
|
|||||||
this.flex_host = source["flex_host"];
|
this.flex_host = source["flex_host"];
|
||||||
this.flex_port = source["flex_port"];
|
this.flex_port = source["flex_port"];
|
||||||
this.flex_spots = source["flex_spots"];
|
this.flex_spots = source["flex_spots"];
|
||||||
|
this.flex_dvk_dax = source["flex_dvk_dax"];
|
||||||
this.flex_decode_spots = source["flex_decode_spots"];
|
this.flex_decode_spots = source["flex_decode_spots"];
|
||||||
this.flex_decode_secs = source["flex_decode_secs"];
|
this.flex_decode_secs = source["flex_decode_secs"];
|
||||||
this.xiegu_port = source["xiegu_port"];
|
this.xiegu_port = source["xiegu_port"];
|
||||||
@@ -2093,9 +2099,13 @@ export namespace main {
|
|||||||
this.tci_spots = source["tci_spots"];
|
this.tci_spots = source["tci_spots"];
|
||||||
this.poll_ms = source["poll_ms"];
|
this.poll_ms = source["poll_ms"];
|
||||||
this.delay_ms = source["delay_ms"];
|
this.delay_ms = source["delay_ms"];
|
||||||
|
this.offset_on = source["offset_on"];
|
||||||
|
this.offset_hz = source["offset_hz"];
|
||||||
this.digital_default = source["digital_default"];
|
this.digital_default = source["digital_default"];
|
||||||
this.share_enabled = source["share_enabled"];
|
this.share_enabled = source["share_enabled"];
|
||||||
this.share_port = source["share_port"];
|
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_enabled = source["ptt_hotkey_enabled"];
|
||||||
this.ptt_hotkey = source["ptt_hotkey"];
|
this.ptt_hotkey = source["ptt_hotkey"];
|
||||||
this.ptt_hotkey_toggle = source["ptt_hotkey_toggle"];
|
this.ptt_hotkey_toggle = source["ptt_hotkey_toggle"];
|
||||||
@@ -2679,6 +2689,30 @@ export namespace main {
|
|||||||
this.cache_ttl_days = source["cache_ttl_days"];
|
this.cache_ttl_days = source["cache_ttl_days"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class MatrixColors {
|
||||||
|
enabled: boolean;
|
||||||
|
call_confirmed: string;
|
||||||
|
call_worked: string;
|
||||||
|
entity_confirmed: string;
|
||||||
|
entity_worked: string;
|
||||||
|
not_worked: string;
|
||||||
|
current_entry: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new MatrixColors(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
this.call_confirmed = source["call_confirmed"];
|
||||||
|
this.call_worked = source["call_worked"];
|
||||||
|
this.entity_confirmed = source["entity_confirmed"];
|
||||||
|
this.entity_worked = source["entity_worked"];
|
||||||
|
this.not_worked = source["not_worked"];
|
||||||
|
this.current_entry = source["current_entry"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class MySQLSettings {
|
export class MySQLSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -3178,6 +3212,20 @@ export namespace main {
|
|||||||
this.motorized = source["motorized"];
|
this.motorized = source["motorized"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class RotorPreset {
|
||||||
|
label: string;
|
||||||
|
azimuth: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new RotorPreset(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.label = source["label"];
|
||||||
|
this.azimuth = source["azimuth"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class RowColorRule {
|
export class RowColorRule {
|
||||||
id: string;
|
id: string;
|
||||||
color: string;
|
color: string;
|
||||||
@@ -3375,6 +3423,7 @@ export namespace main {
|
|||||||
off_urls?: string[];
|
off_urls?: string[];
|
||||||
on_pattern?: string;
|
on_pattern?: string;
|
||||||
off_pattern?: string;
|
off_pattern?: string;
|
||||||
|
insecure_tls?: boolean;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new StationDevice(source);
|
return new StationDevice(source);
|
||||||
@@ -3394,6 +3443,7 @@ export namespace main {
|
|||||||
this.off_urls = source["off_urls"];
|
this.off_urls = source["off_urls"];
|
||||||
this.on_pattern = source["on_pattern"];
|
this.on_pattern = source["on_pattern"];
|
||||||
this.off_pattern = source["off_pattern"];
|
this.off_pattern = source["off_pattern"];
|
||||||
|
this.insecure_tls = source["insecure_tls"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class StationRelay {
|
export class StationRelay {
|
||||||
|
|||||||
+144
-143
@@ -46,11 +46,12 @@
|
|||||||
"qsl"
|
"qsl"
|
||||||
],
|
],
|
||||||
"total": 0,
|
"total": 0,
|
||||||
"builtin": true
|
"builtin": true,
|
||||||
|
"version": 2
|
||||||
},
|
},
|
||||||
"references": [
|
"references": [
|
||||||
{
|
{
|
||||||
"code": "1",
|
"code": "01",
|
||||||
"name": "Hokkaido",
|
"name": "Hokkaido",
|
||||||
"dxcc": 0,
|
"dxcc": 0,
|
||||||
"group": "",
|
"group": "",
|
||||||
@@ -58,16 +59,81 @@
|
|||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "10",
|
"code": "02",
|
||||||
"name": "Gunma",
|
"name": "Aomori",
|
||||||
"dxcc": 0,
|
"dxcc": 0,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
"valid": true
|
"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",
|
"code": "11",
|
||||||
"name": "Saitama",
|
"name": "Kanagawa",
|
||||||
"dxcc": 0,
|
"dxcc": 0,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
@@ -83,16 +149,15 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "13",
|
"code": "13",
|
||||||
"name": "Tokyo",
|
"name": "Saitama",
|
||||||
"dxcc": 0,
|
"dxcc": 0,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
"pattern": "\\bTok[iy]o\\b",
|
|
||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "14",
|
"code": "14",
|
||||||
"name": "Kanagawa",
|
"name": "Ibaraki",
|
||||||
"dxcc": 0,
|
"dxcc": 0,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
@@ -100,7 +165,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "15",
|
"code": "15",
|
||||||
"name": "Niigata",
|
"name": "Tochigi",
|
||||||
"dxcc": 0,
|
"dxcc": 0,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
@@ -108,7 +173,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "16",
|
"code": "16",
|
||||||
"name": "Toyama",
|
"name": "Gunma",
|
||||||
"dxcc": 0,
|
"dxcc": 0,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
@@ -116,22 +181,6 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "17",
|
"code": "17",
|
||||||
"name": "Ishikawa",
|
|
||||||
"dxcc": 0,
|
|
||||||
"group": "",
|
|
||||||
"subgrp": "",
|
|
||||||
"valid": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "18",
|
|
||||||
"name": "Fukui",
|
|
||||||
"dxcc": 0,
|
|
||||||
"group": "",
|
|
||||||
"subgrp": "",
|
|
||||||
"valid": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "19",
|
|
||||||
"name": "Yamanashi",
|
"name": "Yamanashi",
|
||||||
"dxcc": 0,
|
"dxcc": 0,
|
||||||
"group": "",
|
"group": "",
|
||||||
@@ -139,31 +188,7 @@
|
|||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "2",
|
"code": "18",
|
||||||
"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",
|
|
||||||
"name": "Shizuoka",
|
"name": "Shizuoka",
|
||||||
"dxcc": 0,
|
"dxcc": 0,
|
||||||
"group": "",
|
"group": "",
|
||||||
@@ -171,7 +196,15 @@
|
|||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "23",
|
"code": "19",
|
||||||
|
"name": "Gifu",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "20",
|
||||||
"name": "Aichi",
|
"name": "Aichi",
|
||||||
"dxcc": 0,
|
"dxcc": 0,
|
||||||
"group": "",
|
"group": "",
|
||||||
@@ -179,7 +212,7 @@
|
|||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "24",
|
"code": "21",
|
||||||
"name": "Mie",
|
"name": "Mie",
|
||||||
"dxcc": 0,
|
"dxcc": 0,
|
||||||
"group": "",
|
"group": "",
|
||||||
@@ -187,15 +220,7 @@
|
|||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "25",
|
"code": "22",
|
||||||
"name": "Shiga",
|
|
||||||
"dxcc": 0,
|
|
||||||
"group": "",
|
|
||||||
"subgrp": "",
|
|
||||||
"valid": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "26",
|
|
||||||
"name": "Kyoto",
|
"name": "Kyoto",
|
||||||
"dxcc": 0,
|
"dxcc": 0,
|
||||||
"group": "",
|
"group": "",
|
||||||
@@ -203,23 +228,15 @@
|
|||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "27",
|
"code": "23",
|
||||||
"name": "Osaka",
|
"name": "Shiga",
|
||||||
"dxcc": 0,
|
"dxcc": 0,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "28",
|
"code": "24",
|
||||||
"name": "Hyogo",
|
|
||||||
"dxcc": 0,
|
|
||||||
"group": "",
|
|
||||||
"subgrp": "",
|
|
||||||
"valid": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "29",
|
|
||||||
"name": "Nara",
|
"name": "Nara",
|
||||||
"dxcc": 0,
|
"dxcc": 0,
|
||||||
"group": "",
|
"group": "",
|
||||||
@@ -227,24 +244,56 @@
|
|||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "3",
|
"code": "25",
|
||||||
"name": "Iwate",
|
"name": "Osaka",
|
||||||
"dxcc": 0,
|
"dxcc": 0,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "30",
|
"code": "26",
|
||||||
"name": "Wakayama",
|
"name": "Wakayama",
|
||||||
"dxcc": 0,
|
"dxcc": 0,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
"valid": true
|
"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",
|
"code": "31",
|
||||||
"name": "Tottori",
|
"name": "Okayama",
|
||||||
"dxcc": 0,
|
"dxcc": 0,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
@@ -260,31 +309,31 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "33",
|
"code": "33",
|
||||||
"name": "Okayama",
|
|
||||||
"dxcc": 0,
|
|
||||||
"group": "",
|
|
||||||
"subgrp": "",
|
|
||||||
"valid": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "34",
|
|
||||||
"name": "Hiroshima",
|
|
||||||
"dxcc": 0,
|
|
||||||
"group": "",
|
|
||||||
"subgrp": "",
|
|
||||||
"valid": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"code": "35",
|
|
||||||
"name": "Yamaguchi",
|
"name": "Yamaguchi",
|
||||||
"dxcc": 0,
|
"dxcc": 0,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"code": "34",
|
||||||
|
"name": "Tottori",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "35",
|
||||||
|
"name": "Hiroshima",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"code": "36",
|
"code": "36",
|
||||||
"name": "Tokushima",
|
"name": "Kagawa",
|
||||||
"dxcc": 0,
|
"dxcc": 0,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
@@ -292,7 +341,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"code": "37",
|
"code": "37",
|
||||||
"name": "Kagawa",
|
"name": "Tokushima",
|
||||||
"dxcc": 0,
|
"dxcc": 0,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
@@ -314,14 +363,6 @@
|
|||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"code": "4",
|
|
||||||
"name": "Miyagi",
|
|
||||||
"dxcc": 0,
|
|
||||||
"group": "",
|
|
||||||
"subgrp": "",
|
|
||||||
"valid": true
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"code": "40",
|
"code": "40",
|
||||||
"name": "Fukuoka",
|
"name": "Fukuoka",
|
||||||
@@ -385,46 +426,6 @@
|
|||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
"valid": true
|
"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
|
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.
|
// Delete removes one reference from an award.
|
||||||
func (r *Repo) Delete(ctx context.Context, awardCode, refCode string) error {
|
func (r *Repo) Delete(ctx context.Context, awardCode, refCode string) error {
|
||||||
_, err := r.db.ExecContext(ctx,
|
_, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,6 +79,9 @@ type Manager struct {
|
|||||||
|
|
||||||
pollEvery time.Duration
|
pollEvery time.Duration
|
||||||
cmdDelay time.Duration // pause after each command (some rigs need it)
|
cmdDelay time.Duration // pause after each command (some rigs need it)
|
||||||
|
// freqOffset is the transverter offset in Hz — see SetFreqOffset. Added to
|
||||||
|
// what the rig reports, taken off what it is told.
|
||||||
|
freqOffset int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewManager(emit func(RigState)) *Manager {
|
func NewManager(emit func(RigState)) *Manager {
|
||||||
@@ -185,8 +188,39 @@ func (m *Manager) stopLocked() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetFreqOffset sets the transverter offset: the number of hertz between what
|
||||||
|
// the RIG is tuned to and where the station is actually on the air.
|
||||||
|
//
|
||||||
|
// A 28 MHz IF driving a 144 MHz transverter is an offset of +116 MHz. Everything
|
||||||
|
// above this layer — the entry form, the band, the log, the cluster, the shared
|
||||||
|
// CAT servers — then works in real frequencies, and the rig keeps seeing its own.
|
||||||
|
//
|
||||||
|
// Zero disables it, which is why it is a plain number and not a flag plus a
|
||||||
|
// number: "enabled with an offset of nothing" and "disabled" are the same thing
|
||||||
|
// to everyone downstream.
|
||||||
|
func (m *Manager) SetFreqOffset(hz int64) {
|
||||||
|
m.mu.Lock()
|
||||||
|
m.freqOffset = hz
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// freqOffsetHz reads the offset.
|
||||||
|
func (m *Manager) freqOffsetHz() int64 {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return m.freqOffset
|
||||||
|
}
|
||||||
|
|
||||||
// SetFrequency dispatches a SetFreq call to the CAT goroutine.
|
// SetFrequency dispatches a SetFreq call to the CAT goroutine.
|
||||||
|
//
|
||||||
|
// The caller speaks in REAL frequencies (a 2 m spot is 144.300), so the offset
|
||||||
|
// comes back off before the rig hears it. Without this the offset would be a
|
||||||
|
// display trick: the readout would say 144 and every spot click, band change and
|
||||||
|
// memory recall would send the rig somewhere 116 MHz away.
|
||||||
func (m *Manager) SetFrequency(hz int64) error {
|
func (m *Manager) SetFrequency(hz int64) error {
|
||||||
|
if off := m.freqOffsetHz(); off != 0 && hz > off {
|
||||||
|
hz -= off
|
||||||
|
}
|
||||||
return m.exec(func(b Backend) error { return b.SetFrequency(hz) })
|
return m.exec(func(b Backend) error { return b.SetFrequency(hz) })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,6 +249,10 @@ type splitSetter interface {
|
|||||||
// band when it was transmitting on the DX's own frequency. A refusal WSJT-X can
|
// band when it was transmitting on the DX's own frequency. A refusal WSJT-X can
|
||||||
// report is worth far more than a success it cannot check.
|
// report is worth far more than a success it cannot check.
|
||||||
func (m *Manager) SetSplit(on bool, txHz int64) error {
|
func (m *Manager) SetSplit(on bool, txHz int64) error {
|
||||||
|
// Real frequency in, IF frequency out — same as SetFrequency.
|
||||||
|
if off := m.freqOffsetHz(); off != 0 && txHz > off {
|
||||||
|
txHz -= off
|
||||||
|
}
|
||||||
return m.exec(func(b Backend) error {
|
return m.exec(func(b Backend) error {
|
||||||
s, ok := b.(splitSetter)
|
s, ok := b.(splitSetter)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -744,6 +782,20 @@ func (m *Manager) run(b Backend, stop, done chan struct{}, cmds chan func(), pol
|
|||||||
ns.Enabled = true
|
ns.Enabled = true
|
||||||
ns.Backend = b.Name()
|
ns.Backend = b.Name()
|
||||||
ns.UpdatedAt = time.Now()
|
ns.UpdatedAt = time.Now()
|
||||||
|
// Transverter offset: the rig reports its IF, the operator is on the
|
||||||
|
// real band. Applied BEFORE the band is worked out, or a 28 MHz IF
|
||||||
|
// behind a 2 m transverter would log every contact on 10 m — and the
|
||||||
|
// band the backend may already have filled in is the IF's, so it is
|
||||||
|
// recomputed rather than trusted.
|
||||||
|
if off := m.freqOffsetHz(); off != 0 {
|
||||||
|
if ns.FreqHz != 0 {
|
||||||
|
ns.FreqHz += off
|
||||||
|
ns.Band = ""
|
||||||
|
}
|
||||||
|
if ns.RxFreqHz != 0 {
|
||||||
|
ns.RxFreqHz += off
|
||||||
|
}
|
||||||
|
}
|
||||||
if ns.FreqHz != 0 && ns.Band == "" {
|
if ns.FreqHz != 0 && ns.Band == "" {
|
||||||
ns.Band = BandFromHz(ns.FreqHz)
|
ns.Band = BandFromHz(ns.FreqHz)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package cat
|
package cat
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -513,7 +514,8 @@ func (b *IcomSerial) SetFrequency(hz int64) error {
|
|||||||
return fmt.Errorf("invalid frequency")
|
return fmt.Errorf("invalid frequency")
|
||||||
}
|
}
|
||||||
b.lastSetFreq, b.lastSetFreqAt = hz, time.Now()
|
b.lastSetFreq, b.lastSetFreqAt = hz, time.Now()
|
||||||
return b.exec(append([]byte{civ.CmdSetFreq}, civ.FreqToBCD(hz)...)...)
|
return b.execIdempotent(fmt.Sprintf("set frequency %d Hz", hz),
|
||||||
|
append([]byte{civ.CmdSetFreq}, civ.FreqToBCD(hz)...)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *IcomSerial) SetMode(mode string) error {
|
func (b *IcomSerial) SetMode(mode string) error {
|
||||||
@@ -523,7 +525,7 @@ func (b *IcomSerial) SetMode(mode string) error {
|
|||||||
}
|
}
|
||||||
// Set the base mode (keeping the rig's current filter by sending only the
|
// Set the base mode (keeping the rig's current filter by sending only the
|
||||||
// mode byte), then set the data-mode flag for digital modes.
|
// mode byte), then set the data-mode flag for digital modes.
|
||||||
if err := b.exec(civ.CmdSetMode, code); err != nil {
|
if err := b.execIdempotent("set mode "+mode, civ.CmdSetMode, code); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
dataByte := byte(0)
|
dataByte := byte(0)
|
||||||
@@ -531,16 +533,48 @@ func (b *IcomSerial) SetMode(mode string) error {
|
|||||||
dataByte = 1
|
dataByte = 1
|
||||||
}
|
}
|
||||||
// Filter 0x01 (FIL1) is the conventional default for the data-mode set.
|
// Filter 0x01 (FIL1) is the conventional default for the data-mode set.
|
||||||
_ = b.exec(civ.CmdExtra, civ.SubDataMode, dataByte, 0x01)
|
_ = b.execIdempotent("set data mode", civ.CmdExtra, civ.SubDataMode, dataByte, 0x01)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// errIcomAckLost is "the reply never came" — as opposed to a reply that said no
|
||||||
|
// (NG) or a dead port. Only this one is worth repeating: the command may well
|
||||||
|
// have been carried out and only its acknowledgement lost. Sentinel rather than
|
||||||
|
// a formatted string so callers can tell the two apart.
|
||||||
|
var errIcomAckLost = errors.New("icom: timeout waiting for response")
|
||||||
|
|
||||||
|
// execIdempotent runs a SET command and sends it ONCE MORE if the
|
||||||
|
// acknowledgement is lost.
|
||||||
|
//
|
||||||
|
// A missing FB is not a missing command — the rig acts on the frame as soon as it
|
||||||
|
// decodes it, and what expires is our wait for the answer on a bus shared with
|
||||||
|
// the rig's own transceive updates. JTDX in "Split Operating: Fake It" moves the
|
||||||
|
// dial and the mode immediately before every key-down, so those acks queue behind
|
||||||
|
// each other, and losing one was fatal: rigctld answers RPRT -9, JTDX reads that
|
||||||
|
// as losing rig control and tears the connection down mid-over. An operator's log
|
||||||
|
// shows it happening on set_ptt, on set_freq and on set_mode alike, each failure
|
||||||
|
// followed within 300 ms by a fresh rigctld client — and shows this resend
|
||||||
|
// rescuing a PTT that would otherwise have ended the over.
|
||||||
|
//
|
||||||
|
// Only for commands that say "be in this state": re-sending one changes nothing
|
||||||
|
// if the first arrived. A relative or incremental command must not come through
|
||||||
|
// here, which is why this is opt-in per caller rather than folded into exec.
|
||||||
|
func (b *IcomSerial) execIdempotent(what string, payload ...byte) error {
|
||||||
|
err := b.exec(payload...)
|
||||||
|
if err == nil || !errors.Is(err, errIcomAckLost) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
applog.Printf("icom: %s — no acknowledgement in %s, sending it once more", what, icomCmdTimeout)
|
||||||
|
return b.exec(payload...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPTT keys or unkeys the transmitter (CI-V 0x1C 0x00).
|
||||||
func (b *IcomSerial) SetPTT(on bool) error {
|
func (b *IcomSerial) SetPTT(on bool) error {
|
||||||
state := byte(0)
|
state := byte(0)
|
||||||
if on {
|
if on {
|
||||||
state = 1
|
state = 1
|
||||||
}
|
}
|
||||||
return b.exec(civ.CmdPTT, civ.SubPTT, state)
|
return b.execIdempotent(fmt.Sprintf("PTT %v", on), civ.CmdPTT, civ.SubPTT, state)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetPower turns the transceiver on or off (CI-V 0x18). Power-ON is prefixed with
|
// SetPower turns the transceiver on or off (CI-V 0x18). Power-ON is prefixed with
|
||||||
@@ -619,7 +653,7 @@ func (b *IcomSerial) recv(timeout time.Duration, match func(civ.Decoded) bool) (
|
|||||||
case <-cancel:
|
case <-cancel:
|
||||||
return civ.Decoded{}, fmt.Errorf("icom: interrupted")
|
return civ.Decoded{}, fmt.Errorf("icom: interrupted")
|
||||||
case <-deadline:
|
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
|
mode string
|
||||||
split bool
|
split bool
|
||||||
tx 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)
|
lastSig string // last logged state signature (log only on change)
|
||||||
|
|
||||||
@@ -109,6 +121,10 @@ func (t *TCI) Connect() error {
|
|||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
t.conn = conn
|
t.conn = conn
|
||||||
t.ready = false
|
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()
|
t.mu.Unlock()
|
||||||
debugLog.Printf("TCI: connected to %s", url)
|
debugLog.Printf("TCI: connected to %s", url)
|
||||||
go t.reader(conn)
|
go t.reader(conn)
|
||||||
@@ -298,7 +314,22 @@ func (t *TCI) SetMode(mode string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SetPTT keys or unkeys the transmitter (VFO 0).
|
// 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 {
|
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))
|
return t.send(fmt.Sprintf("trx:0,%t;", on))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,6 +423,14 @@ func (t *TCI) handle(msg string) {
|
|||||||
if get(0) == "0" {
|
if get(0) == "0" {
|
||||||
t.tx = get(1) == "true"
|
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:
|
default:
|
||||||
lname := strings.ToLower(name)
|
lname := strings.ToLower(name)
|
||||||
// A click on one of our panorama spots comes back as
|
// 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"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hamlog/internal/applog"
|
"hamlog/internal/applog"
|
||||||
@@ -355,6 +356,18 @@ func (s *session) run() {
|
|||||||
// that gets skipped.
|
// that gets skipped.
|
||||||
var idleTick = 30 * time.Second
|
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.
|
// quietNotice is the silence after which the log says so, once.
|
||||||
const quietNotice = 10 * time.Minute
|
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
|
// Login: send on first prompt OR blindly after 1.5s. Many DXSpider
|
||||||
// nodes accept the callsign without re-prompting.
|
// 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 != "" {
|
if s.login != "" {
|
||||||
go func() {
|
go func() {
|
||||||
select {
|
select {
|
||||||
case <-s.stopCh:
|
case <-s.stopCh:
|
||||||
return
|
return
|
||||||
case <-time.After(1500 * time.Millisecond):
|
case <-time.After(1500 * time.Millisecond):
|
||||||
if !loginSent {
|
sendLogin()
|
||||||
_, _ = conn.Write([]byte(s.login + "\r\n"))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
@@ -453,7 +487,21 @@ func (s *session) runOnce() (time.Time, error) {
|
|||||||
|
|
||||||
var connectedAt time.Time
|
var connectedAt time.Time
|
||||||
var quiet time.Duration // how long the node has said nothing
|
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
|
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)
|
rd := bufio.NewReader(conn)
|
||||||
for {
|
for {
|
||||||
select {
|
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:
|
// Only a REAL error ends the session. A dead peer still gets caught:
|
||||||
// TCP keepalive probes an idle connection and its failure arrives here
|
// TCP keepalive probes an idle connection and its failure arrives here
|
||||||
// as an error, not as a timeout.
|
// 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')
|
chunk, err := rd.ReadString('\n')
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var ne net.Error
|
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
|
// Keep it: a spot line straddling the deadline would otherwise lose
|
||||||
// its first half and arrive as nonsense, or vanish entirely.
|
// its first half and arrive as nonsense, or vanish entirely.
|
||||||
pending += chunk
|
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
|
// Said once at the first long silence, so a genuinely mute node is
|
||||||
// visible without a line every tick.
|
// 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)
|
applog.Printf("cluster[%s] no traffic for %s — still connected", s.cfg.Name, quiet)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return connectedAt, fmt.Errorf("read: %w", err)
|
return connectedAt, fmt.Errorf("read: %w", err)
|
||||||
}
|
}
|
||||||
|
quietNoticed = false
|
||||||
quiet = 0
|
quiet = 0
|
||||||
line := pending + chunk
|
line := pending + chunk
|
||||||
pending = ""
|
pending = ""
|
||||||
@@ -519,28 +613,23 @@ func (s *session) runOnce() (time.Time, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login on explicit prompt.
|
// Login on explicit prompt — the case where the node DID terminate it with
|
||||||
if !loginSent && s.login != "" && isLoginPrompt(line) {
|
// a newline. The newline-less form is handled on the timeout path above.
|
||||||
_, _ = conn.Write([]byte(s.login + "\r\n"))
|
if !loginSent.Load() && s.login != "" && isLoginPrompt(line) {
|
||||||
loginSent = true
|
s.emitLine(line, false)
|
||||||
|
sendLogin()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Password on prompt (rare).
|
// Password on prompt.
|
||||||
if loginSent && s.cfg.Password != "" && isPasswordPrompt(line) {
|
if !pwdSent.Load() && isPasswordPrompt(line) {
|
||||||
_, _ = conn.Write([]byte(s.cfg.Password + "\r\n"))
|
s.emitLine(line, false)
|
||||||
|
sendPassword()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark connected once we've sent login OR seen a welcome banner.
|
// Mark connected once we've sent login OR seen a welcome banner.
|
||||||
if s.snapshot().State != StateConnected && (loginSent || isWelcome(line)) {
|
if loginSent.Load() || isWelcome(line) {
|
||||||
connectedAt = time.Now()
|
markConnected()
|
||||||
s.mu.Lock()
|
|
||||||
s.status.State = StateConnected
|
|
||||||
s.status.ConnectedAt = connectedAt
|
|
||||||
s.status.Error = ""
|
|
||||||
s.mu.Unlock()
|
|
||||||
s.emitStatus()
|
|
||||||
fireInitCommands()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// EVERY line goes to the console — spot or not. This is the whole point:
|
// 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
|
}, 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 {
|
func isLoginPrompt(s string) bool {
|
||||||
low := strings.ToLower(s)
|
low := strings.ToLower(s)
|
||||||
return strings.Contains(low, "login:") ||
|
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 == "" {
|
if api == "" {
|
||||||
api = clublogAppAPIKey
|
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
|
var buf bytes.Buffer
|
||||||
mw := multipart.NewWriter(&buf)
|
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)
|
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
|
// eqslReason trims an eQSL reply to a short human-readable reason: the first
|
||||||
// "Error:" / "Warning:" / "Bad record:" line if present, else the whole body
|
// "Error:" / "Warning:" / "Bad record:" line if present, else the whole body
|
||||||
// (capped), else a generic phrase.
|
// (capped), else a generic phrase.
|
||||||
|
|||||||
+177
-27
@@ -295,9 +295,10 @@ func (m *Manager) CloseUploadCount() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// FlushOnClose uploads every QSO due for an on-close push, scanning the whole
|
// 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
|
// logbook (not just this session). Called from the shutdown sequence. QRZ and
|
||||||
// Log go one-by-one (fast HTTP); LoTW is signed and uploaded as a single TQSL
|
// the rest go one-by-one (fast HTTP, no batch API); LoTW is signed and uploaded
|
||||||
// batch. Returns the number of QSOs uploaded successfully.
|
// 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 {
|
func (m *Manager) FlushOnClose() int {
|
||||||
if m.deps.CloseUploadIDs == nil {
|
if m.deps.CloseUploadIDs == nil {
|
||||||
return 0
|
return 0
|
||||||
@@ -312,38 +313,187 @@ func (m *Manager) FlushOnClose() int {
|
|||||||
switch svc {
|
switch svc {
|
||||||
case ServiceLoTW:
|
case ServiceLoTW:
|
||||||
uploaded += m.flushLoTWBatch(ids, cfg.LoTW)
|
uploaded += m.flushLoTWBatch(ids, cfg.LoTW)
|
||||||
case ServiceQRZ:
|
|
||||||
for _, id := range ids {
|
|
||||||
if ok, _ := m.upload(svc, id, cfg.QRZ); ok {
|
|
||||||
uploaded++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case ServiceClublog:
|
case ServiceClublog:
|
||||||
for _, id := range ids {
|
uploaded += m.flushClublogBatch(ids, cfg.Clublog)
|
||||||
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++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case ServiceEQSL:
|
case ServiceEQSL:
|
||||||
for _, id := range ids {
|
uploaded += m.flushEQSLBatch(ids, cfg.EQSL)
|
||||||
if ok, _ := m.upload(svc, id, cfg.EQSL); ok {
|
case ServiceQRZ:
|
||||||
uploaded++
|
uploaded += m.flushOneByOne(svc, ids, cfg.QRZ)
|
||||||
}
|
case ServiceHRDLog:
|
||||||
}
|
uploaded += m.flushOneByOne(svc, ids, cfg.HRDLog)
|
||||||
case ServiceCloudlog:
|
case ServiceCloudlog:
|
||||||
for _, id := range ids {
|
uploaded += m.flushOneByOne(svc, ids, cfg.Cloudlog)
|
||||||
if ok, _ := m.upload(svc, id, cfg.Cloudlog); ok {
|
}
|
||||||
|
}
|
||||||
|
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++
|
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
|
return uploaded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -195,10 +195,7 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
|
|||||||
// operator was looking the call up for, and it came back empty while
|
// operator was looking the call up for, and it came back empty while
|
||||||
// the same lookup without the suffix answered perfectly.
|
// the same lookup without the suffix answered perfectly.
|
||||||
if !saysNothingAboutLocation(call) {
|
if !saysNothingAboutLocation(call) {
|
||||||
r.Country, r.Continent = "", ""
|
clearHomeLocation(&r)
|
||||||
r.CQZ, r.ITUZ, r.DXCC = 0, 0, 0
|
|
||||||
r.Lat, r.Lon = 0, 0
|
|
||||||
r.Grid, r.State, r.County = "", "", ""
|
|
||||||
}
|
}
|
||||||
fillFromDXCC(&r, dxcc) // entity/zones/lat-lon from the FULL (slashed) call
|
fillFromDXCC(&r, dxcc) // entity/zones/lat-lon from the FULL (slashed) call
|
||||||
normalizeNames(&r)
|
normalizeNames(&r)
|
||||||
@@ -378,6 +375,17 @@ func titleCase(s string) string {
|
|||||||
// the DXCC number since QRZ's value is wrong and we don't have an entity
|
// the DXCC number since QRZ's value is wrong and we don't have an entity
|
||||||
// → DXCC# table yet.
|
// → DXCC# table yet.
|
||||||
// Returns true if any field was filled.
|
// Returns true if any field was filled.
|
||||||
|
// clearHomeLocation drops the fields that say WHERE a callbook record's operator
|
||||||
|
// lives, and keeps the ones that say WHO they are — name, address, QSL route.
|
||||||
|
// A portable operator's cards still go to the home address, so that address is
|
||||||
|
// not wrong; their county is.
|
||||||
|
func clearHomeLocation(r *Result) {
|
||||||
|
r.Country, r.Continent = "", ""
|
||||||
|
r.CQZ, r.ITUZ, r.DXCC = 0, 0, 0
|
||||||
|
r.Lat, r.Lon = 0, 0
|
||||||
|
r.Grid, r.State, r.County = "", "", ""
|
||||||
|
}
|
||||||
|
|
||||||
func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
|
func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
|
||||||
if dxcc == nil {
|
if dxcc == nil {
|
||||||
return false
|
return false
|
||||||
@@ -387,6 +395,33 @@ func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
filled := false
|
filled := false
|
||||||
|
|
||||||
|
// A subdivision belongs to an ENTITY, so a record describing one entity has
|
||||||
|
// nothing to say about a station operating from another.
|
||||||
|
//
|
||||||
|
// TI8/W2RE came back as a Costa Rica contact in Dutchess County, New York, on
|
||||||
|
// square FN31 — W2RE's home details, from a QRZ page that carries the home
|
||||||
|
// mailing address as most portable pages do. CNTY is *defined* as a US
|
||||||
|
// county, so that is not a cosmetic slip: it is a US county award credit
|
||||||
|
// recorded against a Costa Rica QSO, and a distance and beam heading computed
|
||||||
|
// from a square 3,600 km from where the station actually is.
|
||||||
|
//
|
||||||
|
// The home-call fallback above already clears these, but it only runs when NO
|
||||||
|
// provider had the slashed form. An operator with a page for their portable
|
||||||
|
// call never reached it. Cleared here instead, where the operating entity is
|
||||||
|
// known — which also heals rows already in the cache, since every cache hit
|
||||||
|
// comes back through here.
|
||||||
|
//
|
||||||
|
// Same-entity portables (F4BPO/P, W2RE/2) are untouched: the entities match,
|
||||||
|
// and there the home details ARE where the operator is.
|
||||||
|
if dxccNum != 0 && strings.ContainsRune(r.Callsign, '/') && !saysNothingAboutLocation(r.Callsign) {
|
||||||
|
if home := homeCall(r.Callsign); home != "" && home != r.Callsign {
|
||||||
|
if homeNum, _, _, _, _, _, _, homeOK := dxcc.Resolve(home); homeOK && homeNum != 0 && homeNum != dxccNum {
|
||||||
|
clearHomeLocation(r)
|
||||||
|
filled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if country != "" {
|
if country != "" {
|
||||||
r.Country = country
|
r.Country = country
|
||||||
filled = true
|
filled = true
|
||||||
@@ -432,11 +467,21 @@ func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
|
|||||||
// ----- Cache -----
|
// ----- Cache -----
|
||||||
|
|
||||||
// Cache is a SQLite-backed cache of lookup results with a TTL.
|
// 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 {
|
type Cache struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
ttl time.Duration
|
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 {
|
func NewCache(db *sql.DB, ttl time.Duration) *Cache {
|
||||||
if ttl <= 0 {
|
if ttl <= 0 {
|
||||||
ttl = 30 * 24 * time.Hour
|
ttl = 30 * 24 * time.Hour
|
||||||
@@ -444,15 +489,25 @@ func NewCache(db *sql.DB, ttl time.Duration) *Cache {
|
|||||||
return &Cache{db: db, ttl: ttl}
|
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) {
|
func (c *Cache) SetTTL(ttl time.Duration) {
|
||||||
if ttl > 0 {
|
if ttl >= 0 {
|
||||||
c.ttl = ttl
|
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.
|
// Get returns the cached result if present and not expired.
|
||||||
func (c *Cache) Get(ctx context.Context, callsign string) (Result, bool) {
|
func (c *Cache) Get(ctx context.Context, callsign string) (Result, bool) {
|
||||||
|
if !c.Enabled() {
|
||||||
|
return Result{}, false
|
||||||
|
}
|
||||||
row := c.db.QueryRowContext(ctx, `
|
row := c.db.QueryRowContext(ctx, `
|
||||||
SELECT callsign, name, qth, address, state, cnty, country, grid,
|
SELECT callsign, name, qth, address, state, cnty, country, grid,
|
||||||
lat, lon, dxcc, cqz, ituz, cont, email, qsl_via, image_url,
|
lat, lon, dxcc, cqz, ituz, cont, email, qsl_via, image_url,
|
||||||
@@ -519,6 +574,11 @@ func (c *Cache) Get(ctx context.Context, callsign string) (Result, bool) {
|
|||||||
// Put upserts a lookup result. fetched_at is generated in Go (NowISO) so the
|
// Put upserts a lookup result. fetched_at is generated in Go (NowISO) so the
|
||||||
// INSERT is backend-agnostic; the conflict tail is dialect-specific.
|
// INSERT is backend-agnostic; the conflict tail is dialect-specific.
|
||||||
func (c *Cache) Put(ctx context.Context, r Result) error {
|
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{
|
updateCols := []string{
|
||||||
"name", "qth", "address", "state", "cnty",
|
"name", "qth", "address", "state", "cnty",
|
||||||
"country", "grid", "lat", "lon",
|
"country", "grid", "lat", "lon",
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package lookup
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// fakeDXCC resolves a handful of calls, enough to stand in for cty.dat.
|
||||||
|
type fakeDXCC map[string]struct {
|
||||||
|
num int
|
||||||
|
country string
|
||||||
|
cont string
|
||||||
|
cqz, ituz int
|
||||||
|
lat, lon float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fakeDXCC) Resolve(call string) (int, string, string, int, int, float64, float64, bool) {
|
||||||
|
e, ok := f[call]
|
||||||
|
if !ok {
|
||||||
|
return 0, "", "", 0, 0, 0, 0, false
|
||||||
|
}
|
||||||
|
return e.num, e.country, e.cont, e.cqz, e.ituz, e.lat, e.lon, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDXCC() fakeDXCC {
|
||||||
|
return fakeDXCC{
|
||||||
|
// Costa Rica, as cty.dat reads the OPERATING call.
|
||||||
|
"TI8/W2RE": {num: 308, country: "Costa Rica", cont: "NA", cqz: 7, ituz: 11, lat: 9.9, lon: -84.1},
|
||||||
|
// The home call: United States.
|
||||||
|
"W2RE": {num: 291, country: "United States", cont: "NA", cqz: 5, ituz: 8, lat: 39.8, lon: -98.5},
|
||||||
|
// A same-entity portable: still France either way.
|
||||||
|
"F4BPO/P": {num: 227, country: "France", cont: "EU", cqz: 14, ituz: 27, lat: 46.2, lon: 2.2},
|
||||||
|
"F4BPO": {num: 227, country: "France", cont: "EU", cqz: 14, ituz: 27, lat: 46.2, lon: 2.2},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A callbook record for a portable call routinely carries the operator's HOME
|
||||||
|
// address — most QRZ pages for a portable call do — and a subdivision belongs to
|
||||||
|
// an entity. Carrying the home county across an entity change is not cosmetic:
|
||||||
|
// CNTY is defined as a US county, so TI8/W2RE was coming out as a Costa Rica
|
||||||
|
// contact credited to Dutchess County, New York, with the distance and beam
|
||||||
|
// heading taken from a square 3,600 km from where the station actually was.
|
||||||
|
func TestPortableInAnotherEntityDropsTheHomeSubdivision(t *testing.T) {
|
||||||
|
r := Result{
|
||||||
|
Callsign: "TI8/W2RE",
|
||||||
|
Name: "Raymond", // who they are — kept
|
||||||
|
QTH: "Poughquag",
|
||||||
|
Address: "499 Pleasant Ridge Road", // cards still go there — kept
|
||||||
|
State: "NY",
|
||||||
|
County: "Dutchess",
|
||||||
|
Grid: "FN31",
|
||||||
|
Lat: 41.6, Lon: -73.7,
|
||||||
|
DXCC: 291,
|
||||||
|
}
|
||||||
|
fillFromDXCC(&r, testDXCC())
|
||||||
|
|
||||||
|
if r.County != "" {
|
||||||
|
t.Errorf("county = %q, want empty — a US county on a Costa Rica QSO is a false award credit", r.County)
|
||||||
|
}
|
||||||
|
if r.State != "" {
|
||||||
|
t.Errorf("state = %q, want empty — a subdivision of the entity that is not being worked", r.State)
|
||||||
|
}
|
||||||
|
if r.Grid != "" {
|
||||||
|
t.Errorf("grid = %q, want empty — it is the home square, 3,600 km from the operation", r.Grid)
|
||||||
|
}
|
||||||
|
// The entity and its centroid take over, so distance and bearing mean
|
||||||
|
// something again.
|
||||||
|
if r.DXCC != 308 || r.Country != "Costa Rica" {
|
||||||
|
t.Errorf("entity = %d %q, want 308 Costa Rica", r.DXCC, r.Country)
|
||||||
|
}
|
||||||
|
if r.Lat != 9.9 || r.Lon != -84.1 {
|
||||||
|
t.Errorf("lat/lon = %v/%v, want the Costa Rica centroid — the home coordinates must not survive", r.Lat, r.Lon)
|
||||||
|
}
|
||||||
|
// Who they are, and where their cards go, is unchanged.
|
||||||
|
if r.Name != "Raymond" || r.Address != "499 Pleasant Ridge Road" {
|
||||||
|
t.Errorf("name/address were cleared (%q / %q) — a portable operator's post still reaches home", r.Name, r.Address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The other half of the rule: a portable WITHIN the same entity is at home as
|
||||||
|
// far as the entity is concerned, and its details must survive untouched.
|
||||||
|
func TestSameEntityPortableKeepsItsLocation(t *testing.T) {
|
||||||
|
r := Result{
|
||||||
|
Callsign: "F4BPO/P",
|
||||||
|
State: "77", County: "Seine-et-Marne", Grid: "JN18cs",
|
||||||
|
Lat: 48.8, Lon: 2.4, DXCC: 227,
|
||||||
|
}
|
||||||
|
fillFromDXCC(&r, testDXCC())
|
||||||
|
|
||||||
|
if r.Grid != "JN18cs" || r.County != "Seine-et-Marne" || r.State != "77" {
|
||||||
|
t.Errorf("a same-entity portable lost its location: grid=%q county=%q state=%q", r.Grid, r.County, r.State)
|
||||||
|
}
|
||||||
|
if r.Lat != 48.8 || r.Lon != 2.4 {
|
||||||
|
t.Errorf("lat/lon = %v/%v — the precise home position was replaced by the entity centroid", r.Lat, r.Lon)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package qso
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoTW never hands back the submode it was given: every digital contact comes
|
||||||
|
// back as the mode GROUP, "DATA". Matched on the exact mode string that is
|
||||||
|
// simply never equal, so an operator downloading their confirmations was told
|
||||||
|
// their own QSOs were not in their log — reported for FT2 contacts confirmed as
|
||||||
|
// DATA, and true of FT4 and FT8 alike.
|
||||||
|
func TestClassKeyMatchesAConfirmationCarryingTheModeGroup(t *testing.T) {
|
||||||
|
r := openRepo(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
when := time.Date(2026, 8, 16, 16, 29, 0, 0, time.UTC)
|
||||||
|
id, err := r.Add(ctx, QSO{Callsign: "F1NQP", QSODate: when, Band: "20m", Mode: "FT2"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
minute := when.Format("2006-01-02T15:04")
|
||||||
|
|
||||||
|
exact, err := r.DedupeKeyIDs(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, found := exact[DedupeKey("F1NQP", minute, "20m", "DATA")]; found {
|
||||||
|
t.Fatal("the exact index matched DATA against FT2 — the test proves nothing")
|
||||||
|
}
|
||||||
|
|
||||||
|
byClass, err := r.DedupeClassKeyIDs(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, found := byClass[DedupeClassKey("F1NQP", minute, "20m", "DATA")]
|
||||||
|
if !found || got != id {
|
||||||
|
t.Errorf("class match = (%d, %v), want (%d, true) — the confirmation would be reported as having no local QSO", got, found, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two digital contacts with the same station, same band, same minute is barely
|
||||||
|
// physical — but if it happens, stamping the confirmation on whichever row the
|
||||||
|
// map happened to keep is a silent error in an award credit. Ambiguity maps to
|
||||||
|
// 0 so the caller reports it unmatched instead of guessing.
|
||||||
|
func TestAnAmbiguousClassKeyIsRefusedRatherThanGuessed(t *testing.T) {
|
||||||
|
r := openRepo(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
when := time.Date(2026, 8, 16, 16, 29, 0, 0, time.UTC)
|
||||||
|
for _, m := range []string{"FT8", "RTTY"} {
|
||||||
|
if _, err := r.Add(ctx, QSO{Callsign: "F1NQP", QSODate: when, Band: "20m", Mode: m}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
byClass, err := r.DedupeClassKeyIDs(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := byClass[DedupeClassKey("F1NQP", when.Format("2006-01-02T15:04"), "20m", "DATA")]; got != 0 {
|
||||||
|
t.Errorf("ambiguous class key resolved to %d — one of two QSOs was picked at random", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phone and CW keep their own classes: a CW confirmation must never land on an
|
||||||
|
// SSB contact just because the rest of the key agrees.
|
||||||
|
func TestClassMatchDoesNotCrossPhoneAndCW(t *testing.T) {
|
||||||
|
r := openRepo(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
when := time.Date(2026, 8, 16, 16, 29, 0, 0, time.UTC)
|
||||||
|
if _, err := r.Add(ctx, QSO{Callsign: "F1NQP", QSODate: when, Band: "20m", Mode: "SSB"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
byClass, err := r.DedupeClassKeyIDs(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
minute := when.Format("2006-01-02T15:04")
|
||||||
|
if _, found := byClass[DedupeClassKey("F1NQP", minute, "20m", "CW")]; found {
|
||||||
|
t.Error("a CW confirmation matched an SSB contact")
|
||||||
|
}
|
||||||
|
if _, found := byClass[DedupeClassKey("F1NQP", minute, "20m", "USB")]; !found {
|
||||||
|
t.Error("USB did not match the SSB contact — the phone sidebands are one class")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2691,6 +2691,51 @@ func (r *Repo) DedupeKeyIDs(ctx context.Context) (map[string]int64, error) {
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DedupeClassKey is DedupeKey with the mode collapsed to its CLASS (Phone / CW /
|
||||||
|
// Digital).
|
||||||
|
//
|
||||||
|
// For matching a downloaded confirmation whose mode is not the one you logged.
|
||||||
|
// LoTW does not hand back the submode it was given: a contact uploaded as FT4,
|
||||||
|
// FT8 or anything else digital comes back as the mode GROUP, "DATA". Matched on
|
||||||
|
// the exact string that is simply never equal, and the operator is told their
|
||||||
|
// own QSO is not in their log — which is how a confirmed contact goes unrecorded.
|
||||||
|
func DedupeClassKey(callsign, qsoDateMinute, band, mode string) string {
|
||||||
|
return strings.ToUpper(callsign) + "|" + qsoDateMinute + "|" + strings.ToLower(band) + "|" + modeClass(mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DedupeClassKeyIDs is DedupeKeyIDs at mode-CLASS granularity, for the second
|
||||||
|
// pass when the exact key misses.
|
||||||
|
//
|
||||||
|
// A key shared by more than one QSO maps to 0 — AMBIGUOUS, not "pick one". Two
|
||||||
|
// contacts with the same station, on the same band, in the same minute, in two
|
||||||
|
// digital modes is barely physical; but if it ever happens, stamping the
|
||||||
|
// confirmation on whichever row the map happened to keep would be a silent
|
||||||
|
// error in someone's award credit. Better to report it unmatched.
|
||||||
|
func (r *Repo) DedupeClassKeyIDs(ctx context.Context) (map[string]int64, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
|
SELECT id, callsign, substr(qso_date, 1, 16), band, mode
|
||||||
|
FROM qso`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := make(map[string]int64, 1024)
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
var call, when, band, mode string
|
||||||
|
if err := rows.Scan(&id, &call, &when, &band, &mode); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
k := DedupeClassKey(call, when, band, mode)
|
||||||
|
if prev, seen := out[k]; seen && prev != id {
|
||||||
|
out[k] = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[k] = id
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// matchRef is one local QSO's time + id, for time-window confirmation matching.
|
// matchRef is one local QSO's time + id, for time-window confirmation matching.
|
||||||
type matchRef struct {
|
type matchRef struct {
|
||||||
when time.Time
|
when time.Time
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ type httpGen struct {
|
|||||||
user string
|
user string
|
||||||
pass string
|
pass string
|
||||||
count int
|
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
|
mu sync.Mutex
|
||||||
state []bool
|
state []bool
|
||||||
@@ -64,7 +67,7 @@ type httpGen struct {
|
|||||||
// NewHTTPGeneric builds the driver. onURLs/offURLs are per relay (index 0 =
|
// 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
|
// relay 1) and may be short or hold empty entries; onPat/offPat are the
|
||||||
// fallback patterns; labels are the relay names {value} substitutes.
|
// 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 {
|
if count <= 0 {
|
||||||
count = len(onURLs)
|
count = len(onURLs)
|
||||||
}
|
}
|
||||||
@@ -74,7 +77,7 @@ func NewHTTPGeneric(onURLs, offURLs []string, onPat, offPat, user, pass string,
|
|||||||
return &httpGen{
|
return &httpGen{
|
||||||
onURLs: onURLs, offURLs: offURLs,
|
onURLs: onURLs, offURLs: offURLs,
|
||||||
onPat: onPat, offPat: offPat, labels: labels,
|
onPat: onPat, offPat: offPat, labels: labels,
|
||||||
user: user, pass: pass, count: count,
|
user: user, pass: pass, count: count, insecure: insecure,
|
||||||
state: make([]bool, count),
|
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 := h.urlFor(relay, on)
|
||||||
u = withScheme(u)
|
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
|
return err
|
||||||
}
|
}
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ func TestHTTPGenericPattern(t *testing.T) {
|
|||||||
|
|
||||||
d := NewHTTPGeneric(nil, nil,
|
d := NewHTTPGeneric(nil, nil,
|
||||||
srv.URL+"/relay?n={relay}&state=on",
|
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 {
|
if err := d.Set(context.Background(), 2, true); err != nil {
|
||||||
t.Fatalf("Set on: %v", err)
|
t.Fatalf("Set on: %v", err)
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,7 @@ func TestHTTPGenericPerRelayURLsWinOverThePattern(t *testing.T) {
|
|||||||
d := NewHTTPGeneric(
|
d := NewHTTPGeneric(
|
||||||
[]string{srv.URL + "/FF0101", "", srv.URL + "/weird/on"},
|
[]string{srv.URL + "/FF0101", "", srv.URL + "/weird/on"},
|
||||||
[]string{srv.URL + "/FF0100", "", ""},
|
[]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(), 1, true) // its own URL
|
||||||
_ = d.Set(context.Background(), 2, true) // empty → falls back to the pattern
|
_ = 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
|
[]string{srv.URL + "/relay?on={value}"}, // per-relay URL
|
||||||
nil,
|
nil,
|
||||||
"", srv.URL+"/relay?off={value}", // and the pattern, for the other direction
|
"", 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(), 1, true)
|
||||||
_ = d.Set(context.Background(), 2, false)
|
_ = d.Set(context.Background(), 2, false)
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
@@ -107,7 +107,7 @@ func TestHTTPGenericRelayOffset(t *testing.T) {
|
|||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
d := NewHTTPGeneric(nil, nil,
|
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(), 1, true)
|
||||||
_ = d.Set(context.Background(), 4, false)
|
_ = d.Set(context.Background(), 4, false)
|
||||||
mu.Lock()
|
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
|
// movement. It must be refused, and the message must say the label is what is
|
||||||
// missing.
|
// missing.
|
||||||
func TestHTTPGenericRefusesValueWithoutALabel(t *testing.T) {
|
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)
|
err := d.Set(context.Background(), 1, true)
|
||||||
if err == nil || !strings.Contains(err.Error(), "label") {
|
if err == nil || !strings.Contains(err.Error(), "label") {
|
||||||
t.Errorf("err = %v, want it to name the missing label", err)
|
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
|
// 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.
|
// name the direction, or the operator cannot tell which half is missing.
|
||||||
func TestHTTPGenericNamesTheMissingDirection(t *testing.T) {
|
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)
|
err := d.Set(context.Background(), 1, false)
|
||||||
if err == nil || !strings.Contains(err.Error(), "OFF") {
|
if err == nil || !strings.Contains(err.Error(), "OFF") {
|
||||||
t.Errorf("err = %v, want it to name the OFF direction", err)
|
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) {
|
func TestHTTPGenericRemembersWhatItCommanded(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||||
defer srv.Close()
|
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)
|
_ = d.Set(context.Background(), 2, true)
|
||||||
st, err := d.Status(context.Background())
|
st, err := d.Status(context.Background())
|
||||||
if err != nil {
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -40,8 +43,47 @@ type Device interface {
|
|||||||
|
|
||||||
func httpClient() *http.Client { return &http.Client{Timeout: 5 * time.Second} }
|
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.
|
// 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)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -49,9 +91,13 @@ func get(ctx context.Context, url, user, pass string) ([]byte, error) {
|
|||||||
if user != "" || pass != "" {
|
if user != "" || pass != "" {
|
||||||
req.SetBasicAuth(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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, certError(err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
@@ -98,7 +144,7 @@ func (w *webswitch) Set(ctx context.Context, relay int, on bool) error {
|
|||||||
if on {
|
if on {
|
||||||
action = "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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +155,7 @@ func (w *webswitch) Status(ctx context.Context) ([]bool, error) {
|
|||||||
sel.WriteString(strconv.Itoa(i))
|
sel.WriteString(strconv.Itoa(i))
|
||||||
sel.WriteByte('$')
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -156,7 +202,7 @@ func (k *kmtronic) Set(ctx context.Context, relay int, on bool) error {
|
|||||||
state = "01"
|
state = "01"
|
||||||
}
|
}
|
||||||
// FF<rr><ss>: e.g. FF0101 = relay 1 on, FF0800 = relay 8 off.
|
// 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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,7 +216,7 @@ type kmStatus struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (k *kmtronic) Status(ctx context.Context) ([]bool, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,673 @@
|
|||||||
|
// Package tciserver shares OpsLog's CAT link with programs that speak TCI.
|
||||||
|
//
|
||||||
|
// It is the second half of internal/rigctld, and exists for the same reason:
|
||||||
|
// Windows gives a COM port to ONE process, so the moment OpsLog talks to the
|
||||||
|
// radio directly nothing else can. rigctld answers the programs that speak
|
||||||
|
// Hamlib NET rigctl (WSJT-X, JTDX, MSHV, Log4OM); this answers the ones built
|
||||||
|
// around Expert Electronics' TCI instead — and it answers them whatever radio
|
||||||
|
// is actually connected, because it sits on the same backend-agnostic
|
||||||
|
// interface. An operator with an Icom or a Yaesu can hand a TCI-only program a
|
||||||
|
// working rig.
|
||||||
|
//
|
||||||
|
// ── The protocol ──────────────────────────────────────────────────────────
|
||||||
|
// Text commands over a WebSocket, "name:arg,arg;", the same syntax in both
|
||||||
|
// directions. On connection the server sends a block of initialisation
|
||||||
|
// commands describing the device, ending with ready; and start;. Thereafter
|
||||||
|
// either side may send a control command, and the server echoes every change
|
||||||
|
// to all connected clients so they stay in step with each other.
|
||||||
|
//
|
||||||
|
// vfo:0,0,14074000; receiver 0, channel A (RX), Hz
|
||||||
|
// vfo:0,1,14080000; channel B — the TX frequency when split is on
|
||||||
|
// modulation:0,usb; mode
|
||||||
|
// trx:0,true; PTT
|
||||||
|
// split_enable:0,true; split
|
||||||
|
// vfo:0,0; a READ: the reply is the three-argument form
|
||||||
|
//
|
||||||
|
// Written against the official TCI Protocol document (ExpertSDR3/TCI, 12
|
||||||
|
// January 2024, MIT) — the initialisation set and the argument order of every
|
||||||
|
// command below are from §4.1 and §4.2, not from guesswork about what a client
|
||||||
|
// might accept.
|
||||||
|
package tciserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Rig is what the server needs from OpsLog's CAT manager. An interface, so this
|
||||||
|
// package stays testable without a radio and without importing internal/cat —
|
||||||
|
// which also keeps it building on every platform.
|
||||||
|
type Rig interface {
|
||||||
|
Freq() int64 // TX frequency in Hz (ADIF sense), 0 if unknown
|
||||||
|
RxFreq() int64 // RX frequency in Hz; equals Freq when not split
|
||||||
|
Mode() string // ADIF mode (SSB, CW, FT8…)
|
||||||
|
Split() (bool, int64) // split on?, and the TX frequency
|
||||||
|
SetFreq(hz int64) error
|
||||||
|
SetMode(mode string) error
|
||||||
|
SetPTT(on bool) error
|
||||||
|
SetSplit(on bool, txHz int64) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultPort is TCI's own default, which is what a client offers first.
|
||||||
|
const DefaultPort = 40001
|
||||||
|
|
||||||
|
// pollInterval is how often the rig is compared with what the clients were last
|
||||||
|
// told. TCI is an event protocol — a client is entitled to sit silent and be
|
||||||
|
// told when something moves — so this is the rate at which a knob turned on the
|
||||||
|
// radio reaches it.
|
||||||
|
const pollInterval = 250 * time.Millisecond
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
port int
|
||||||
|
rig Rig
|
||||||
|
log func(string, ...any)
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
ln net.Listener
|
||||||
|
http *http.Server
|
||||||
|
conns map[*client]struct{}
|
||||||
|
closed bool
|
||||||
|
|
||||||
|
// pendingTxHz is a transmit frequency a client set on channel B while the rig
|
||||||
|
// was still simplex.
|
||||||
|
//
|
||||||
|
// It must be REMEMBERED, not discarded. A client working split sends two
|
||||||
|
// commands and is free to send them in either order; when the frequency comes
|
||||||
|
// first, throwing it away means the split is then armed on whatever the
|
||||||
|
// transmit VFO happened to hold — the receive frequency — and the operator
|
||||||
|
// transmits straight onto the DX while their software shows exactly what they
|
||||||
|
// asked for. rigctld learned this the same way, and pairs set_split_vfo with
|
||||||
|
// set_split_freq for the same reason.
|
||||||
|
pendingTxHz int64
|
||||||
|
|
||||||
|
// ptt mirrors the last PTT state a client commanded, so a repeat can be
|
||||||
|
// recognised. A client is free to restate PTT as often as it likes, and one
|
||||||
|
// does: through the rigctl server Nexus sent set_ptt 0 about sixteen times a
|
||||||
|
// second, and the Flex's own "xmit 1" landed between two of them and was
|
||||||
|
// overwritten inside a millisecond — a transmit request that simply did
|
||||||
|
// nothing. The same radio sits behind this server.
|
||||||
|
ptt bool
|
||||||
|
pttKnown bool
|
||||||
|
|
||||||
|
// last is what the clients have been told, so only changes are sent. TCI
|
||||||
|
// clients redraw on every command they receive; re-sending an unchanged
|
||||||
|
// frequency four times a second makes a VFO readout flicker and, in some
|
||||||
|
// clients, fights the operator's own tuning.
|
||||||
|
last state
|
||||||
|
}
|
||||||
|
|
||||||
|
// state is the part of the rig the clients are kept in step with.
|
||||||
|
type state struct {
|
||||||
|
rxHz int64
|
||||||
|
txHz int64
|
||||||
|
mode string
|
||||||
|
split bool
|
||||||
|
valid bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// clientLogCap bounds how many of one client's commands reach the log.
|
||||||
|
const clientLogCap = 200
|
||||||
|
|
||||||
|
// client is one connected program.
|
||||||
|
type client struct {
|
||||||
|
conn *websocket.Conn
|
||||||
|
mu sync.Mutex // one writer at a time: gorilla panics on concurrent writes
|
||||||
|
// logged counts what has been written to the log for this connection. Only
|
||||||
|
// the reader goroutine touches it, so it needs no lock of its own.
|
||||||
|
logged int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *client) send(s string) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.conn == nil {
|
||||||
|
return nil // a client with no socket: the tests exercise the protocol, not the transport
|
||||||
|
}
|
||||||
|
_ = c.conn.SetWriteDeadline(time.Now().Add(3 * time.Second))
|
||||||
|
return c.conn.WriteMessage(websocket.TextMessage, []byte(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(port int, rig Rig, logf func(string, ...any)) *Server {
|
||||||
|
if port <= 0 || port > 65535 {
|
||||||
|
port = DefaultPort
|
||||||
|
}
|
||||||
|
if logf == nil {
|
||||||
|
logf = func(string, ...any) {}
|
||||||
|
}
|
||||||
|
return &Server{port: port, rig: rig, log: logf, conns: map[*client]struct{}{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start binds the port and serves until Stop.
|
||||||
|
func (s *Server) Start() error {
|
||||||
|
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", s.port))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("tci server: port %d: %w", s.port, err)
|
||||||
|
}
|
||||||
|
up := websocket.Upgrader{
|
||||||
|
// Any origin: the clients are desktop programs on the same machine or
|
||||||
|
// LAN, and they send whatever Origin their toolkit happens to set. This
|
||||||
|
// is the same trust boundary as the rigctl server on 4532 — a plain TCP
|
||||||
|
// port with no authentication, which is what every logger expects.
|
||||||
|
CheckOrigin: func(*http.Request) bool { return true },
|
||||||
|
}
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
// Any path: clients connect to ws://host:port/ but some append a name.
|
||||||
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
conn, err := up.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
s.log("tci server: upgrade from %s failed: %v", r.RemoteAddr, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.serve(&client{conn: conn}, r.RemoteAddr)
|
||||||
|
})
|
||||||
|
srv := &http.Server{Handler: mux}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.ln, s.http, s.closed = ln, srv, false
|
||||||
|
s.mu.Unlock()
|
||||||
|
go func() { _ = srv.Serve(ln) }()
|
||||||
|
go s.pushLoop()
|
||||||
|
s.log("tci server: listening on :%d", s.port)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop closes the listener and every client.
|
||||||
|
// releasePTT drops a PTT this server asserted, and does it once.
|
||||||
|
//
|
||||||
|
// A client that dies mid-over — or a settings save that closes the server —
|
||||||
|
// leaves the rig keyed with nobody left to un-key it, into an amplifier that has
|
||||||
|
// no idea the transmission ended. rigctld has had this guard for a while (a K3
|
||||||
|
// once sat in transmit for 29 s until the CAT link happened to be rebuilt); the
|
||||||
|
// TCI server was written without it, so an operator who moved from Hamlib to TCI
|
||||||
|
// silently lost the protection.
|
||||||
|
//
|
||||||
|
// pttKnown is cleared whatever happens: after an emergency unkey the radio's
|
||||||
|
// state is a guess, and the next command must reach it rather than be dismissed
|
||||||
|
// as a repeat.
|
||||||
|
func (s *Server) releasePTT(why string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
keyed := s.ptt
|
||||||
|
s.ptt, s.pttKnown = false, false
|
||||||
|
s.mu.Unlock()
|
||||||
|
if !keyed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.log("tci server: %s while the rig was keyed — dropping PTT", why)
|
||||||
|
if err := s.rig.SetPTT(false); err != nil {
|
||||||
|
s.log("tci server: emergency unkey FAILED: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Any client still attached is told, so a second logger's transmit indicator
|
||||||
|
// does not stay lit over a rig that is back in receive.
|
||||||
|
s.broadcast("trx:0,false;")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) Stop() {
|
||||||
|
// Before anything is torn down: the CAT backend is still up here, so an unkey
|
||||||
|
// still lands. Same ordering as rigctld.Stop for the same reason.
|
||||||
|
s.releasePTT("TCI server stopped")
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.closed {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.closed = true
|
||||||
|
ln, srv := s.ln, s.http
|
||||||
|
conns := make([]*client, 0, len(s.conns))
|
||||||
|
for c := range s.conns {
|
||||||
|
conns = append(conns, c)
|
||||||
|
}
|
||||||
|
s.conns = map[*client]struct{}{}
|
||||||
|
s.last = state{}
|
||||||
|
s.mu.Unlock()
|
||||||
|
for _, c := range conns {
|
||||||
|
_ = c.conn.Close()
|
||||||
|
}
|
||||||
|
if srv != nil {
|
||||||
|
_ = srv.Close()
|
||||||
|
}
|
||||||
|
if ln != nil {
|
||||||
|
_ = ln.Close()
|
||||||
|
}
|
||||||
|
s.log("tci server: stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clients reports how many programs are connected — the one thing an operator
|
||||||
|
// wants to know when a client says it cannot find the rig.
|
||||||
|
func (s *Server) Clients() int {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return len(s.conns)
|
||||||
|
}
|
||||||
|
|
||||||
|
// serve runs one connection: the initialisation block, then commands until it
|
||||||
|
// closes.
|
||||||
|
func (s *Server) serve(c *client, remote string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.closed {
|
||||||
|
s.mu.Unlock()
|
||||||
|
_ = c.conn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.conns[c] = struct{}{}
|
||||||
|
s.mu.Unlock()
|
||||||
|
s.log("tci server: %s connected", remote)
|
||||||
|
|
||||||
|
for _, line := range s.initBlock() {
|
||||||
|
if err := c.send(line); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
_, data, err := c.conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// One frame may carry several ";"-terminated commands.
|
||||||
|
for _, cmd := range strings.Split(string(data), ";") {
|
||||||
|
if cmd = strings.TrimSpace(cmd); cmd == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Every command the client sends, in the log.
|
||||||
|
//
|
||||||
|
// This is the only evidence there will ever be about a program on
|
||||||
|
// someone else's machine: "MSHV's PTT test does nothing" is
|
||||||
|
// unanswerable without knowing whether MSHV sent trx at all, and if
|
||||||
|
// so in what form. Cheap, because TCI is event-driven — a client
|
||||||
|
// speaks when the operator does something, not on a timer.
|
||||||
|
//
|
||||||
|
// Capped so a client that DOES poll cannot quietly fill the
|
||||||
|
// operator's log; the cap says so once and then stays quiet.
|
||||||
|
if c.logged < clientLogCap {
|
||||||
|
c.logged++
|
||||||
|
s.log("tci server: ← %s;", cmd)
|
||||||
|
} else if c.logged == clientLogCap {
|
||||||
|
c.logged++
|
||||||
|
s.log("tci server: (further commands from this client are not logged)")
|
||||||
|
}
|
||||||
|
s.handle(c, cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
delete(s.conns, c)
|
||||||
|
s.mu.Unlock()
|
||||||
|
_ = c.conn.Close()
|
||||||
|
s.log("tci server: %s disconnected", remote)
|
||||||
|
// A client that walks away mid-over must not leave the rig transmitting.
|
||||||
|
s.releasePTT("client " + remote + " left")
|
||||||
|
}
|
||||||
|
|
||||||
|
// initBlock is the initialisation set from §4.1 of the protocol document, in
|
||||||
|
// the documented order, followed by the current state so a client that has just
|
||||||
|
// connected shows the right frequency instead of waiting for the first change.
|
||||||
|
//
|
||||||
|
// A client will not proceed without these: they are how it learns the device
|
||||||
|
// exists, what it can do, and that the server has finished setting up.
|
||||||
|
func (s *Server) initBlock() []string {
|
||||||
|
rx, tx, mode, split := s.read()
|
||||||
|
return []string{
|
||||||
|
"protocol:ExpertSDR3,1.9;",
|
||||||
|
"device:OpsLog;",
|
||||||
|
"receive_only:false;",
|
||||||
|
"trx_count:1;",
|
||||||
|
"channel_count:2;",
|
||||||
|
// The whole HF/VHF/UHF span OpsLog itself works over. A client uses this
|
||||||
|
// to bound its own tuning; too narrow a range and it refuses to follow the
|
||||||
|
// rig onto 2 m.
|
||||||
|
"vfo_limits:10000,470000000;",
|
||||||
|
"if_limits:-48000,48000;",
|
||||||
|
"modulations_list:am,sam,dsb,lsb,usb,cw,nfm,digl,digu;",
|
||||||
|
"ready;",
|
||||||
|
"start;",
|
||||||
|
fmt.Sprintf("vfo:0,0,%d;", rx),
|
||||||
|
fmt.Sprintf("vfo:0,1,%d;", tx),
|
||||||
|
fmt.Sprintf("modulation:0,%s;", mode),
|
||||||
|
fmt.Sprintf("split_enable:0,%t;", split),
|
||||||
|
"trx:0,false;",
|
||||||
|
// TRANSMIT PERMISSION, and it is not optional in practice.
|
||||||
|
//
|
||||||
|
// The document files TX_ENABLE under unidirectional control rather than
|
||||||
|
// initialisation, but its own note says it is "sent to the client when
|
||||||
|
// connected". A client that models permission — and one written for
|
||||||
|
// ExpertSDR users has every reason to — starts out assuming it may NOT
|
||||||
|
// transmit, and without this it never even tries: PTT does nothing and
|
||||||
|
// the server never sees a trx command to refuse.
|
||||||
|
//
|
||||||
|
// Always true. OpsLog is not the thing that decides: the radio behind
|
||||||
|
// whichever backend is connected does, and its refusal comes back through
|
||||||
|
// SetPTT and into the log.
|
||||||
|
"tx_enable:0,true;",
|
||||||
|
fmt.Sprintf("tx_frequency:%d;", tx),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// read takes one consistent snapshot of the rig in TCI's terms: channel A is
|
||||||
|
// where we LISTEN and channel B where we transmit, which is the opposite way
|
||||||
|
// round from ADIF's RigState and the one mistake here that would make a client
|
||||||
|
// transmit on the DX's frequency.
|
||||||
|
func (s *Server) read() (rxHz, txHz int64, mode string, split bool) {
|
||||||
|
split, txHz = s.rig.Split()
|
||||||
|
rxHz = s.rig.RxFreq()
|
||||||
|
if !split {
|
||||||
|
txHz = s.rig.Freq()
|
||||||
|
if rxHz == 0 {
|
||||||
|
rxHz = txHz
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rxHz == 0 {
|
||||||
|
rxHz = s.rig.Freq()
|
||||||
|
}
|
||||||
|
if txHz == 0 {
|
||||||
|
txHz = rxHz
|
||||||
|
}
|
||||||
|
mode = adifToTCIMode(s.rig.Mode(), rxHz)
|
||||||
|
return rxHz, txHz, mode, split
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushLoop tells the clients what has changed on the radio.
|
||||||
|
func (s *Server) pushLoop() {
|
||||||
|
t := time.NewTicker(pollInterval)
|
||||||
|
defer t.Stop()
|
||||||
|
for range t.C {
|
||||||
|
s.mu.Lock()
|
||||||
|
done := s.closed
|
||||||
|
s.mu.Unlock()
|
||||||
|
if done {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.publish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// publish sends only what moved. Returns the lines sent, for the tests.
|
||||||
|
func (s *Server) publish() []string {
|
||||||
|
rx, tx, mode, split := s.read()
|
||||||
|
cur := state{rxHz: rx, txHz: tx, mode: mode, split: split, valid: true}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
prev := s.last
|
||||||
|
s.last = cur
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
if !prev.valid || prev.rxHz != cur.rxHz {
|
||||||
|
lines = append(lines, fmt.Sprintf("vfo:0,0,%d;", cur.rxHz))
|
||||||
|
}
|
||||||
|
if !prev.valid || prev.txHz != cur.txHz {
|
||||||
|
lines = append(lines, fmt.Sprintf("vfo:0,1,%d;", cur.txHz))
|
||||||
|
// The transmit frequency has its own command, which is what a client
|
||||||
|
// showing "TX 14.080" reads. Channel B alone leaves that stale.
|
||||||
|
lines = append(lines, fmt.Sprintf("tx_frequency:%d;", cur.txHz))
|
||||||
|
}
|
||||||
|
if (!prev.valid || prev.mode != cur.mode) && cur.mode != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf("modulation:0,%s;", cur.mode))
|
||||||
|
}
|
||||||
|
if !prev.valid || prev.split != cur.split {
|
||||||
|
lines = append(lines, fmt.Sprintf("split_enable:0,%t;", cur.split))
|
||||||
|
}
|
||||||
|
for _, l := range lines {
|
||||||
|
s.broadcast(l)
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) broadcast(line string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
conns := make([]*client, 0, len(s.conns))
|
||||||
|
for c := range s.conns {
|
||||||
|
conns = append(conns, c)
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
for _, c := range conns {
|
||||||
|
_ = c.send(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle answers one command from a client. Returns what was sent back, which
|
||||||
|
// is "" for a command that only acts on the radio.
|
||||||
|
//
|
||||||
|
// A command that SETS something is echoed to every client, not just answered to
|
||||||
|
// the one that sent it: the protocol document is explicit that the server
|
||||||
|
// synchronises all connected clients, and two loggers that disagree about the
|
||||||
|
// frequency are worse than one that is merely slow.
|
||||||
|
func (s *Server) handle(c *client, cmd string) string {
|
||||||
|
name, args := cmd, ""
|
||||||
|
if i := strings.IndexByte(cmd, ':'); i >= 0 {
|
||||||
|
name, args = cmd[:i], cmd[i+1:]
|
||||||
|
}
|
||||||
|
f := strings.Split(args, ",")
|
||||||
|
arg := func(i int) string {
|
||||||
|
if i < len(f) {
|
||||||
|
return strings.TrimSpace(f[i])
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
num := func(i int) int64 {
|
||||||
|
v, _ := strconv.ParseInt(arg(i), 10, 64)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
reply := func(line string) string {
|
||||||
|
_ = c.send(line)
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
rx, tx, mode, split := s.read()
|
||||||
|
|
||||||
|
switch strings.ToLower(strings.TrimSpace(name)) {
|
||||||
|
case "vfo":
|
||||||
|
// Read form: two arguments. Set form: three.
|
||||||
|
if len(f) < 3 || arg(2) == "" {
|
||||||
|
if arg(1) == "1" {
|
||||||
|
return reply(fmt.Sprintf("vfo:0,1,%d;", tx))
|
||||||
|
}
|
||||||
|
return reply(fmt.Sprintf("vfo:0,0,%d;", rx))
|
||||||
|
}
|
||||||
|
hz := num(2)
|
||||||
|
if hz <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if arg(1) == "1" {
|
||||||
|
// Channel B is the transmit frequency. Setting it while simplex must
|
||||||
|
// not move the rig's only VFO — the client asked to prepare a split
|
||||||
|
// transmit frequency, not to QSY — but it must not be thrown away
|
||||||
|
// either: it is where the split will be armed a moment from now.
|
||||||
|
s.mu.Lock()
|
||||||
|
s.pendingTxHz = hz
|
||||||
|
s.mu.Unlock()
|
||||||
|
if !split {
|
||||||
|
s.broadcast(fmt.Sprintf("vfo:0,1,%d;", hz))
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if err := s.rig.SetSplit(true, hz); err != nil {
|
||||||
|
s.log("tci server: split TX %d Hz refused: %v", hz, err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
} else if err := s.rig.SetFreq(hz); err != nil {
|
||||||
|
s.log("tci server: tune to %d Hz refused: %v", hz, err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
s.broadcast(fmt.Sprintf("vfo:0,%s,%d;", orZero(arg(1)), hz))
|
||||||
|
return ""
|
||||||
|
|
||||||
|
case "modulation":
|
||||||
|
if len(f) < 2 || arg(1) == "" {
|
||||||
|
return reply(fmt.Sprintf("modulation:0,%s;", mode))
|
||||||
|
}
|
||||||
|
m := tciModeToADIF(arg(1))
|
||||||
|
if m == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if err := s.rig.SetMode(m); err != nil {
|
||||||
|
s.log("tci server: mode %s refused: %v", m, err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
s.broadcast(fmt.Sprintf("modulation:0,%s;", strings.ToLower(arg(1))))
|
||||||
|
return ""
|
||||||
|
|
||||||
|
case "trx":
|
||||||
|
if len(f) < 2 || arg(1) == "" {
|
||||||
|
return reply("trx:0,false;")
|
||||||
|
}
|
||||||
|
on := strings.EqualFold(arg(1), "true")
|
||||||
|
// Only touch the radio on a CHANGE — restating a state is not a request
|
||||||
|
// to change it. The first command always goes through, since there is no
|
||||||
|
// knowing how the radio was left.
|
||||||
|
s.mu.Lock()
|
||||||
|
known, prev := s.pttKnown, s.ptt
|
||||||
|
s.mu.Unlock()
|
||||||
|
if known && prev == on {
|
||||||
|
s.broadcast(fmt.Sprintf("trx:0,%t;", on))
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if err := s.rig.SetPTT(on); err != nil {
|
||||||
|
// The cache is stamped ONLY on success, and a failure clears "known"
|
||||||
|
// outright so the NEXT command — whatever it is — reaches the radio.
|
||||||
|
//
|
||||||
|
// It used to be written before the radio was commanded and left in
|
||||||
|
// place when the command failed. That is how a rig got stuck keyed for
|
||||||
|
// good: the un-key failed on a lost CI-V acknowledgement, the cache
|
||||||
|
// recorded "off" regardless, and from then on every trx:0,false was
|
||||||
|
// dismissed as a repeat of a state the radio had never reached. Not
|
||||||
|
// even reconnecting the client cleared it — this cache is per-server,
|
||||||
|
// not per-connection — so the transmitter stayed keyed into the
|
||||||
|
// amplifier until the operator switched the radio off. A cache must
|
||||||
|
// never claim something the radio refused.
|
||||||
|
s.mu.Lock()
|
||||||
|
s.pttKnown = false
|
||||||
|
s.mu.Unlock()
|
||||||
|
s.log("tci server: PTT %v refused: %v", on, err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.ptt, s.pttKnown = on, true
|
||||||
|
s.mu.Unlock()
|
||||||
|
s.log("tci server: PTT %s", map[bool]string{true: "ON", false: "off"}[on])
|
||||||
|
s.broadcast(fmt.Sprintf("trx:0,%t;", on))
|
||||||
|
return ""
|
||||||
|
|
||||||
|
case "split_enable":
|
||||||
|
if len(f) < 2 || arg(1) == "" {
|
||||||
|
return reply(fmt.Sprintf("split_enable:0,%t;", split))
|
||||||
|
}
|
||||||
|
on := strings.EqualFold(arg(1), "true")
|
||||||
|
// Already in the state asked for? Then it is done, and nothing goes to
|
||||||
|
// the radio. This is the lesson the rigctl server paid for: JTDX in "Fake
|
||||||
|
// It" uses no split but still says so to be sure, and a backend that
|
||||||
|
// cannot set split answered an error to a request that was already true.
|
||||||
|
// JTDX read that as rig control failing and abandoned the transmission a
|
||||||
|
// second into the frame. A refusal is only honest when something actually
|
||||||
|
// needed doing.
|
||||||
|
if on == split {
|
||||||
|
s.broadcast(fmt.Sprintf("split_enable:0,%t;", on))
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// Arm on the frequency the client gave for channel B, which it is free to
|
||||||
|
// have sent before this command rather than after.
|
||||||
|
s.mu.Lock()
|
||||||
|
pending := s.pendingTxHz
|
||||||
|
s.mu.Unlock()
|
||||||
|
txHz := tx
|
||||||
|
if on && pending > 0 {
|
||||||
|
txHz = pending
|
||||||
|
}
|
||||||
|
if err := s.rig.SetSplit(on, txHz); err != nil {
|
||||||
|
// The refusal is the useful part: a backend that cannot split says
|
||||||
|
// so, and the client can tell the operator instead of transmitting
|
||||||
|
// on the wrong frequency believing all is well.
|
||||||
|
s.log("tci server: split %v refused: %v", on, err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
s.log("tci server: split %s, TX %d Hz", map[bool]string{true: "ON", false: "off"}[on], txHz)
|
||||||
|
s.broadcast(fmt.Sprintf("split_enable:0,%t;", on))
|
||||||
|
return ""
|
||||||
|
|
||||||
|
case "dds":
|
||||||
|
// The panorama's centre frequency. OpsLog has no panorama, so it answers
|
||||||
|
// with the receive frequency — which is where a client draws its own.
|
||||||
|
return reply(fmt.Sprintf("dds:0,%d;", rx))
|
||||||
|
|
||||||
|
case "if":
|
||||||
|
// Offset of the tuning filter inside the panorama: zero, since our "dds"
|
||||||
|
// is the receive frequency itself.
|
||||||
|
return reply("if:0,0,0;")
|
||||||
|
|
||||||
|
case "start", "stop", "ready":
|
||||||
|
return ""
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Everything else — audio streams, CW macros, the E-Coder, the
|
||||||
|
// panorama's own settings — belongs to a radio, not to a CAT link.
|
||||||
|
// Silence rather than an error: a client sends these hopefully at
|
||||||
|
// connect, and a refusal it did not ask for reads as a fault.
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func orZero(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// adifToTCIMode maps an ADIF mode to a TCI modulation.
|
||||||
|
//
|
||||||
|
// SSB carries no sideband, so it is resolved from the frequency the way every
|
||||||
|
// operator does: below 10 MHz lower, above it upper. A client told "ssb" would
|
||||||
|
// not recognise it — the modulation list is the vocabulary.
|
||||||
|
func adifToTCIMode(mode string, hz int64) string {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||||
|
case "":
|
||||||
|
return ""
|
||||||
|
case "CW", "CWR":
|
||||||
|
return "cw"
|
||||||
|
case "USB":
|
||||||
|
return "usb"
|
||||||
|
case "LSB":
|
||||||
|
return "lsb"
|
||||||
|
case "SSB":
|
||||||
|
if hz > 0 && hz < 10_000_000 {
|
||||||
|
return "lsb"
|
||||||
|
}
|
||||||
|
return "usb"
|
||||||
|
case "AM":
|
||||||
|
return "am"
|
||||||
|
case "FM", "NFM":
|
||||||
|
return "nfm"
|
||||||
|
case "RTTY":
|
||||||
|
return "digl"
|
||||||
|
}
|
||||||
|
// Everything else is a data mode: FT8, FT4, JT65, PSK31, MSK144, VARA…
|
||||||
|
// TCI has one pair for the whole family, and the sideband follows the same
|
||||||
|
// rule the data modes themselves use — upper, but for the few HF corners
|
||||||
|
// where LSB is conventional the radio is already there.
|
||||||
|
return "digu"
|
||||||
|
}
|
||||||
|
|
||||||
|
// tciModeToADIF maps a TCI modulation back to an ADIF mode.
|
||||||
|
func tciModeToADIF(m string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(m)) {
|
||||||
|
case "cw":
|
||||||
|
return "CW"
|
||||||
|
case "usb":
|
||||||
|
return "USB"
|
||||||
|
case "lsb":
|
||||||
|
return "LSB"
|
||||||
|
case "am", "sam":
|
||||||
|
return "AM"
|
||||||
|
case "nfm", "fm", "wfm":
|
||||||
|
return "FM"
|
||||||
|
case "digl", "digu", "dsb", "drm":
|
||||||
|
// The data family: the mode the operator is actually running (FT8, RTTY)
|
||||||
|
// is chosen in OpsLog, and a client switching to "digital" must not
|
||||||
|
// overwrite it with a guess. DATA is the honest ADIF answer.
|
||||||
|
return "DATA"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
package tciserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeRig is a radio that remembers what it was told. Everything here is about
|
||||||
|
// what OpsLog does with a client's command, so the rig only has to answer and
|
||||||
|
// record.
|
||||||
|
type fakeRig struct {
|
||||||
|
freq, rxFreq int64
|
||||||
|
mode string
|
||||||
|
split bool
|
||||||
|
txHz int64
|
||||||
|
ptt bool
|
||||||
|
splitErr error
|
||||||
|
pttErr error
|
||||||
|
calls []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRig) Freq() int64 { return r.freq }
|
||||||
|
func (r *fakeRig) RxFreq() int64 { return r.rxFreq }
|
||||||
|
func (r *fakeRig) Mode() string { return r.mode }
|
||||||
|
func (r *fakeRig) Split() (bool, int64) { return r.split, r.txHz }
|
||||||
|
func (r *fakeRig) SetFreq(hz int64) error {
|
||||||
|
r.calls = append(r.calls, fmt.Sprintf("freq=%d", hz))
|
||||||
|
r.freq, r.rxFreq = hz, hz
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (r *fakeRig) SetMode(m string) error {
|
||||||
|
r.calls = append(r.calls, "mode="+m)
|
||||||
|
r.mode = m
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (r *fakeRig) SetPTT(on bool) error {
|
||||||
|
// Refused BEFORE the state moves, like a radio that never got the frame.
|
||||||
|
if r.pttErr != nil {
|
||||||
|
return r.pttErr
|
||||||
|
}
|
||||||
|
r.calls = append(r.calls, fmt.Sprintf("ptt=%v", on))
|
||||||
|
r.ptt = on
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (r *fakeRig) SetSplit(on bool, txHz int64) error {
|
||||||
|
if r.splitErr != nil {
|
||||||
|
return r.splitErr
|
||||||
|
}
|
||||||
|
r.calls = append(r.calls, fmt.Sprintf("split=%v,%d", on, txHz))
|
||||||
|
r.split, r.txHz = on, txHz
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// srv builds a server with no listener — handle() and publish() are the whole
|
||||||
|
// protocol, and neither needs a socket.
|
||||||
|
func srv(r *fakeRig) *Server { return New(0, r, nil) }
|
||||||
|
|
||||||
|
// A client with no connection: send() would need one, so reads are checked
|
||||||
|
// through the returned line instead. This is why handle returns what it sent.
|
||||||
|
func ask(t *testing.T, s *Server, cmd string) string {
|
||||||
|
t.Helper()
|
||||||
|
return s.handle(&client{}, cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The initialisation block is what a client needs before it will believe there
|
||||||
|
// is a radio at all. Its contents come from §4.1 of the protocol document, and
|
||||||
|
// a client that does not see ready; simply waits for ever.
|
||||||
|
func TestInitBlockCarriesTheDocumentedInitialisationSet(t *testing.T) {
|
||||||
|
s := srv(&fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB"})
|
||||||
|
block := strings.Join(s.initBlock(), "")
|
||||||
|
for _, want := range []string{
|
||||||
|
"protocol:ExpertSDR3,", "device:", "receive_only:false;", "trx_count:1;",
|
||||||
|
"channel_count:2;", "vfo_limits:", "if_limits:", "modulations_list:",
|
||||||
|
"ready;", "start;",
|
||||||
|
// Transmit permission. A client that models it starts out assuming it
|
||||||
|
// may NOT transmit, and without this never even tries — PTT does
|
||||||
|
// nothing and the server never sees a trx command at all.
|
||||||
|
"tx_enable:0,true;",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(block, want) {
|
||||||
|
t.Errorf("the initialisation block is missing %q — a client would not proceed past connect", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// And the current state, so a client that connects mid-session shows the
|
||||||
|
// right frequency instead of waiting for the operator to touch something.
|
||||||
|
if !strings.Contains(block, "vfo:0,0,14074000;") {
|
||||||
|
t.Errorf("no current frequency in the block:\n%s", block)
|
||||||
|
}
|
||||||
|
if !strings.Contains(block, "modulation:0,usb;") {
|
||||||
|
t.Errorf("no current mode in the block:\n%s", block)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channel A is where we LISTEN, channel B where we transmit. Handing these to a
|
||||||
|
// client the wrong way round is the one mistake here that puts a station on the
|
||||||
|
// DX's own frequency, so it is pinned in both directions.
|
||||||
|
func TestSplitPutsTheListeningFrequencyOnChannelA(t *testing.T) {
|
||||||
|
// OpsLog's RigState is ADIF: Freq is the TRANSMIT frequency, RxFreq where we
|
||||||
|
// listen. A DX transmitting on 14025 and listening up 2.
|
||||||
|
r := &fakeRig{freq: 14027000, rxFreq: 14025000, mode: "CW", split: true, txHz: 14027000}
|
||||||
|
s := srv(r)
|
||||||
|
block := strings.Join(s.initBlock(), "")
|
||||||
|
if !strings.Contains(block, "vfo:0,0,14025000;") {
|
||||||
|
t.Errorf("channel A is not the receive frequency:\n%s", block)
|
||||||
|
}
|
||||||
|
if !strings.Contains(block, "vfo:0,1,14027000;") {
|
||||||
|
t.Errorf("channel B is not the transmit frequency:\n%s", block)
|
||||||
|
}
|
||||||
|
if !strings.Contains(block, "split_enable:0,true;") {
|
||||||
|
t.Errorf("split was not announced:\n%s", block)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simplex: both channels report the one frequency, so a client reading either
|
||||||
|
// gets the right answer.
|
||||||
|
func TestSimplexReportsTheSameFrequencyOnBothChannels(t *testing.T) {
|
||||||
|
s := srv(&fakeRig{freq: 7100000, rxFreq: 7100000, mode: "SSB"})
|
||||||
|
if got := ask(t, s, "vfo:0,0"); got != "vfo:0,0,7100000;" {
|
||||||
|
t.Errorf("read of channel A = %q", got)
|
||||||
|
}
|
||||||
|
if got := ask(t, s, "vfo:0,1"); got != "vfo:0,1,7100000;" {
|
||||||
|
t.Errorf("read of channel B = %q", got)
|
||||||
|
}
|
||||||
|
// 7 MHz is below 10, so SSB is lower sideband — a client told "ssb" would
|
||||||
|
// not recognise it at all, the modulation list is the vocabulary.
|
||||||
|
if got := ask(t, s, "modulation:0"); got != "modulation:0,lsb;" {
|
||||||
|
t.Errorf("read of the mode = %q, want lsb below 10 MHz", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The client tunes the radio.
|
||||||
|
func TestAClientCanTuneAndSetModeAndKey(t *testing.T) {
|
||||||
|
r := &fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB"}
|
||||||
|
s := srv(r)
|
||||||
|
ask(t, s, "vfo:0,0,14200000")
|
||||||
|
ask(t, s, "modulation:0,cw")
|
||||||
|
ask(t, s, "trx:0,true")
|
||||||
|
ask(t, s, "trx:0,false")
|
||||||
|
want := []string{"freq=14200000", "mode=CW", "ptt=true", "ptt=false"}
|
||||||
|
if strings.Join(r.calls, " ") != strings.Join(want, " ") {
|
||||||
|
t.Errorf("the radio was told %v, want %v", r.calls, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channel B is the SPLIT transmit frequency. Writing it while the rig is
|
||||||
|
// simplex must not move the only VFO there is: the client asked to prepare a
|
||||||
|
// transmit frequency, not to QSY — and a logger that did this on every spot
|
||||||
|
// click would drag the operator off the station they were listening to.
|
||||||
|
func TestWritingChannelBWhileSimplexLeavesTheRigAlone(t *testing.T) {
|
||||||
|
r := &fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB"}
|
||||||
|
s := srv(r)
|
||||||
|
ask(t, s, "vfo:0,1,14080000")
|
||||||
|
if len(r.calls) != 0 {
|
||||||
|
t.Errorf("the radio was told %v — a split TX frequency moved a simplex rig", r.calls)
|
||||||
|
}
|
||||||
|
// With split armed it means what it says.
|
||||||
|
r.split, r.txHz = true, 14074000
|
||||||
|
ask(t, s, "vfo:0,1,14080000")
|
||||||
|
if len(r.calls) != 1 || r.calls[0] != "split=true,14080000" {
|
||||||
|
t.Errorf("with split on the radio was told %v, want the new transmit frequency", r.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A backend that cannot split says so, and the refusal must not be dressed up
|
||||||
|
// as success: the client can then tell the operator to use Fake It, where
|
||||||
|
// before it would transmit on the receive frequency believing all was well.
|
||||||
|
func TestARefusedSplitIsNotAnnouncedAsDone(t *testing.T) {
|
||||||
|
r := &fakeRig{freq: 14025000, rxFreq: 14025000, mode: "CW", splitErr: fmt.Errorf("this backend cannot split")}
|
||||||
|
s := srv(r)
|
||||||
|
if got := ask(t, s, "split_enable:0,true"); got != "" {
|
||||||
|
t.Errorf("a refused split answered %q", got)
|
||||||
|
}
|
||||||
|
if r.split {
|
||||||
|
t.Error("the rig was recorded as split after the backend refused")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only what moved is sent. TCI clients redraw on every command they receive, so
|
||||||
|
// re-sending an unchanged frequency four times a second makes a VFO readout
|
||||||
|
// flicker and, in some clients, fights the operator's own tuning.
|
||||||
|
func TestOnlyChangesAreSent(t *testing.T) {
|
||||||
|
r := &fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB"}
|
||||||
|
s := srv(r)
|
||||||
|
|
||||||
|
first := s.publish()
|
||||||
|
if len(first) == 0 {
|
||||||
|
t.Fatal("the first pass sent nothing — a client would never learn the state")
|
||||||
|
}
|
||||||
|
if got := s.publish(); len(got) != 0 {
|
||||||
|
t.Errorf("an unchanged radio produced %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.freq, r.rxFreq = 14200000, 14200000
|
||||||
|
got := strings.Join(s.publish(), "")
|
||||||
|
// Both channels move together on a simplex rig, and the transmit frequency
|
||||||
|
// has its own command besides — a client showing "TX 14.200" reads that one,
|
||||||
|
// and channel B alone leaves it stale.
|
||||||
|
for _, want := range []string{"vfo:0,0,14200000;", "vfo:0,1,14200000;", "tx_frequency:14200000;"} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Errorf("after a QSY the clients were not told %q — got %q", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := s.publish(); len(got) != 0 {
|
||||||
|
t.Errorf("the QSY was re-sent: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modes travel both ways, and the data family is the interesting half: a client
|
||||||
|
// switching to "digital" must not overwrite the mode the operator chose in
|
||||||
|
// OpsLog with a guess at which data mode it was.
|
||||||
|
func TestModeMapping(t *testing.T) {
|
||||||
|
up := []struct {
|
||||||
|
adif string
|
||||||
|
hz int64
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"CW", 14025000, "cw"},
|
||||||
|
{"SSB", 14200000, "usb"},
|
||||||
|
{"SSB", 7100000, "lsb"},
|
||||||
|
{"USB", 7100000, "usb"}, // an explicit sideband is never second-guessed
|
||||||
|
{"FT8", 14074000, "digu"},
|
||||||
|
{"RTTY", 14080000, "digl"},
|
||||||
|
{"AM", 3700000, "am"},
|
||||||
|
{"FM", 145500000, "nfm"},
|
||||||
|
{"", 14074000, ""},
|
||||||
|
}
|
||||||
|
for _, c := range up {
|
||||||
|
if got := adifToTCIMode(c.adif, c.hz); got != c.want {
|
||||||
|
t.Errorf("adifToTCIMode(%q, %d) = %q, want %q", c.adif, c.hz, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
down := map[string]string{
|
||||||
|
"cw": "CW", "usb": "USB", "lsb": "LSB", "am": "AM", "sam": "AM",
|
||||||
|
"nfm": "FM", "digu": "DATA", "digl": "DATA", "": "",
|
||||||
|
}
|
||||||
|
for in, want := range down {
|
||||||
|
if got := tciModeToADIF(in); got != want {
|
||||||
|
t.Errorf("tciModeToADIF(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A command for something OpsLog is not — audio streams, CW macros, the
|
||||||
|
// panorama's settings — is met with silence rather than an error. A client
|
||||||
|
// sends these hopefully at connect, and a refusal it did not ask for reads as a
|
||||||
|
// fault with the rig.
|
||||||
|
func TestUnknownCommandsAreQuiet(t *testing.T) {
|
||||||
|
s := srv(&fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB"})
|
||||||
|
for _, cmd := range []string{"audio_start:0", "cw_macros_speed:25", "rx_filter_band:0,-2700,-100", "iq_start:0"} {
|
||||||
|
if got := ask(t, s, cmd); got != "" {
|
||||||
|
t.Errorf("%q answered %q", cmd, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split, with the client sending the two commands in the order it prefers.
|
||||||
|
//
|
||||||
|
// A client working split has to say two things: where to transmit, and that
|
||||||
|
// split is on. Nothing obliges it to say them in that order, and the frequency
|
||||||
|
// arriving first is the dangerous case: discarding it and then arming split
|
||||||
|
// leaves the transmit VFO on whatever it held — the RECEIVE frequency — so the
|
||||||
|
// operator transmits straight onto the DX while their software shows exactly
|
||||||
|
// what they asked for.
|
||||||
|
func TestSplitIsArmedOnTheFrequencyTheClientGaveWhicheverOrderItCame(t *testing.T) {
|
||||||
|
// Frequency first, then split — the order that used to lose the frequency.
|
||||||
|
r := &fakeRig{freq: 14025000, rxFreq: 14025000, mode: "CW"}
|
||||||
|
s := srv(r)
|
||||||
|
ask(t, s, "vfo:0,1,14027000")
|
||||||
|
ask(t, s, "split_enable:0,true")
|
||||||
|
if len(r.calls) != 1 || r.calls[0] != "split=true,14027000" {
|
||||||
|
t.Errorf("frequency first: the radio was told %v, want split armed on 14027000", r.calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split first, then the frequency — the order that always worked.
|
||||||
|
r2 := &fakeRig{freq: 14025000, rxFreq: 14025000, mode: "CW"}
|
||||||
|
s2 := srv(r2)
|
||||||
|
ask(t, s2, "split_enable:0,true")
|
||||||
|
ask(t, s2, "vfo:0,1,14027000")
|
||||||
|
if len(r2.calls) == 0 || r2.calls[len(r2.calls)-1] != "split=true,14027000" {
|
||||||
|
t.Errorf("split first: the radio was told %v, want it to end on 14027000", r2.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Fake It" uses no split at all: the client shifts the DIAL at the start of
|
||||||
|
// transmit and shifts it back at the end. All it needs is channel A, and it
|
||||||
|
// must reach the radio both ways.
|
||||||
|
func TestFakeItIsJustTheDialMoving(t *testing.T) {
|
||||||
|
r := &fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB"}
|
||||||
|
s := srv(r)
|
||||||
|
ask(t, s, "vfo:0,0,14075300") // up for the over
|
||||||
|
ask(t, s, "vfo:0,0,14074000") // and back
|
||||||
|
want := []string{"freq=14075300", "freq=14074000"}
|
||||||
|
if strings.Join(r.calls, " ") != strings.Join(want, " ") {
|
||||||
|
t.Errorf("the radio was told %v, want %v", r.calls, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A client in Fake It still says "split off" to be sure. The rig is already
|
||||||
|
// simplex, so there is nothing to do — and saying so beats asking a backend
|
||||||
|
// that may not be able to set split at all.
|
||||||
|
//
|
||||||
|
// This is what broke JTDX through the rigctl server: an error answered to a
|
||||||
|
// request that was already true, read as rig control failing, and the
|
||||||
|
// transmission abandoned a second into the frame.
|
||||||
|
func TestSayingSplitOffWhenAlreadySimplexTouchesNothing(t *testing.T) {
|
||||||
|
r := &fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB",
|
||||||
|
splitErr: fmt.Errorf("this backend cannot split")}
|
||||||
|
s := srv(r)
|
||||||
|
ask(t, s, "split_enable:0,false")
|
||||||
|
if len(r.calls) != 0 {
|
||||||
|
t.Errorf("the radio was told %v for a state it was already in", r.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A client restating PTT must not re-command the radio. Through the rigctl
|
||||||
|
// server, one sent set_ptt 0 sixteen times a second and the Flex's own transmit
|
||||||
|
// request was overwritten between two of them inside a millisecond.
|
||||||
|
func TestRepeatedPTTIsNotResentToTheRadio(t *testing.T) {
|
||||||
|
r := &fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB"}
|
||||||
|
s := srv(r)
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
ask(t, s, "trx:0,false")
|
||||||
|
}
|
||||||
|
if len(r.calls) != 1 || r.calls[0] != "ptt=false" {
|
||||||
|
// The FIRST one always goes through: there is no knowing how the radio
|
||||||
|
// was left.
|
||||||
|
t.Errorf("the radio was told %v, want one unkey and no repeats", r.calls)
|
||||||
|
}
|
||||||
|
ask(t, s, "trx:0,true")
|
||||||
|
ask(t, s, "trx:0,true")
|
||||||
|
if len(r.calls) != 2 || r.calls[1] != "ptt=true" {
|
||||||
|
t.Errorf("the radio was told %v, want the change through and the repeat dropped", r.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A refused un-key must never be remembered as done.
|
||||||
|
//
|
||||||
|
// The failure an operator hit running JTDX over TCI with an Icom on CI-V: the
|
||||||
|
// rig went to transmit, the un-key was refused on a lost acknowledgement, and
|
||||||
|
// from then on NOTHING could take it out of transmit. The cache had stamped
|
||||||
|
// "off" before the radio was even commanded and kept it after the refusal, so
|
||||||
|
// every later trx:0,false was dismissed as a repeat of a state the radio had
|
||||||
|
// never reached. It is per-server, not per-connection, so reconnecting the
|
||||||
|
// client changed nothing either — the transmitter stayed keyed into the
|
||||||
|
// amplifier, with no drive, until the radio was switched off by hand.
|
||||||
|
func TestARefusedUnkeyIsNotRememberedAsDone(t *testing.T) {
|
||||||
|
r := &fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB"}
|
||||||
|
s := srv(r)
|
||||||
|
ask(t, s, "trx:0,true")
|
||||||
|
if !r.ptt {
|
||||||
|
t.Fatal("the rig was never keyed — the test would prove nothing")
|
||||||
|
}
|
||||||
|
|
||||||
|
r.pttErr = fmt.Errorf("icom: timeout waiting for response")
|
||||||
|
ask(t, s, "trx:0,false")
|
||||||
|
if !r.ptt {
|
||||||
|
t.Fatal("the fake rig un-keyed on a refusal — the test would prove nothing")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The client asks again, and this time the radio answers. It MUST be told.
|
||||||
|
r.pttErr = nil
|
||||||
|
ask(t, s, "trx:0,false")
|
||||||
|
if r.ptt {
|
||||||
|
t.Error("still keyed: the refused un-key was cached as done and the retry was dropped as a repeat")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A client that walks away mid-over must not leave the rig transmitting, and
|
||||||
|
// the release must be once-only — a second call has nothing to un-key and must
|
||||||
|
// not re-command a radio that is already receiving.
|
||||||
|
func TestReleasePTTUnkeysOnceWhenTheClientLeaves(t *testing.T) {
|
||||||
|
r := &fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB"}
|
||||||
|
s := srv(r)
|
||||||
|
ask(t, s, "trx:0,true")
|
||||||
|
|
||||||
|
s.releasePTT("client left")
|
||||||
|
if r.ptt {
|
||||||
|
t.Error("the rig is still keyed after the client left")
|
||||||
|
}
|
||||||
|
n := len(r.calls)
|
||||||
|
s.releasePTT("client left")
|
||||||
|
if len(r.calls) != n {
|
||||||
|
t.Errorf("released twice — the radio was told %v", r.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||||
appVersion = "0.25.7"
|
appVersion = "0.25.9"
|
||||||
|
|
||||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||||
// to https://us.i.posthog.com for a US project.
|
// to https://us.i.posthog.com for a US project.
|
||||||
|
|||||||
Reference in New Issue
Block a user