Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7410fa4825 | ||
|
|
246d9d1b6a | ||
|
|
d12cfe94cd | ||
|
|
ede81d3926 | ||
|
|
f27faa81a6 | ||
|
|
69e6c20b97 | ||
|
|
d93c40ebb3 | ||
|
|
dfe3afbf2d | ||
|
|
bb3542c920 | ||
|
|
a966abd9df | ||
|
|
95482ec1d8 | ||
|
|
500fe8bfec | ||
|
|
87ff0a9e16 | ||
|
|
18f9b915c5 | ||
|
|
b3fdd0b7fa |
@@ -126,6 +126,7 @@ const (
|
|||||||
// backend's setting.
|
// backend's setting.
|
||||||
keyCATYaesuLowLines = "cat.yaesu.low_dtr_rts" // deassert DTR/RTS on connect
|
keyCATYaesuLowLines = "cat.yaesu.low_dtr_rts" // deassert DTR/RTS on connect
|
||||||
keyCATKenwoodLowLines = "cat.kenwood.low_dtr_rts" // deassert DTR/RTS on connect
|
keyCATKenwoodLowLines = "cat.kenwood.low_dtr_rts" // deassert DTR/RTS on connect
|
||||||
|
keyCATKenwoodDataMode = "cat.kenwood.data_mode" // data modes → "usb" | "data" (MD6) | "keep"
|
||||||
keyCATIcomPort = "cat.icom.port" // Icom USB CI-V serial port (e.g. COM5)
|
keyCATIcomPort = "cat.icom.port" // Icom USB CI-V serial port (e.g. COM5)
|
||||||
keyCATIcomBaud = "cat.icom.baud" // Icom CI-V baud (default 115200)
|
keyCATIcomBaud = "cat.icom.baud" // Icom CI-V baud (default 115200)
|
||||||
keyCATIcomAddr = "cat.icom.addr" // Icom CI-V address, decimal (IC-7610 = 152 / 0x98)
|
keyCATIcomAddr = "cat.icom.addr" // Icom CI-V address, decimal (IC-7610 = 152 / 0x98)
|
||||||
@@ -136,6 +137,9 @@ const (
|
|||||||
keyCATTCIHost = "cat.tci.host" // TCI host (Expert Electronics SunSDR / ExpertSDR2)
|
keyCATTCIHost = "cat.tci.host" // TCI host (Expert Electronics SunSDR / ExpertSDR2)
|
||||||
keyCATTCIPort = "cat.tci.port" // TCI WebSocket port (default 40001)
|
keyCATTCIPort = "cat.tci.port" // TCI WebSocket port (default 40001)
|
||||||
keyCATTCISpots = "cat.tci.spots" // push cluster spots to the TCI panorama
|
keyCATTCISpots = "cat.tci.spots" // push cluster spots to the TCI panorama
|
||||||
|
keyCATPttHotkeyEnabled = "cat.ptt_hotkey.enabled" // keyboard PTT key active
|
||||||
|
keyCATPttHotkey = "cat.ptt_hotkey.code" // bound key (KeyboardEvent.code)
|
||||||
|
keyCATPttHotkeyToggle = "cat.ptt_hotkey.toggle" // toggle mode vs hold-to-talk
|
||||||
|
|
||||||
// Audio (Digital Voice Keyer + QSO recorder). Machine-local hardware, so
|
// Audio (Digital Voice Keyer + QSO recorder). Machine-local hardware, so
|
||||||
// global (not per-profile) like CAT/rotator. Device fields store the
|
// global (not per-profile) like CAT/rotator. Device fields store the
|
||||||
@@ -196,9 +200,25 @@ const (
|
|||||||
keyRotatorHasElevation = "rotator.has_elevation"
|
keyRotatorHasElevation = "rotator.has_elevation"
|
||||||
keyRotatorType = "rotator.type" // "pst" (PstRotator UDP) | "rotgenius" (4O3A native TCP) | "arco" (microHAM ARCO, GS-232A)
|
keyRotatorType = "rotator.type" // "pst" (PstRotator UDP) | "rotgenius" (4O3A native TCP) | "arco" (microHAM ARCO, GS-232A)
|
||||||
keyRotatorNum = "rotator.num" // Rotator Genius rotator index: 1 or 2
|
keyRotatorNum = "rotator.num" // Rotator Genius rotator index: 1 or 2
|
||||||
|
keyRotatorDual = "rotator.dual" // Rotator Genius drives both rotors; compass shows a Rotor 1/2 toggle
|
||||||
|
keyRotatorActive = "rotator.active" // currently selected rotor when dual: 1 or 2 (set from the compass toggle, not the settings panel)
|
||||||
|
keyRotatorName1 = "rotator.name1" // optional label for rotor 1 (e.g. "Yagi HF")
|
||||||
|
keyRotatorName2 = "rotator.name2" // optional label for rotor 2
|
||||||
|
keyRotatorMotor1 = "rotator.motor1" // rotor 1 carries the motorized antenna (Ultrabeam/SteppIR) → show pattern paths
|
||||||
|
keyRotatorMotor2 = "rotator.motor2" // rotor 2 carries the motorized antenna
|
||||||
keyRotatorTransport = "rotator.transport" // arco: "tcp" (LAN) | "serial" (USB virtual COM)
|
keyRotatorTransport = "rotator.transport" // arco: "tcp" (LAN) | "serial" (USB virtual COM)
|
||||||
keyRotatorComPort = "rotator.com_port" // GS-232 serial transport
|
keyRotatorComPort = "rotator.com_port" // GS-232 serial transport
|
||||||
keyRotatorBaud = "rotator.baud" // GS-232 serial baud (an ERC needs it; an ARCO ignores it)
|
keyRotatorBaud = "rotator.baud" // GS-232 serial baud (an ERC needs it; an ARCO ignores it)
|
||||||
|
// Rotor 2 — a fully independent second rotor (any type), so an ARCO and a
|
||||||
|
// Rotator Genius can run side by side. Mirrors the rotor-1 device keys.
|
||||||
|
keyRotatorType2 = "rotator.type2"
|
||||||
|
keyRotatorHost2 = "rotator.host2"
|
||||||
|
keyRotatorPort2 = "rotator.port2"
|
||||||
|
keyRotatorHasElevation2 = "rotator.has_elevation2"
|
||||||
|
keyRotatorNum2 = "rotator.num2" // Rotator Genius internal index when rotor 2 is a RG
|
||||||
|
keyRotatorTransport2 = "rotator.transport2"
|
||||||
|
keyRotatorComPort2 = "rotator.com_port2"
|
||||||
|
keyRotatorBaud2 = "rotator.baud2"
|
||||||
|
|
||||||
// Motorized antenna (Ultrabeam or SteppIR) — Hardware → Antenna. Keys keep the
|
// Motorized antenna (Ultrabeam or SteppIR) — Hardware → Antenna. Keys keep the
|
||||||
// "ultrabeam." prefix for backward compatibility with configs saved before the
|
// "ultrabeam." prefix for backward compatibility with configs saved before the
|
||||||
@@ -384,6 +404,10 @@ type CATSettings struct {
|
|||||||
// adapter). NOT the radio’s own RJ45, which speaks Kenwood’s KNS/ARCP.
|
// adapter). NOT the radio’s own RJ45, which speaks Kenwood’s KNS/ARCP.
|
||||||
KenwoodPort string `json:"kenwood_port"` // Kenwood CAT serial port (TS-590/890/2000, Elecraft)
|
KenwoodPort string `json:"kenwood_port"` // Kenwood CAT serial port (TS-590/890/2000, Elecraft)
|
||||||
KenwoodBaud int `json:"kenwood_baud"` // Kenwood CAT baud (TS-590 default 9600)
|
KenwoodBaud int `json:"kenwood_baud"` // Kenwood CAT baud (TS-590 default 9600)
|
||||||
|
// What a DATA/digital mode (FT8, PSK…) sets on the rig: "usb" (default), "data"
|
||||||
|
// (MD6 — Elecraft K3/K4 DATA mode) or "keep" (leave the rig's mode untouched,
|
||||||
|
// for a TS-590SG/TS-990S whose data mode is a USB modifier set on the rig).
|
||||||
|
KenwoodDataMode string `json:"kenwood_data_mode"`
|
||||||
// Per-backend: deassert DTR and RTS after opening the CAT port, for
|
// Per-backend: deassert DTR and RTS after opening the CAT port, for
|
||||||
// interfaces that read either line as PTT. Off by default: lowering them
|
// interfaces that read either line as PTT. Off by default: lowering them
|
||||||
// stops some USB-serial interfaces transmitting at all.
|
// stops some USB-serial interfaces transmitting at all.
|
||||||
@@ -404,6 +428,12 @@ type CATSettings struct {
|
|||||||
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 (Hamlib NET rigctl)
|
||||||
SharePort int `json:"share_port"` // TCP port for it (default 4532)
|
SharePort int `json:"share_port"` // TCP port for it (default 4532)
|
||||||
|
// PTT hotkey — a keyboard key that keys the transmitter while OpsLog is
|
||||||
|
// focused (hold-to-talk, or toggle). Uses the configured Audio → PTT method,
|
||||||
|
// falling back to CAT keying when that is VOX/none.
|
||||||
|
PTTHotkeyEnabled bool `json:"ptt_hotkey_enabled"`
|
||||||
|
PTTHotkey string `json:"ptt_hotkey"` // KeyboardEvent.code, e.g. "Backslash", "Pause"
|
||||||
|
PTTHotkeyToggle bool `json:"ptt_hotkey_toggle"` // press to key / press again to unkey (vs hold)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ModePreset is a mode entry with default RST values to auto-populate
|
// ModePreset is a mode entry with default RST values to auto-populate
|
||||||
@@ -592,6 +622,12 @@ type App struct {
|
|||||||
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
|
||||||
pttMu sync.Mutex
|
pttMu sync.Mutex
|
||||||
|
// confDLMu/confDLCancel cancel the in-flight confirmation download (LoTW/QRZ)
|
||||||
|
// so closing the QSL Manager — or starting another download — stops the previous
|
||||||
|
// one instead of leaving it running against the app-lifetime context (which made
|
||||||
|
// a still-running QRZ sync bleed its log into a freshly started LoTW download).
|
||||||
|
confDLMu sync.Mutex
|
||||||
|
confDLCancel context.CancelFunc
|
||||||
udpLogMu sync.Mutex // serialises UDP auto-log so concurrent packets can't both pass the dedup check
|
udpLogMu sync.Mutex // serialises UDP auto-log so concurrent packets can't both pass the dedup check
|
||||||
adifMonMu sync.Mutex // guards the ADIF-monitor config (file list + per-file read offsets)
|
adifMonMu sync.Mutex // guards the ADIF-monitor config (file list + per-file read offsets)
|
||||||
relayAutoMu sync.Mutex // serialises relay auto-control evaluation
|
relayAutoMu sync.Mutex // serialises relay auto-control evaluation
|
||||||
@@ -950,6 +986,12 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
a.settings.SetProfile(active.ID)
|
a.settings.SetProfile(active.ID)
|
||||||
a.settingsScoped.Store(true) // per-profile settings reads (GetUIPref…) are now safe
|
a.settingsScoped.Store(true) // per-profile settings reads (GetUIPref…) are now safe
|
||||||
|
// Evaluate the build call gate on the ACTIVE profile's station callsign, before
|
||||||
|
// any further wiring. A denied call exits here, silently. Only the active
|
||||||
|
// profile is checked (not every profile) so that "--profile <other>" is always
|
||||||
|
// a way back in — a blocked call is otherwise unrecoverable, since the process
|
||||||
|
// quits before the operator can change it.
|
||||||
|
enforceCallGate(active.Callsign)
|
||||||
// US county resolver — its own local SQLite (data/uls.db), populated on demand
|
// US county resolver — its own local SQLite (data/uls.db), populated on demand
|
||||||
// by DownloadULSCounties. Opening (creating an empty store) is cheap and never
|
// by DownloadULSCounties. Opening (creating an empty store) is cheap and never
|
||||||
// fatal: county resolution simply stays inert until the operator downloads it.
|
// fatal: county resolution simply stays inert until the operator downloads it.
|
||||||
@@ -6830,7 +6872,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, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort)
|
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATYaesuLowLines, keyCATKenwoodLowLines, keyCATKenwoodDataMode, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPttHotkeyEnabled, keyCATPttHotkey, keyCATPttHotkeyToggle, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return CATSettings{}, err
|
return CATSettings{}, err
|
||||||
}
|
}
|
||||||
@@ -6853,6 +6895,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
|||||||
KenwoodBaud: 9600,
|
KenwoodBaud: 9600,
|
||||||
YaesuLowLines: m[keyCATYaesuLowLines] == "1",
|
YaesuLowLines: m[keyCATYaesuLowLines] == "1",
|
||||||
KenwoodLowLines: m[keyCATKenwoodLowLines] == "1",
|
KenwoodLowLines: m[keyCATKenwoodLowLines] == "1",
|
||||||
|
KenwoodDataMode: m[keyCATKenwoodDataMode],
|
||||||
IcomPort: m[keyCATIcomPort],
|
IcomPort: m[keyCATIcomPort],
|
||||||
IcomBaud: 115200,
|
IcomBaud: 115200,
|
||||||
IcomAddr: 0x98, // IC-7610 default
|
IcomAddr: 0x98, // IC-7610 default
|
||||||
@@ -6863,6 +6906,9 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
|||||||
TCIHost: m[keyCATTCIHost],
|
TCIHost: m[keyCATTCIHost],
|
||||||
TCIPort: 40001,
|
TCIPort: 40001,
|
||||||
TCISpots: m[keyCATTCISpots] == "1",
|
TCISpots: m[keyCATTCISpots] == "1",
|
||||||
|
PTTHotkeyEnabled: m[keyCATPttHotkeyEnabled] == "1",
|
||||||
|
PTTHotkey: m[keyCATPttHotkey],
|
||||||
|
PTTHotkeyToggle: m[keyCATPttHotkeyToggle] == "1",
|
||||||
PollMs: 250,
|
PollMs: 250,
|
||||||
DelayMs: 0,
|
DelayMs: 0,
|
||||||
DigitalDefault: m[keyCATDigitalDefault],
|
DigitalDefault: m[keyCATDigitalDefault],
|
||||||
@@ -7019,6 +7065,7 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
|||||||
keyCATKenwoodBaud: strconv.Itoa(s.KenwoodBaud),
|
keyCATKenwoodBaud: strconv.Itoa(s.KenwoodBaud),
|
||||||
keyCATYaesuLowLines: b01(s.YaesuLowLines),
|
keyCATYaesuLowLines: b01(s.YaesuLowLines),
|
||||||
keyCATKenwoodLowLines: b01(s.KenwoodLowLines),
|
keyCATKenwoodLowLines: b01(s.KenwoodLowLines),
|
||||||
|
keyCATKenwoodDataMode: strings.ToLower(strings.TrimSpace(s.KenwoodDataMode)),
|
||||||
keyCATIcomPort: strings.TrimSpace(s.IcomPort),
|
keyCATIcomPort: strings.TrimSpace(s.IcomPort),
|
||||||
keyCATIcomBaud: strconv.Itoa(s.IcomBaud),
|
keyCATIcomBaud: strconv.Itoa(s.IcomBaud),
|
||||||
keyCATIcomAddr: strconv.Itoa(s.IcomAddr),
|
keyCATIcomAddr: strconv.Itoa(s.IcomAddr),
|
||||||
@@ -7029,6 +7076,9 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
|||||||
keyCATTCIHost: strings.TrimSpace(s.TCIHost),
|
keyCATTCIHost: strings.TrimSpace(s.TCIHost),
|
||||||
keyCATTCIPort: strconv.Itoa(s.TCIPort),
|
keyCATTCIPort: strconv.Itoa(s.TCIPort),
|
||||||
keyCATTCISpots: tciSpots,
|
keyCATTCISpots: tciSpots,
|
||||||
|
keyCATPttHotkeyEnabled: b01(s.PTTHotkeyEnabled),
|
||||||
|
keyCATPttHotkey: strings.TrimSpace(s.PTTHotkey),
|
||||||
|
keyCATPttHotkeyToggle: b01(s.PTTHotkeyToggle),
|
||||||
keyCATPollMs: strconv.Itoa(s.PollMs),
|
keyCATPollMs: strconv.Itoa(s.PollMs),
|
||||||
keyCATDelayMs: strconv.Itoa(s.DelayMs),
|
keyCATDelayMs: strconv.Itoa(s.DelayMs),
|
||||||
keyCATDigitalDefault: strings.ToUpper(strings.TrimSpace(s.DigitalDefault)),
|
keyCATDigitalDefault: strings.ToUpper(strings.TrimSpace(s.DigitalDefault)),
|
||||||
@@ -8949,6 +8999,32 @@ func (a *App) pttUnkey() {
|
|||||||
applog.Printf("dvk: PTT released")
|
applog.Printf("dvk: PTT released")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PTTHotkeyDown keys the transmitter for the keyboard PTT hotkey. It uses the
|
||||||
|
// configured Audio → PTT method (CAT / RTS / DTR); when that is VOX/none it
|
||||||
|
// falls back to CAT keying so the hotkey still works with a plain CAT rig.
|
||||||
|
func (a *App) PTTHotkeyDown() error {
|
||||||
|
// This hotkey lives under CAT and is meant to key the rig over CAT — so prefer
|
||||||
|
// CAT whenever a backend is active. Only a rig with no CAT link falls back to
|
||||||
|
// the Audio-tab PTT method (RTS/DTR serial). Previously it always used the
|
||||||
|
// Audio method, so a stale serial PTT setting (e.g. an RTS line on a COM port
|
||||||
|
// that is no longer present) hijacked the hotkey and it failed with a serial
|
||||||
|
// error — even though the operator only ever wanted CAT.
|
||||||
|
if a.cat != nil {
|
||||||
|
return a.pttKey(AudioSettings{PTTMethod: "cat"})
|
||||||
|
}
|
||||||
|
cfg, err := a.GetAudioSettings()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cfg.PTTMethod == "" || cfg.PTTMethod == "none" {
|
||||||
|
return fmt.Errorf("no PTT method configured and CAT not active")
|
||||||
|
}
|
||||||
|
return a.pttKey(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PTTHotkeyUp releases the PTT hotkey.
|
||||||
|
func (a *App) PTTHotkeyUp() { a.pttUnkey() }
|
||||||
|
|
||||||
// TestPTT keys PTT for ~600ms so the user can confirm the rig transmits.
|
// TestPTT keys PTT for ~600ms so the user can confirm the rig transmits.
|
||||||
// TestPTT keys the transmitter for ~600ms using the GIVEN settings (the live
|
// TestPTT keys the transmitter for ~600ms using the GIVEN settings (the live
|
||||||
// UI selection), so the user can test a method/port without saving first —
|
// UI selection), so the user can test a method/port without saving first —
|
||||||
@@ -10116,13 +10192,35 @@ func (a *App) DownloadConfirmations(service string, addNotFound bool, since stri
|
|||||||
}
|
}
|
||||||
svc := extsvc.Service(service)
|
svc := extsvc.Service(service)
|
||||||
cfg := a.loadExternalServices()
|
cfg := a.loadExternalServices()
|
||||||
go a.runDownloadConfirmations(svc, cfg, addNotFound, since)
|
// Cancel any download still running (a slow QRZ sync, say) before starting this
|
||||||
|
// one, so the two don't run at once and interleave their logs — and derive a
|
||||||
|
// cancellable context so closing the QSL Manager can stop it too.
|
||||||
|
a.confDLMu.Lock()
|
||||||
|
if a.confDLCancel != nil {
|
||||||
|
a.confDLCancel()
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(a.ctx)
|
||||||
|
a.confDLCancel = cancel
|
||||||
|
a.confDLMu.Unlock()
|
||||||
|
go a.runDownloadConfirmations(ctx, svc, cfg, addNotFound, since)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalServices, addNotFound bool, since string) {
|
// CancelConfirmations stops the in-flight LoTW/QRZ download — the QSL Manager
|
||||||
|
// calls it when the window closes, so work doesn't keep running in the background.
|
||||||
|
func (a *App) CancelConfirmations() {
|
||||||
|
a.confDLMu.Lock()
|
||||||
|
if a.confDLCancel != nil {
|
||||||
|
a.confDLCancel()
|
||||||
|
a.confDLCancel = nil
|
||||||
|
}
|
||||||
|
a.confDLMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service, cfg extsvc.ExternalServices, addNotFound bool, since string) {
|
||||||
emit := func(line string) {
|
emit := func(line string) {
|
||||||
if a.ctx != nil {
|
// Don't keep logging into a closed/cancelled window.
|
||||||
|
if a.ctx != nil && ctx.Err() == nil {
|
||||||
wruntime.EventsEmit(a.ctx, "qslmgr:log", line)
|
wruntime.EventsEmit(a.ctx, "qslmgr:log", line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10131,7 +10229,6 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
|||||||
wruntime.EventsEmit(a.ctx, "qslmgr:done", map[string]any{"uploaded": matched, "total": total})
|
wruntime.EventsEmit(a.ctx, "qslmgr:done", map[string]any{"uploaded": matched, "total": total})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ctx := a.ctx
|
|
||||||
matched, total, added := 0, 0, 0
|
matched, total, added := 0, 0, 0
|
||||||
|
|
||||||
// resolveSince turns the UI's request into a concrete date (or ""):
|
// resolveSince turns the UI's request into a concrete date (or ""):
|
||||||
@@ -10183,6 +10280,9 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
|||||||
var items []ConfirmationItem
|
var items []ConfirmationItem
|
||||||
var unmatched []string
|
var unmatched []string
|
||||||
perr := adif.Parse(strings.NewReader(adifText), func(rec adif.Record) error {
|
perr := adif.Parse(strings.NewReader(adifText), func(rec adif.Record) error {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err() // window closed / superseded
|
||||||
|
}
|
||||||
q, ok := adif.RecordToQSO(rec)
|
q, ok := adif.RecordToQSO(rec)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
@@ -10365,6 +10465,9 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
|||||||
// QRZ FETCH returns headerless ADIF (no <EOH>); prepend one so the
|
// QRZ FETCH returns headerless ADIF (no <EOH>); prepend one so the
|
||||||
// parser treats the stream as records.
|
// parser treats the stream as records.
|
||||||
perr := adif.Parse(strings.NewReader("<EOH>\n"+adifText), func(rec adif.Record) error {
|
perr := adif.Parse(strings.NewReader("<EOH>\n"+adifText), func(rec adif.Record) error {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err() // window closed / superseded — stop clearing flags
|
||||||
|
}
|
||||||
parsed++
|
parsed++
|
||||||
for k := range rec {
|
for k := range rec {
|
||||||
allKeys[k] = true
|
allKeys[k] = true
|
||||||
@@ -10482,7 +10585,11 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
|||||||
}
|
}
|
||||||
sort.Strings(keys)
|
sort.Strings(keys)
|
||||||
emit(fmt.Sprintf("Parsed %d record(s). Fields seen: %s", parsed, strings.Join(keys, ", ")))
|
emit(fmt.Sprintf("Parsed %d record(s). Fields seen: %s", parsed, strings.Join(keys, ", ")))
|
||||||
emit(fmt.Sprintf("Confirmed %d, added %d (of %d returned)", matched, added, total))
|
// Spell out the funnel: QRZ hands back the WHOLE logbook, but a confirmation
|
||||||
|
// download only acts on the ones QRZ marks confirmed — the "26165 but only
|
||||||
|
// 7950?" question. Nothing is lost; the rest simply aren't confirmed yet.
|
||||||
|
emit(fmt.Sprintf("%d of your %d QRZ QSOs are confirmed by the other station — the rest aren't confirmed yet, so there's nothing to import for them.", total, parsed))
|
||||||
|
emit(fmt.Sprintf("Of those %d confirmed: %d newly marked, %d added.", total, matched, added))
|
||||||
if unconfirmed > 0 {
|
if unconfirmed > 0 {
|
||||||
// Worth its own line and not a footnote: these QSOs were showing as
|
// Worth its own line and not a footnote: these QSOs were showing as
|
||||||
// confirmed a moment ago, and an award total may move because of it.
|
// confirmed a moment ago, and an award total may move because of it.
|
||||||
@@ -10534,6 +10641,9 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
|||||||
var items []ConfirmationItem
|
var items []ConfirmationItem
|
||||||
var unmatched []string
|
var unmatched []string
|
||||||
perr := adif.Parse(strings.NewReader(adifText), func(rec adif.Record) error {
|
perr := adif.Parse(strings.NewReader(adifText), func(rec adif.Record) error {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err() // window closed / superseded
|
||||||
|
}
|
||||||
q, ok := adif.RecordToQSO(rec)
|
q, ok := adif.RecordToQSO(rec)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
@@ -12908,10 +13018,13 @@ func (a *App) reloadCAT() {
|
|||||||
// more deliberate setting, and silently preferring the wire would leave an
|
// more deliberate setting, and silently preferring the wire would leave an
|
||||||
// operator staring at a host they typed and a radio that never answers.
|
// operator staring at a host they typed and a radio that never answers.
|
||||||
if h := strings.TrimSpace(s.KenwoodHost); h != "" {
|
if h := strings.TrimSpace(s.KenwoodHost); h != "" {
|
||||||
a.cat.Start(cat.NewKenwoodTCP(h, s.DigitalDefault))
|
kw := cat.NewKenwoodTCP(h, s.DigitalDefault)
|
||||||
|
kw.SetDataMode(s.KenwoodDataMode)
|
||||||
|
a.cat.Start(kw)
|
||||||
} else {
|
} else {
|
||||||
kw := cat.NewKenwood(s.KenwoodPort, s.KenwoodBaud, s.DigitalDefault)
|
kw := cat.NewKenwood(s.KenwoodPort, s.KenwoodBaud, s.DigitalDefault)
|
||||||
kw.SetLowerLines(s.KenwoodLowLines)
|
kw.SetLowerLines(s.KenwoodLowLines)
|
||||||
|
kw.SetDataMode(s.KenwoodDataMode)
|
||||||
a.cat.Start(kw)
|
a.cat.Start(kw)
|
||||||
}
|
}
|
||||||
case "icom":
|
case "icom":
|
||||||
@@ -12946,7 +13059,14 @@ func (a *App) reloadCAT() {
|
|||||||
case "tci":
|
case "tci":
|
||||||
// Expert Electronics TCI (WebSocket) — SunSDR / ExpertSDR2, or any
|
// Expert Electronics TCI (WebSocket) — SunSDR / ExpertSDR2, or any
|
||||||
// TCI-compatible server.
|
// TCI-compatible server.
|
||||||
a.cat.Start(cat.NewTCI(s.TCIHost, s.TCIPort, s.DigitalDefault, s.TCISpots))
|
tb := cat.NewTCI(s.TCIHost, s.TCIPort, s.DigitalDefault, s.TCISpots)
|
||||||
|
// Clicking one of our spots on the ExpertSDR panorama fills the entry form.
|
||||||
|
tb.OnSpotClick = func(call string, hz int64) {
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "tci:spot_clicked", map[string]any{"call": call, "freq_hz": hz})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.cat.Start(tb)
|
||||||
default:
|
default:
|
||||||
// Unknown backend → stop and emit a dummy state so the UI shows it.
|
// Unknown backend → stop and emit a dummy state so the UI shows it.
|
||||||
a.cat.Stop()
|
a.cat.Stop()
|
||||||
@@ -13254,246 +13374,414 @@ func (a *App) DuplicateProfile(id int64, newName string) (profile.Profile, error
|
|||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Rotator bindings (PstRotator UDP v0) ---
|
// --- Rotator bindings ---
|
||||||
|
|
||||||
// RotatorSettings is the JSON shape for the Hardware → Rotator panel.
|
const keyRotatorsList = "rotators.json"
|
||||||
type RotatorSettings struct {
|
|
||||||
Enabled bool `json:"enabled"`
|
// RotatorDevice is one configured rotor in Settings → Rotator (a list, like the
|
||||||
Type string `json:"type"` // "pst" (PstRotator UDP) | "rotgenius" (4O3A native TCP) | "arco" (microHAM ARCO, Yaesu GS-232A over TCP)
|
// amplifiers). A Rotator Genius with Dual on drives BOTH its ports and so
|
||||||
|
// contributes TWO logical rotors — Name/Motorized for the first, Name2/Motorized2
|
||||||
|
// for the second.
|
||||||
|
type RotatorDevice struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"` // "pst" (PstRotator UDP) | "rotgenius" (4O3A native TCP) | "arco" (GS-232A)
|
||||||
Host string `json:"host"` // default 127.0.0.1
|
Host string `json:"host"` // default 127.0.0.1
|
||||||
Port int `json:"port"` // default 12000 (pst) / 9006 (rotgenius) / 4001 (arco — must match the port set in ARCO's LAN CONTROL PROTOCOL)
|
Port int `json:"port"` // default 12000 (pst) / 9006 (rotgenius) / 4001 (arco)
|
||||||
HasElevation bool `json:"has_elevation"` // include EL in GoTo packets (PstRotator)
|
HasElevation bool `json:"has_elevation"` // include EL in GoTo packets (PstRotator)
|
||||||
RotatorNum int `json:"rotator_num"` // Rotator Genius index: 1 or 2
|
RotatorNum int `json:"rotator_num"` // Rotator Genius internal index (1/2) when not Dual
|
||||||
Transport string `json:"transport"` // GS-232 controller: "tcp" (LAN) | "serial" (COM)
|
Dual bool `json:"dual"` // Rotator Genius: drive both ports → two logical rotors
|
||||||
|
Motorized bool `json:"motorized"` // carries a motorized antenna (Ultrabeam/SteppIR) → show pattern paths
|
||||||
|
Name2 string `json:"name2"` // Dual: label for the second rotor
|
||||||
|
Motorized2 bool `json:"motorized2"` // Dual: second rotor motorized
|
||||||
|
Transport string `json:"transport"` // ARCO: "tcp" (LAN) | "serial" (USB COM)
|
||||||
ComPort string `json:"com_port"` // GS-232 serial transport
|
ComPort string `json:"com_port"` // GS-232 serial transport
|
||||||
// Baud for the serial transport. An ARCO's virtual COM ignores it; an ERC runs
|
Baud int `json:"baud"` // GS-232 serial baud (an ERC needs it; an ARCO ignores it)
|
||||||
// at the rate set in its own configuration, so it has to be selectable.
|
|
||||||
Baud int `json:"baud"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRotatorSettings returns the persisted rotator config with defaults.
|
// logicalRotor is one addressable rotor. Flattening the device list expands a
|
||||||
func (a *App) GetRotatorSettings() (RotatorSettings, error) {
|
// Dual Rotator Genius into two.
|
||||||
out := RotatorSettings{Type: "pst", Host: "127.0.0.1", Port: 12000, RotatorNum: 1}
|
type logicalRotor struct {
|
||||||
|
Name string
|
||||||
|
Motorized bool
|
||||||
|
Link rotorLink
|
||||||
|
}
|
||||||
|
|
||||||
|
// normRotorType clamps a rotor type to a known backend.
|
||||||
|
func normRotorType(t string) string {
|
||||||
|
if t == "rotgenius" || t == "arco" {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
return "pst"
|
||||||
|
}
|
||||||
|
|
||||||
|
// rotatorDefaultPort is each backend's default network port.
|
||||||
|
func rotatorDefaultPort(typ string) int {
|
||||||
|
switch typ {
|
||||||
|
case "rotgenius":
|
||||||
|
return 9006 // 4O3A native default
|
||||||
|
case "arco":
|
||||||
|
return 4001 // placeholder — the real number is set in ARCO's LAN menu
|
||||||
|
default:
|
||||||
|
return 12000 // PstRotator UDP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deviceLink builds the connection params for a device's rotor. sub selects the
|
||||||
|
// Rotator Genius internal port (1/2) for a Dual device; ignored otherwise.
|
||||||
|
func deviceLink(d RotatorDevice, sub int) rotorLink {
|
||||||
|
l := rotorLink{
|
||||||
|
Type: normRotorType(d.Type), Host: d.Host, Port: d.Port,
|
||||||
|
Transport: d.Transport, ComPort: d.ComPort, Baud: d.Baud, HasElevation: d.HasElevation,
|
||||||
|
}
|
||||||
|
if l.Host == "" {
|
||||||
|
l.Host = "127.0.0.1"
|
||||||
|
}
|
||||||
|
if l.Port <= 0 || l.Port > 65535 {
|
||||||
|
l.Port = rotatorDefaultPort(l.Type)
|
||||||
|
}
|
||||||
|
if l.Baud <= 0 {
|
||||||
|
l.Baud = 9600
|
||||||
|
}
|
||||||
|
if l.Transport != "serial" {
|
||||||
|
l.Transport = "tcp"
|
||||||
|
}
|
||||||
|
if l.Type == "rotgenius" {
|
||||||
|
if d.Dual {
|
||||||
|
l.Num = 1
|
||||||
|
if sub == 2 {
|
||||||
|
l.Num = 2
|
||||||
|
}
|
||||||
|
} else if d.RotatorNum == 2 {
|
||||||
|
l.Num = 2
|
||||||
|
} else {
|
||||||
|
l.Num = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// flattenRotors expands the device list into the ordered logical rotors the
|
||||||
|
// compass switches between (a Dual Rotator Genius yields two).
|
||||||
|
func flattenRotors(devs []RotatorDevice) []logicalRotor {
|
||||||
|
var out []logicalRotor
|
||||||
|
for _, d := range devs {
|
||||||
|
if normRotorType(d.Type) == "rotgenius" && d.Dual {
|
||||||
|
out = append(out,
|
||||||
|
logicalRotor{Name: d.Name, Motorized: d.Motorized, Link: deviceLink(d, 1)},
|
||||||
|
logicalRotor{Name: d.Name2, Motorized: d.Motorized2, Link: deviceLink(d, 2)},
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, logicalRotor{Name: d.Name, Motorized: d.Motorized, Link: deviceLink(d, 1)})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRotators returns the configured rotor list, migrating the legacy single-
|
||||||
|
// rotor flat settings into the list on first read (persisted on next save).
|
||||||
|
func (a *App) GetRotators() ([]RotatorDevice, error) {
|
||||||
if a.settings == nil {
|
if a.settings == nil {
|
||||||
return out, fmt.Errorf("db not initialized")
|
return nil, fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
m, err := a.settings.GetMany(a.ctx,
|
raw, _ := a.settings.Get(a.ctx, keyRotatorsList)
|
||||||
keyRotatorEnabled, keyRotatorHost, keyRotatorPort, keyRotatorHasElevation, keyRotatorType, keyRotatorNum,
|
if strings.TrimSpace(raw) != "" {
|
||||||
keyRotatorTransport, keyRotatorComPort, keyRotatorBaud)
|
var list []RotatorDevice
|
||||||
if err != nil {
|
if err := json.Unmarshal([]byte(raw), &list); err == nil {
|
||||||
return out, err
|
return list, nil
|
||||||
}
|
}
|
||||||
out.Enabled = m[keyRotatorEnabled] == "1"
|
|
||||||
if t := m[keyRotatorType]; t == "rotgenius" || t == "pst" || t == "arco" {
|
|
||||||
out.Type = t
|
|
||||||
}
|
}
|
||||||
if h := m[keyRotatorHost]; h != "" {
|
return a.migrateLegacyRotors(), nil
|
||||||
out.Host = h
|
|
||||||
}
|
|
||||||
if p, _ := strconv.Atoi(m[keyRotatorPort]); p > 0 && p <= 65535 {
|
|
||||||
out.Port = p
|
|
||||||
} else if out.Type == "rotgenius" {
|
|
||||||
out.Port = 9006 // native default when no port stored yet
|
|
||||||
} else if out.Type == "arco" {
|
|
||||||
out.Port = 4001 // placeholder — the real number is set in ARCO's LAN menu
|
|
||||||
}
|
|
||||||
out.HasElevation = m[keyRotatorHasElevation] == "1"
|
|
||||||
if rn, _ := strconv.Atoi(m[keyRotatorNum]); rn == 2 {
|
|
||||||
out.RotatorNum = 2
|
|
||||||
}
|
|
||||||
out.Transport = "tcp"
|
|
||||||
if m[keyRotatorTransport] == "serial" {
|
|
||||||
out.Transport = "serial"
|
|
||||||
}
|
|
||||||
out.ComPort = m[keyRotatorComPort]
|
|
||||||
out.Baud = 9600
|
|
||||||
if b, _ := strconv.Atoi(m[keyRotatorBaud]); b > 0 {
|
|
||||||
out.Baud = b
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveRotatorSettings persists the rotator config. Connection is per-call
|
// migrateLegacyRotors builds a device list from the pre-list flat settings keys
|
||||||
// (UDP, no socket to (re)open) so no reload step is needed.
|
// (the single rotor shipped in 0.23.2, plus the unreleased two-rotor keys).
|
||||||
func (a *App) SaveRotatorSettings(s RotatorSettings) error {
|
func (a *App) migrateLegacyRotors() []RotatorDevice {
|
||||||
|
m, _ := a.settings.GetMany(a.ctx,
|
||||||
|
keyRotatorEnabled, keyRotatorType, keyRotatorHost, keyRotatorPort, keyRotatorHasElevation, keyRotatorNum,
|
||||||
|
keyRotatorDual, keyRotatorName1, keyRotatorName2, keyRotatorMotor1, keyRotatorMotor2,
|
||||||
|
keyRotatorTransport, keyRotatorComPort, keyRotatorBaud,
|
||||||
|
keyRotatorType2, keyRotatorHost2, keyRotatorPort2, keyRotatorHasElevation2, keyRotatorNum2,
|
||||||
|
keyRotatorTransport2, keyRotatorComPort2, keyRotatorBaud2)
|
||||||
|
if m[keyRotatorEnabled] != "1" && m[keyRotatorType] == "" && m[keyRotatorHost] == "" {
|
||||||
|
return []RotatorDevice{} // never configured
|
||||||
|
}
|
||||||
|
atoi := func(s string) int { n, _ := strconv.Atoi(s); return n }
|
||||||
|
typ := normRotorType(m[keyRotatorType])
|
||||||
|
dual := m[keyRotatorDual] == "1"
|
||||||
|
d1 := RotatorDevice{
|
||||||
|
ID: "rotor-1", Name: strings.TrimSpace(m[keyRotatorName1]), Type: typ,
|
||||||
|
Host: m[keyRotatorHost], Port: atoi(m[keyRotatorPort]), HasElevation: m[keyRotatorHasElevation] == "1",
|
||||||
|
RotatorNum: map[bool]int{true: 2, false: 1}[m[keyRotatorNum] == "2"],
|
||||||
|
Motorized: m[keyRotatorMotor1] != "0", Transport: m[keyRotatorTransport], ComPort: m[keyRotatorComPort], Baud: atoi(m[keyRotatorBaud]),
|
||||||
|
// A legacy Rotator Genius "two rotors" was one Dual device.
|
||||||
|
Dual: dual && typ == "rotgenius", Name2: strings.TrimSpace(m[keyRotatorName2]), Motorized2: m[keyRotatorMotor2] == "1",
|
||||||
|
}
|
||||||
|
devs := []RotatorDevice{d1}
|
||||||
|
// The unreleased "two independent rotors" (dual with a distinct rotor 2 of any
|
||||||
|
// type) becomes a second list entry.
|
||||||
|
if dual && typ != "rotgenius" {
|
||||||
|
devs = append(devs, RotatorDevice{
|
||||||
|
ID: "rotor-2", Name: strings.TrimSpace(m[keyRotatorName2]), Type: normRotorType(m[keyRotatorType2]),
|
||||||
|
Host: m[keyRotatorHost2], Port: atoi(m[keyRotatorPort2]), HasElevation: m[keyRotatorHasElevation2] == "1",
|
||||||
|
RotatorNum: map[bool]int{true: 2, false: 1}[m[keyRotatorNum2] == "2"],
|
||||||
|
Motorized: m[keyRotatorMotor2] == "1", Transport: m[keyRotatorTransport2], ComPort: m[keyRotatorComPort2], Baud: atoi(m[keyRotatorBaud2]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return devs
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveRotators persists the rotor list. Connections are per-call (no socket to
|
||||||
|
// (re)open) so no reload step is needed.
|
||||||
|
func (a *App) SaveRotators(list []RotatorDevice) error {
|
||||||
if a.settings == nil {
|
if a.settings == nil {
|
||||||
return fmt.Errorf("db not initialized")
|
return fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
if s.Type != "rotgenius" && s.Type != "arco" {
|
for i := range list {
|
||||||
s.Type = "pst"
|
d := &list[i]
|
||||||
|
if strings.TrimSpace(d.ID) == "" {
|
||||||
|
d.ID = fmt.Sprintf("rotor-%d-%d", time.Now().Unix(), i)
|
||||||
}
|
}
|
||||||
if s.Host == "" {
|
d.Type = normRotorType(d.Type)
|
||||||
s.Host = "127.0.0.1"
|
if strings.TrimSpace(d.Host) == "" {
|
||||||
|
d.Host = "127.0.0.1"
|
||||||
}
|
}
|
||||||
if s.Port <= 0 || s.Port > 65535 {
|
if d.Port <= 0 || d.Port > 65535 {
|
||||||
s.Port = map[string]int{"rotgenius": 9006, "pst": 12000, "arco": 4001}[s.Type]
|
d.Port = rotatorDefaultPort(d.Type)
|
||||||
}
|
}
|
||||||
if s.RotatorNum != 2 {
|
if d.RotatorNum != 2 {
|
||||||
s.RotatorNum = 1
|
d.RotatorNum = 1
|
||||||
}
|
}
|
||||||
if s.Transport != "serial" {
|
if d.Transport != "serial" {
|
||||||
s.Transport = "tcp"
|
d.Transport = "tcp"
|
||||||
}
|
}
|
||||||
for k, v := range map[string]string{
|
if d.Baud <= 0 {
|
||||||
keyRotatorEnabled: boolStr(s.Enabled),
|
d.Baud = 9600
|
||||||
keyRotatorType: s.Type,
|
}
|
||||||
keyRotatorHost: s.Host,
|
}
|
||||||
keyRotatorPort: strconv.Itoa(s.Port),
|
b, err := json.Marshal(list)
|
||||||
keyRotatorHasElevation: boolStr(s.HasElevation),
|
if err != nil {
|
||||||
keyRotatorNum: strconv.Itoa(s.RotatorNum),
|
|
||||||
keyRotatorTransport: s.Transport,
|
|
||||||
keyRotatorComPort: strings.TrimSpace(s.ComPort),
|
|
||||||
keyRotatorBaud: strconv.Itoa(s.Baud),
|
|
||||||
} {
|
|
||||||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
return a.settings.Set(a.ctx, keyRotatorsList, string(b))
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// arcoClient builds the GS-232 client for the configured ARCO transport:
|
// rotorLink is the connection params for one rotor, so the command methods don't
|
||||||
|
// branch on device shape.
|
||||||
|
type rotorLink struct {
|
||||||
|
Type string
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
Num int // Rotator Genius internal index (1/2)
|
||||||
|
Transport string
|
||||||
|
ComPort string
|
||||||
|
Baud int
|
||||||
|
HasElevation bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// activeRotorIndex returns the compass-selected rotor index, clamped to the
|
||||||
|
// available logical rotors (0-based). Persisted via SetActiveRotor.
|
||||||
|
func (a *App) activeRotorIndex(n int) int {
|
||||||
|
if n <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
raw, _ := a.settings.Get(a.ctx, keyRotatorActive)
|
||||||
|
i, _ := strconv.Atoi(raw)
|
||||||
|
if i < 0 || i >= n {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetActiveRotor selects which rotor the compass drives and displays. Persisted
|
||||||
|
// so it survives a restart. The index is into the flattened logical-rotor list.
|
||||||
|
func (a *App) SetActiveRotor(index int) error {
|
||||||
|
if a.settings == nil {
|
||||||
|
return fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
if index < 0 {
|
||||||
|
index = 0
|
||||||
|
}
|
||||||
|
return a.settings.Set(a.ctx, keyRotatorActive, strconv.Itoa(index))
|
||||||
|
}
|
||||||
|
|
||||||
|
// arcoClient builds the GS-232 client for a rotor's ARCO transport:
|
||||||
// USB virtual COM (serial) or the LAN CONTROL PROTOCOL TCP port.
|
// USB virtual COM (serial) or the LAN CONTROL PROTOCOL TCP port.
|
||||||
func arcoClient(s RotatorSettings) *gs232.Client {
|
func arcoClient(l rotorLink) *gs232.Client {
|
||||||
if s.Transport == "serial" {
|
if l.Transport == "serial" {
|
||||||
return gs232.NewSerial(s.ComPort, s.Baud)
|
return gs232.NewSerial(l.ComPort, l.Baud)
|
||||||
}
|
}
|
||||||
return gs232.New(s.Host, s.Port)
|
return gs232.New(l.Host, l.Port)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RotatorHeading is the live antenna heading for the status bar.
|
// RotatorHeading is the live antenna heading for the status bar and compass.
|
||||||
type RotatorHeading struct {
|
type RotatorHeading struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
OK bool `json:"ok"`
|
OK bool `json:"ok"`
|
||||||
Azimuth int `json:"azimuth"`
|
Azimuth int `json:"azimuth"`
|
||||||
Raw string `json:"raw"`
|
Raw string `json:"raw"`
|
||||||
|
// The compass renders a rotor selector from these — one entry per logical
|
||||||
|
// rotor — without an extra roundtrip.
|
||||||
|
Rotors []string `json:"rotors"` // names of every logical rotor (may be empty strings)
|
||||||
|
Active int `json:"active"` // index this heading is for (0-based)
|
||||||
|
Motorized bool `json:"motorized"` // active rotor carries the motorized antenna → show pattern paths
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRotatorHeading queries PstRotator for the current azimuth. Returns
|
// activeRotor resolves the currently selected logical rotor. ok is false when no
|
||||||
// Enabled=false when the rotator isn't configured. Polled by the status bar.
|
// rotor is configured.
|
||||||
|
func (a *App) activeRotor() (lr logicalRotor, rotors []logicalRotor, idx int, ok bool) {
|
||||||
|
devs, err := a.GetRotators()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rotors = flattenRotors(devs)
|
||||||
|
if len(rotors) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idx = a.activeRotorIndex(len(rotors))
|
||||||
|
return rotors[idx], rotors, idx, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRotatorHeading queries the active rotor for its azimuth. Returns
|
||||||
|
// Enabled=false when no rotator is configured. Polled by the status bar.
|
||||||
func (a *App) GetRotatorHeading() RotatorHeading {
|
func (a *App) GetRotatorHeading() RotatorHeading {
|
||||||
s, err := a.GetRotatorSettings()
|
lr, rotors, idx, ok := a.activeRotor()
|
||||||
if err != nil || !s.Enabled {
|
if !ok {
|
||||||
return RotatorHeading{Enabled: false}
|
return RotatorHeading{Enabled: false}
|
||||||
}
|
}
|
||||||
if s.Type == "rotgenius" {
|
names := make([]string, len(rotors))
|
||||||
st, raw, herr := rotgenius.New(s.Host, s.Port).Heading(s.RotatorNum)
|
for i, r := range rotors {
|
||||||
|
names[i] = r.Name
|
||||||
|
}
|
||||||
|
base := RotatorHeading{Enabled: true, Rotors: names, Active: idx, Motorized: lr.Motorized}
|
||||||
|
link := lr.Link
|
||||||
|
switch link.Type {
|
||||||
|
case "rotgenius":
|
||||||
|
st, raw, herr := rotgenius.New(link.Host, link.Port).Heading(link.Num)
|
||||||
if herr != nil {
|
if herr != nil {
|
||||||
return RotatorHeading{Enabled: true, OK: false, Raw: herr.Error()}
|
base.Raw = herr.Error()
|
||||||
|
return base
|
||||||
}
|
}
|
||||||
if !st.Connected {
|
if !st.Connected {
|
||||||
return RotatorHeading{Enabled: true, OK: false, Raw: "sensor not connected (999)"}
|
base.Raw = "sensor not connected (999)"
|
||||||
|
return base
|
||||||
}
|
}
|
||||||
return RotatorHeading{Enabled: true, OK: true, Azimuth: st.Azimuth, Raw: raw}
|
base.OK = true
|
||||||
}
|
base.Azimuth = st.Azimuth
|
||||||
if s.Type == "arco" {
|
base.Raw = raw
|
||||||
az, raw, herr := arcoClient(s).Heading()
|
return base
|
||||||
|
case "arco":
|
||||||
|
az, raw, herr := arcoClient(link).Heading()
|
||||||
if herr != nil {
|
if herr != nil {
|
||||||
return RotatorHeading{Enabled: true, OK: false, Raw: herr.Error()}
|
base.Raw = herr.Error()
|
||||||
|
return base
|
||||||
}
|
}
|
||||||
return RotatorHeading{Enabled: true, OK: true, Azimuth: az, Raw: raw}
|
base.OK = true
|
||||||
}
|
base.Azimuth = az
|
||||||
az, raw, herr := pst.New(s.Host, s.Port).Heading()
|
base.Raw = raw
|
||||||
|
return base
|
||||||
|
default:
|
||||||
|
az, raw, herr := pst.New(link.Host, link.Port).Heading()
|
||||||
if herr != nil {
|
if herr != nil {
|
||||||
return RotatorHeading{Enabled: true, OK: false, Raw: raw}
|
base.Raw = raw
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
base.OK = true
|
||||||
|
base.Azimuth = az
|
||||||
|
base.Raw = raw
|
||||||
|
return base
|
||||||
}
|
}
|
||||||
return RotatorHeading{Enabled: true, OK: true, Azimuth: az, Raw: raw}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RotatorGoTo points the antenna at the given azimuth (and optional
|
// RotatorGoTo points the active rotor at the given azimuth (and optional
|
||||||
// elevation if the rotator is configured for it).
|
// elevation if it is configured for it).
|
||||||
func (a *App) RotatorGoTo(az int, el int) error {
|
func (a *App) RotatorGoTo(az int, el int) error {
|
||||||
s, err := a.GetRotatorSettings()
|
lr, _, _, ok := a.activeRotor()
|
||||||
if err != nil {
|
if !ok {
|
||||||
return err
|
return fmt.Errorf("no rotator configured")
|
||||||
}
|
}
|
||||||
if !s.Enabled {
|
link := lr.Link
|
||||||
return fmt.Errorf("rotator disabled in settings")
|
switch link.Type {
|
||||||
|
case "rotgenius":
|
||||||
|
return rotgenius.New(link.Host, link.Port).GoTo(link.Num, az)
|
||||||
|
case "arco":
|
||||||
|
return arcoClient(link).GoTo(az)
|
||||||
|
default:
|
||||||
|
return pst.New(link.Host, link.Port).GoTo(az, link.HasElevation, el)
|
||||||
}
|
}
|
||||||
if s.Type == "rotgenius" {
|
|
||||||
return rotgenius.New(s.Host, s.Port).GoTo(s.RotatorNum, az)
|
|
||||||
}
|
|
||||||
if s.Type == "arco" {
|
|
||||||
return arcoClient(s).GoTo(az)
|
|
||||||
}
|
|
||||||
return pst.New(s.Host, s.Port).GoTo(az, s.HasElevation, el)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RotatorStop interrupts any in-progress rotation, on either backend.
|
// RotatorStop interrupts any in-progress rotation of the active rotor.
|
||||||
func (a *App) RotatorStop() error {
|
func (a *App) RotatorStop() error {
|
||||||
s, err := a.GetRotatorSettings()
|
lr, _, _, ok := a.activeRotor()
|
||||||
if err != nil {
|
if !ok {
|
||||||
return err
|
return fmt.Errorf("no rotator configured")
|
||||||
}
|
}
|
||||||
if !s.Enabled {
|
link := lr.Link
|
||||||
return fmt.Errorf("rotator disabled in settings")
|
switch link.Type {
|
||||||
|
case "rotgenius":
|
||||||
|
return rotgenius.New(link.Host, link.Port).Stop()
|
||||||
|
case "arco":
|
||||||
|
return arcoClient(link).Stop()
|
||||||
|
default:
|
||||||
|
return pst.New(link.Host, link.Port).Stop()
|
||||||
}
|
}
|
||||||
if s.Type == "rotgenius" {
|
|
||||||
return rotgenius.New(s.Host, s.Port).Stop()
|
|
||||||
}
|
|
||||||
if s.Type == "arco" {
|
|
||||||
return arcoClient(s).Stop()
|
|
||||||
}
|
|
||||||
return pst.New(s.Host, s.Port).Stop()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RotatorPark moves the antenna to its parked position (configured in
|
// RotatorPark moves the active rotor to its parked position (PstRotator only;
|
||||||
// PstRotator itself). Park is a PstRotator concept — the 4O3A native protocol
|
// the 4O3A native and ARCO GS-232 protocols have no park command).
|
||||||
// has no park command.
|
|
||||||
func (a *App) RotatorPark() error {
|
func (a *App) RotatorPark() error {
|
||||||
s, err := a.GetRotatorSettings()
|
lr, _, _, ok := a.activeRotor()
|
||||||
if err != nil {
|
if !ok {
|
||||||
return err
|
return fmt.Errorf("no rotator configured")
|
||||||
}
|
}
|
||||||
if !s.Enabled {
|
link := lr.Link
|
||||||
return fmt.Errorf("rotator disabled in settings")
|
switch link.Type {
|
||||||
}
|
case "rotgenius":
|
||||||
if s.Type == "rotgenius" {
|
|
||||||
return fmt.Errorf("park is a PstRotator feature; not available on the Rotator Genius")
|
return fmt.Errorf("park is a PstRotator feature; not available on the Rotator Genius")
|
||||||
}
|
case "arco":
|
||||||
if s.Type == "arco" {
|
|
||||||
return fmt.Errorf("park is a PstRotator feature; not available over the ARCO GS-232 link")
|
return fmt.Errorf("park is a PstRotator feature; not available over the ARCO GS-232 link")
|
||||||
|
default:
|
||||||
|
return pst.New(link.Host, link.Port).Park()
|
||||||
}
|
}
|
||||||
return pst.New(s.Host, s.Port).Park()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRotator sends a no-op GoTo to the rotator's current heading to
|
// TestRotatorDevice verifies a rotor device's link (sub picks the Rotator Genius
|
||||||
// verify the UDP link without actually moving the antenna. We use 0° as
|
// port 1/2 for a Dual device). Takes the live panel device so it can be tested
|
||||||
// the test target — pick a known direction the user expects to see.
|
// before saving.
|
||||||
// Returns nil on success or a descriptive error.
|
func (a *App) TestRotatorDevice(d RotatorDevice, sub int) error {
|
||||||
func (a *App) TestRotator(s RotatorSettings) error {
|
return testRotorLink(deviceLink(d, sub))
|
||||||
if s.Host == "" {
|
}
|
||||||
s.Host = "127.0.0.1"
|
|
||||||
|
// testRotorLink confirms a rotor's link without meaningfully moving the antenna.
|
||||||
|
func testRotorLink(l rotorLink) error {
|
||||||
|
if l.Host == "" {
|
||||||
|
l.Host = "127.0.0.1"
|
||||||
}
|
}
|
||||||
if s.Type == "rotgenius" {
|
if l.Port <= 0 || l.Port > 65535 {
|
||||||
if s.Port <= 0 || s.Port > 65535 {
|
l.Port = rotatorDefaultPort(l.Type)
|
||||||
s.Port = 9006
|
|
||||||
}
|
}
|
||||||
rn := s.RotatorNum
|
switch l.Type {
|
||||||
|
case "rotgenius":
|
||||||
|
rn := l.Num
|
||||||
if rn != 2 {
|
if rn != 2 {
|
||||||
rn = 1
|
rn = 1
|
||||||
}
|
}
|
||||||
// Match the button ("Test — point to 0°"): actually command the move, like
|
// Match the button ("Test — point to 0°"): actually command the move, like
|
||||||
// the PstRotator path does. GoTo confirms the link AND the accept ('K')
|
// the PstRotator path does. GoTo confirms the link AND the accept ('K')
|
||||||
// reply, so a rejected command surfaces as an error instead of a false OK.
|
// reply, so a rejected command surfaces as an error instead of a false OK.
|
||||||
return rotgenius.New(s.Host, s.Port).GoTo(rn, 0)
|
return rotgenius.New(l.Host, l.Port).GoTo(rn, 0)
|
||||||
}
|
case "arco":
|
||||||
if s.Type == "arco" {
|
if l.Transport == "serial" && strings.TrimSpace(l.ComPort) == "" {
|
||||||
if s.Port <= 0 || s.Port > 65535 {
|
|
||||||
s.Port = 4001
|
|
||||||
}
|
|
||||||
if s.Transport == "serial" && strings.TrimSpace(s.ComPort) == "" {
|
|
||||||
return fmt.Errorf("select the ARCO's COM port first")
|
return fmt.Errorf("select the ARCO's COM port first")
|
||||||
}
|
}
|
||||||
// A heading query proves the link AND that the ARCO port really speaks
|
// A heading query proves the link AND that the ARCO port really speaks
|
||||||
// GS-232 — without moving the antenna.
|
// GS-232 — without moving the antenna.
|
||||||
_, _, err := arcoClient(s).Heading()
|
_, _, err := arcoClient(l).Heading()
|
||||||
return err
|
return err
|
||||||
|
default:
|
||||||
|
return pst.New(l.Host, l.Port).GoTo(0, false, -1)
|
||||||
}
|
}
|
||||||
if s.Port <= 0 || s.Port > 65535 {
|
|
||||||
s.Port = 12000
|
|
||||||
}
|
|
||||||
return pst.New(s.Host, s.Port).GoTo(0, false, -1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func boolStr(b bool) string {
|
func boolStr(b bool) string {
|
||||||
@@ -14557,6 +14845,7 @@ type AmpConfig struct {
|
|||||||
Transport string `json:"transport"` // "tcp" | "serial"
|
Transport string `json:"transport"` // "tcp" | "serial"
|
||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
|
Password string `json:"password"` // PowerGenius XL remote-access code (blank on LAN); sent as AUTH like the Tuner/Antenna Genius
|
||||||
ComPort string `json:"com_port"`
|
ComPort string `json:"com_port"`
|
||||||
Baud int `json:"baud"`
|
Baud int `json:"baud"`
|
||||||
|
|
||||||
@@ -14705,7 +14994,7 @@ func (a *App) startAmps() {
|
|||||||
inst := &Inst{cfg: c}
|
inst := &Inst{cfg: c}
|
||||||
switch {
|
switch {
|
||||||
case isPGXL:
|
case isPGXL:
|
||||||
inst.pgxl = powergenius.New(c.Host, c.Port)
|
inst.pgxl = powergenius.New(c.Host, c.Port, c.Password)
|
||||||
_ = inst.pgxl.Start()
|
_ = inst.pgxl.Start()
|
||||||
if a.pgxl == nil {
|
if a.pgxl == nil {
|
||||||
a.pgxl = inst.pgxl
|
a.pgxl = inst.pgxl
|
||||||
@@ -15884,3 +16173,32 @@ func (a *App) YaesuStopCW() error {
|
|||||||
}
|
}
|
||||||
return a.cat.YaesuDo(func(y cat.YaesuController) error { return y.StopCW() })
|
return a.cat.YaesuDo(func(y cat.YaesuController) error { return y.StopCW() })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// KenwoodSendCW keys a CW message through the Kenwood/Elecraft keyer (CAT "KY"),
|
||||||
|
// so a K3 keys CW over its single CAT link — no WinKeyer, no second COM port.
|
||||||
|
func (a *App) KenwoodSendCW(text string) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
err := a.cat.KenwoodDo(func(k cat.KenwoodController) error { return k.SendCW(text) })
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("kenwood cw: KenwoodSendCW(%q) failed: %v", text, err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// KenwoodStopCW aborts the message being sent (drops to receive).
|
||||||
|
func (a *App) KenwoodStopCW() error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.KenwoodDo(func(k cat.KenwoodController) error { return k.StopCW() })
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetKenwoodKeySpeed sets the K3/Kenwood keyer speed in WPM (CAT "KS").
|
||||||
|
func (a *App) SetKenwoodKeySpeed(wpm int) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.KenwoodDo(func(k cat.KenwoodController) error { return k.SetKeySpeed(wpm) })
|
||||||
|
}
|
||||||
|
|||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// deniedCallHashes lists station callsigns not permitted to run this build,
|
||||||
|
// stored as SHA-256 hex of the base call so the calls themselves appear nowhere
|
||||||
|
// in the source or the compiled binary. Enforcement is best-effort by design —
|
||||||
|
// the call is entered by the operator and can be changed — so this only turns
|
||||||
|
// away straightforward use, not a determined one.
|
||||||
|
var deniedCallHashes = map[string]struct{}{
|
||||||
|
"2282a88b3e5e4eebc6b8174d005bb46d32025758b7741bdcf34f2b3621c02205": {},
|
||||||
|
"0ee09fe60817a2a4982f5e5b14a60b8dbb11cff55b3d36661213c4cbdd0933ea": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// callDenied reports whether a callsign is on deniedCallHashes. The call is
|
||||||
|
// reduced to its base form (upper-cased, portable prefix/suffix dropped) the
|
||||||
|
// same way extsvc does, so F4XYZ/P matches F4XYZ.
|
||||||
|
func callDenied(call string) bool {
|
||||||
|
base := denyBaseCall(call)
|
||||||
|
if base == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256([]byte(base))
|
||||||
|
_, bad := deniedCallHashes[hex.EncodeToString(sum[:])]
|
||||||
|
return bad
|
||||||
|
}
|
||||||
|
|
||||||
|
// denyBaseCall mirrors extsvc.baseCall: for a slashed form it returns the
|
||||||
|
// longest token (the real call), otherwise the call itself, upper-cased.
|
||||||
|
func denyBaseCall(s string) string {
|
||||||
|
s = strings.ToUpper(strings.TrimSpace(s))
|
||||||
|
if !strings.Contains(s, "/") {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
best := ""
|
||||||
|
for _, part := range strings.Split(s, "/") {
|
||||||
|
if len(part) > len(best) {
|
||||||
|
best = part
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
// enforceCallGate exits the process silently when any of the supplied callsigns
|
||||||
|
// is denied. Called once at startup, after the profiles are loaded: the first
|
||||||
|
// launch is where the operator enters and saves the call, so a denied build
|
||||||
|
// simply does not come back up on the next launch. No window, no message.
|
||||||
|
func enforceCallGate(calls ...string) {
|
||||||
|
for _, c := range calls {
|
||||||
|
if callDenied(c) {
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,54 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.23.5",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"CAT sharing (Hamlib rigctld): fixed a slow frequency drift in FT8. With a digital app (JTDX/WSJT-X) sharing OpsLog's rig in \"Fake It\" split, each transmit crept the dial down and never came back. The app follows the dial by polling, and our reply lagged the last tune by a poll cycle, so right after a transmission it read the still-shifted frequency, took it for a manual QSY and adopted it. Reads now echo the last commanded frequency until the rig confirms it — the dial holds. Applies to every backend, not just Kenwood."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Partage CAT (rigctld Hamlib) : dérive lente de fréquence en FT8 corrigée. Avec une app numérique (JTDX/WSJT-X) partageant la radio d'OpsLog en split « Fake It », chaque passage en émission faisait descendre le VFO sans jamais revenir. L'app suit le VFO en l'interrogeant, et notre réponse était en retard d'un cycle sur la dernière syntonisation : juste après une émission elle lisait la fréquence encore décalée, la prenait pour un QSY manuel et l'adoptait. Les lectures renvoient désormais la dernière fréquence commandée jusqu'à confirmation de la radio — le VFO tient. Vaut pour tous les backends, pas seulement Kenwood."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.23.4",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"CW keyer (Kenwood/Elecraft): choosing the Kenwood/Elecraft engine now actually switches to it. A bug left the keyer on WinKeyer even though the setting showed Kenwood/Elecraft, so its CW-over-CAT never ran.",
|
||||||
|
"Kenwood/Elecraft data mode: a new setting (Settings → CAT) chooses what a data mode (FT8/PSK…) sets on the rig — USB (default), DATA mode (MD6, for an Elecraft K3/K4), or leave the rig's mode unchanged (safest for a TS-590SG/TS-990S, whose data mode is a USB modifier set on the radio). There is no single command that fits every rig, so it's now the operator's choice.",
|
||||||
|
"Ultrabeam over a remote link: changing the pattern (Normal / 180° / bidirectional) no longer snaps back to the old one after a few seconds. The commanded pattern is now held while the motors are still moving plus a grace window after they stop, so the slow remote status poll — which lagged behind the antenna — can't revert the display while the change is in flight.",
|
||||||
|
"QSL Manager: closing the window — or starting another download — now cancels the one still in progress, so a slow QRZ sync no longer keeps running in the background and bleeds its log into a freshly started LoTW run. The QRZ download summary also spells out that only confirmed QSOs are acted on (e.g. \"7950 of 26164 are confirmed\") — a full QRZ logbook has many not-yet-confirmed QSOs, so nothing is lost even though the old wording read as if only a fraction came back. (A LoTW \"http 503\" is separate: the ARRL server being down — retry later.)"
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Keyer CW (Kenwood/Elecraft) : choisir le moteur Kenwood/Elecraft y bascule désormais réellement. Un bug laissait le keyer sur WinKeyer alors que le réglage affichait Kenwood/Elecraft, son CW-sur-CAT ne démarrait donc jamais.",
|
||||||
|
"Mode data Kenwood/Elecraft : un nouveau réglage (Réglages → CAT) choisit ce qu'un mode data (FT8/PSK…) règle sur la radio — USB (défaut), mode DATA (MD6, pour un Elecraft K3/K4), ou ne pas changer le mode de la radio (le plus sûr pour un TS-590SG/TS-990S dont le mode data est un modificateur d'USB réglé sur la radio). Aucune commande unique ne convient à toutes les radios, c'est donc désormais au choix de l'opérateur.",
|
||||||
|
"Ultrabeam en remote : changer le diagramme (Normal / 180° / bidirectionnel) ne revient plus à l'ancien au bout de quelques secondes. Le diagramme commandé est maintenu tant que les moteurs bougent, plus une fenêtre de grâce après leur arrêt — le poll de statut distant, lent et en retard sur l'antenne, ne peut donc plus faire revenir l'affichage pendant que le changement est en cours.",
|
||||||
|
"QSL Manager : fermer la fenêtre — ou lancer un autre téléchargement — annule désormais celui en cours, une synchro QRZ lente ne continue donc plus en arrière-plan à mélanger son journal à un téléchargement LoTW fraîchement lancé. Le récapitulatif QRZ indique aussi clairement que seuls les QSO confirmés sont traités (ex. « 7950 sur 26164 confirmés ») — un logbook QRZ complet contient beaucoup de QSO pas encore confirmés, rien n'est donc perdu même si l'ancien texte donnait l'impression qu'une fraction seulement était revenue. (Un « http 503 » sur LoTW est un autre sujet : serveur de l'ARRL indisponible — réessayez plus tard.)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.23.3",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"Multiple rotors: Settings → Rotator is now a list, like the amplifiers — add rotors with “Add rotor”, remove with the trash icon. Mix any types (an ARCO and a Rotator Genius); a Rotator Genius set to “drives two rotors” adds a second one. The compass has a selector to pick the active rotor, which it turns, displays and points to the DX; each rotor is named, and you mark which one carries the motorized antenna (Ultrabeam/SteppIR) so the boom and reverse/bidirectional pattern paths show only for it.",
|
||||||
|
"CAT PTT hotkey: pick a key in Settings → CAT and it keys the transmitter over CAT while OpsLog is focused — hold-to-talk or toggle (a rig with no CAT link falls back to the Audio-tab RTS/DTR method). Keys that need AltGr on your layout (e.g. \"\\\" on AZERTY) work, and each press is written to the diagnostic log.",
|
||||||
|
"TCI spots (ExpertSDR / SunSDR): OpsLog's cluster spots now show on the panorama — the colour is sent as the decimal integer the protocol expects (a hex string was silently dropped), with a valid modulation — and clicking one fills the entry (callsign + frequency) via the CLICKED_ON_SPOT message.",
|
||||||
|
"FlexRadio spot click: clicking one of OpsLog's spots on the SmartSDR panadapter now switches the mode too, not just the frequency.",
|
||||||
|
"PowerGenius XL: the widget and the FlexRadio amplifier card now read forward power, drain current and temperature from the amp's own link (polled every 250 ms with peak-hold), so the meters keep working — and stay steady — over a remote link where the radio's VITA stream never reaches OpsLog. Adds a remote-access password (Settings → Amplifier) to reach the amp from outside the LAN, and fixes the fan mode (Standard/Contest/Broadcast), which was reverting to Contest.",
|
||||||
|
"CW macro <LOGQSO>: a macro ending in the token (e.g. \"BK UR <STX> TU <LOGQSO>\") now logs the QSO once the CW before it has been sent, not the instant the macro starts — it was logging on the first keyed letter.",
|
||||||
|
"Filter presets: the trash icon next to a saved preset deletes it again — clicking it was loading the preset instead of removing it.",
|
||||||
|
"Kenwood/Elecraft (K3/K4): data modes (FT8/PSK/JT) now go out on USB on every band — below 10 MHz they were landing on LSB, which a K3 shows as \"DATA REV\". The frequency refreshes promptly again (the split double-check no longer runs on every poll). And a new CW keyer engine (Settings → CW Keyer → Kenwood/Elecraft) sends CW over the CAT link with the KY command — no WinKeyer and no second COM port — for a rig whose single USB port is the CAT port. Needs on-air testing on a K3."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Plusieurs rotors : Réglages → Rotator est désormais une liste, comme les amplis — ajoutez des rotors avec « Ajouter un rotor », supprimez avec la corbeille. Mélangez les types (un ARCO et un Rotator Genius) ; un Rotator Genius réglé sur « pilote deux rotors » en ajoute un second. Le compas a un sélecteur pour choisir le rotor actif, qu'il tourne, affiche et pointe vers le DX ; chaque rotor est nommé, et vous indiquez lequel porte l'antenne motorisée (Ultrabeam/SteppIR) pour que les tracés de boom et de diagramme inverse/bidirectionnel n'apparaissent que pour lui.",
|
||||||
|
"Touche PTT CAT : choisissez une touche dans Réglages → CAT et elle passe la radio en émission via CAT quand OpsLog a le focus — maintien ou bascule (une radio sans liaison CAT retombe sur la méthode RTS/DTR de l'onglet Audio). Les touches nécessitant AltGr (ex. « \\ » en AZERTY) fonctionnent, et chaque appui est tracé dans le journal de diagnostic.",
|
||||||
|
"Spots TCI (ExpertSDR / SunSDR) : les spots cluster d'OpsLog apparaissent sur le panorama — la couleur est envoyée en entier décimal attendu par le protocole (une chaîne hexadécimale était silencieusement rejetée), avec une modulation valide — et cliquer un spot remplit la saisie (indicatif + fréquence) via le message CLICKED_ON_SPOT.",
|
||||||
|
"Clic sur un spot FlexRadio : cliquer un spot d'OpsLog sur le panadapter SmartSDR change désormais aussi le mode, plus seulement la fréquence.",
|
||||||
|
"PowerGenius XL : le widget et la carte ampli du panneau FlexRadio lisent maintenant puissance directe, courant de drain et température depuis la liaison propre de l'ampli (interrogée toutes les 250 ms avec maintien de crête) — les meters continuent donc de fonctionner, et restent stables, sur une liaison distante où le flux VITA de la radio n'atteint jamais OpsLog. Ajoute un mot de passe d'accès distant (Réglages → Amplificateur) pour joindre l'ampli hors du réseau local, et corrige le mode ventilateur (Standard/Contest/Broadcast) qui repassait sur Contest.",
|
||||||
|
"Macro CW <LOGQSO> : une macro se terminant par le token (ex. « BK UR <STX> TU <LOGQSO> ») journalise le QSO une fois le CW qui précède envoyé, et non à l'instant où la macro démarre — elle journalisait dès la première lettre manipulée.",
|
||||||
|
"Presets de filtre : la corbeille à côté d'un preset enregistré le supprime de nouveau — le clic chargeait le preset au lieu de le retirer.",
|
||||||
|
"Kenwood/Elecraft (K3/K4) : les modes data (FT8/PSK/JT) partent désormais en USB sur toutes les bandes — sous 10 MHz ils passaient en LSB, ce qu'un K3 affiche comme « DATA REV ». La fréquence se rafraîchit de nouveau rapidement (la double-vérification du split ne se fait plus à chaque cycle). Et un nouveau moteur de keyer (Réglages → Keyer CW → Kenwood/Elecraft) envoie le CW sur la liaison CAT avec la commande KY — sans WinKeyer ni second port COM — pour une radio dont l'unique port USB est le port CAT. À tester sur l'air avec un K3."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.23.2",
|
"version": "0.23.2",
|
||||||
"date": "",
|
"date": "",
|
||||||
|
|||||||
+97
-20
@@ -20,7 +20,7 @@ import {
|
|||||||
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna, FlexApplyBandPower,
|
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna, FlexApplyBandPower,
|
||||||
GetSecretStatus, UnlockSecrets,
|
GetSecretStatus, UnlockSecrets,
|
||||||
RefreshCtyDat, DownloadAllReferenceLists,
|
RefreshCtyDat, DownloadAllReferenceLists,
|
||||||
RotatorGoTo, RotatorStop, GetRotatorHeading,
|
RotatorGoTo, RotatorStop, GetRotatorHeading, SetActiveRotor,
|
||||||
GetDBConnectionInfo, GetLogbookRevision,
|
GetDBConnectionInfo, GetLogbookRevision,
|
||||||
GetUltrabeamStatus, SetUltrabeamDirection, UILog,
|
GetUltrabeamStatus, SetUltrabeamDirection, UILog,
|
||||||
GetAntGeniusStatus, GetAntGeniusSettings, AntGeniusActivate,
|
GetAntGeniusStatus, GetAntGeniusSettings, AntGeniusActivate,
|
||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
OpenExternalURL,
|
OpenExternalURL,
|
||||||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
|
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
|
||||||
ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
|
ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
|
||||||
GetCATSettings,
|
GetCATSettings, PTTHotkeyDown, PTTHotkeyUp,
|
||||||
GetSolarData,
|
GetSolarData,
|
||||||
GetQSORate,
|
GetQSORate,
|
||||||
LoTWUserInfo,
|
LoTWUserInfo,
|
||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus,
|
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus,
|
||||||
WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace,
|
WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace,
|
||||||
IcomSendCW, YaesuSendCW, YaesuStopCW, SetYaesuKeySpeed, IcomStopCW, IcomSetKeySpeed, IcomSetBreakIn, GetIcomState,
|
IcomSendCW, YaesuSendCW, YaesuStopCW, SetYaesuKeySpeed, IcomStopCW, IcomSetKeySpeed, IcomSetBreakIn, GetIcomState,
|
||||||
|
KenwoodSendCW, KenwoodStopCW, SetKenwoodKeySpeed,
|
||||||
FlexSendCW, FlexStopCW, FlexSetKeySpeed, FlexBackspaceCW,
|
FlexSendCW, FlexStopCW, FlexSetKeySpeed, FlexBackspaceCW,
|
||||||
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
|
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
|
||||||
StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
|
StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
|
||||||
@@ -628,6 +629,11 @@ export default function App() {
|
|||||||
// can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default
|
// can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default
|
||||||
// in Preferences > Hardware > CAT interface.
|
// in Preferences > Hardware > CAT interface.
|
||||||
const digitalDefaultRef = useRef<string>('FT8');
|
const digitalDefaultRef = useRef<string>('FT8');
|
||||||
|
// PTT hotkey config, refreshed by loadCATCfg. Held in a ref so the global
|
||||||
|
// key listener (registered once) always sees the current binding.
|
||||||
|
const pttHotkeyRef = useRef<{ enabled: boolean; code: string; toggle: boolean }>({ enabled: false, code: '', toggle: false });
|
||||||
|
const pttHeldRef = useRef(false); // hold-mode: PTT currently keyed by the hotkey
|
||||||
|
const pttToggledRef = useRef(false); // toggle-mode: latched TX state
|
||||||
// Don't override freq/band/mode the user JUST typed — track a small grace
|
// Don't override freq/band/mode the user JUST typed — track a small grace
|
||||||
// window after manual edits and skip CAT updates during it.
|
// window after manual edits and skip CAT updates during it.
|
||||||
const catFreezeUntilRef = useRef<number>(0);
|
const catFreezeUntilRef = useRef<number>(0);
|
||||||
@@ -1075,7 +1081,7 @@ export default function App() {
|
|||||||
// CI-V 0x17 (no extra hardware — sends over the CAT connection). Macros,
|
// CI-V 0x17 (no extra hardware — sends over the CAT connection). Macros,
|
||||||
// auto-call and <LOGQSO> are shared; only the transport differs.
|
// auto-call and <LOGQSO> are shared; only the transport differs.
|
||||||
const [wkEngine, setWkEngine] = useState<string>('winkeyer');
|
const [wkEngine, setWkEngine] = useState<string>('winkeyer');
|
||||||
const cwSource: 'winkeyer' | 'icom' | 'flex' | 'yaesu' = wkEngine === 'icom' ? 'icom' : wkEngine === 'flex' ? 'flex' : wkEngine === 'yaesu' ? 'yaesu' : 'winkeyer';
|
const cwSource: 'winkeyer' | 'icom' | 'flex' | 'yaesu' | 'kenwood' = wkEngine === 'icom' ? 'icom' : wkEngine === 'flex' ? 'flex' : wkEngine === 'yaesu' ? 'yaesu' : wkEngine === 'kenwood' ? 'kenwood' : 'winkeyer';
|
||||||
// Setting the CW speed has to reach the keyer that is ACTUALLY sending, and
|
// Setting the CW speed has to reach the keyer that is ACTUALLY sending, and
|
||||||
// both the CW panel and the Yaesu console can ask for it. With DTR/RTS line
|
// both the CW panel and the Yaesu console can ask for it. With DTR/RTS line
|
||||||
// keying the PC does the timing, so the rig's internal keyer speed changes
|
// keying the PC does the timing, so the rig's internal keyer speed changes
|
||||||
@@ -1088,6 +1094,7 @@ export default function App() {
|
|||||||
if (src === 'icom') IcomSetKeySpeed(w).catch(() => {});
|
if (src === 'icom') IcomSetKeySpeed(w).catch(() => {});
|
||||||
else if (src === 'flex') FlexSetKeySpeed(w).catch(() => {});
|
else if (src === 'flex') FlexSetKeySpeed(w).catch(() => {});
|
||||||
else if (src === 'yaesu') SetYaesuKeySpeed(w).catch(() => {});
|
else if (src === 'yaesu') SetYaesuKeySpeed(w).catch(() => {});
|
||||||
|
else if (src === 'kenwood') SetKenwoodKeySpeed(w).catch(() => {});
|
||||||
else WinkeyerSetSpeed(w).catch(() => {});
|
else WinkeyerSetSpeed(w).catch(() => {});
|
||||||
// The rig's own keyer follows too whenever a Yaesu is on CAT, even when it is
|
// The rig's own keyer follows too whenever a Yaesu is on CAT, even when it is
|
||||||
// not the sending engine: its front panel and OpsLog then agree.
|
// not the sending engine: its front panel and OpsLog then agree.
|
||||||
@@ -1131,6 +1138,7 @@ export default function App() {
|
|||||||
const connected = cwSource === 'icom' ? (catState.backend === 'icom' && catState.connected)
|
const connected = cwSource === 'icom' ? (catState.backend === 'icom' && catState.connected)
|
||||||
: cwSource === 'flex' ? (catState.backend === 'flex' && catState.connected)
|
: cwSource === 'flex' ? (catState.backend === 'flex' && catState.connected)
|
||||||
: cwSource === 'yaesu' ? (catState.backend === 'yaesu' && catState.connected)
|
: cwSource === 'yaesu' ? (catState.backend === 'yaesu' && catState.connected)
|
||||||
|
: cwSource === 'kenwood' ? (catState.backend === 'kenwood' && catState.connected)
|
||||||
: wkStatus.connected;
|
: wkStatus.connected;
|
||||||
wkActiveRef.current = wkEnabled && connected;
|
wkActiveRef.current = wkEnabled && connected;
|
||||||
}, [wkEnabled, wkStatus.connected, cwSource, catState.backend, catState.connected]);
|
}, [wkEnabled, wkStatus.connected, cwSource, catState.backend, catState.connected]);
|
||||||
@@ -1764,23 +1772,28 @@ export default function App() {
|
|||||||
: null
|
: null
|
||||||
), [rotatorHeading.enabled, rotatorHeading.ok, rotatorHeading.azimuth]);
|
), [rotatorHeading.enabled, rotatorHeading.ok, rotatorHeading.azimuth]);
|
||||||
|
|
||||||
|
// Only the active rotor carrying the motorized antenna (Ultrabeam/SteppIR)
|
||||||
|
// shows the pattern paths; a plain rotor shows just its heading. The backend
|
||||||
|
// reports Motorized for the active rotor (true for a lone/standard rotor).
|
||||||
|
const activeRotorMotorized = (rotatorHeading as any).motorized !== false;
|
||||||
|
|
||||||
const beamHeadings = useMemo<number[]>(() => {
|
const beamHeadings = useMemo<number[]>(() => {
|
||||||
if (rotorAz == null) return [];
|
if (rotorAz == null) return [];
|
||||||
if (ubStatus.enabled && ubStatus.connected) {
|
if (activeRotorMotorized && ubStatus.enabled && ubStatus.connected) {
|
||||||
if (ubStatus.direction === 1) return [(rotorAz + 180) % 360];
|
if (ubStatus.direction === 1) return [(rotorAz + 180) % 360];
|
||||||
if (ubStatus.direction === 2) return [rotorAz, (rotorAz + 180) % 360];
|
if (ubStatus.direction === 2) return [rotorAz, (rotorAz + 180) % 360];
|
||||||
}
|
}
|
||||||
return [rotorAz];
|
return [rotorAz];
|
||||||
}, [rotorAz, ubStatus.enabled, ubStatus.connected, ubStatus.direction]);
|
}, [rotorAz, activeRotorMotorized, ubStatus.enabled, ubStatus.connected, ubStatus.direction]);
|
||||||
|
|
||||||
// Mechanical boom (rotor) heading + Ultrabeam pattern — so the compass/map can
|
// Mechanical boom (rotor) heading + Ultrabeam pattern — so the compass/map can
|
||||||
// show where the antenna physically points (boom) vs where it radiates when
|
// show where the antenna physically points (boom) vs where it radiates when
|
||||||
// the Ultrabeam is reversed/bidirectional.
|
// the Ultrabeam is reversed/bidirectional.
|
||||||
const boomHeading = rotorAz;
|
const boomHeading = rotorAz;
|
||||||
const ubPattern = useMemo<'normal' | 'reverse' | 'bi' | null>(() => {
|
const ubPattern = useMemo<'normal' | 'reverse' | 'bi' | null>(() => {
|
||||||
if (!(ubStatus.enabled && ubStatus.connected)) return null;
|
if (!activeRotorMotorized || !(ubStatus.enabled && ubStatus.connected)) return null;
|
||||||
return ubStatus.direction === 1 ? 'reverse' : ubStatus.direction === 2 ? 'bi' : 'normal';
|
return ubStatus.direction === 1 ? 'reverse' : ubStatus.direction === 2 ? 'bi' : 'normal';
|
||||||
}, [ubStatus.enabled, ubStatus.connected, ubStatus.direction]);
|
}, [activeRotorMotorized, ubStatus.enabled, ubStatus.connected, ubStatus.direction]);
|
||||||
|
|
||||||
// Portable UI toggles (mirrored to the DB via writeUiPref / syncPortablePrefs).
|
// Portable UI toggles (mirrored to the DB via writeUiPref / syncPortablePrefs).
|
||||||
const [showRotor, setShowRotor] = useState(() => localStorage.getItem('opslog.showRotor') !== '0');
|
const [showRotor, setShowRotor] = useState(() => localStorage.getItem('opslog.showRotor') !== '0');
|
||||||
@@ -1927,6 +1940,56 @@ export default function App() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [showToast]);
|
}, [showToast]);
|
||||||
|
|
||||||
|
// PTT hotkey: a keyboard key keys the rig while OpsLog is focused. Registered
|
||||||
|
// once; reads the live binding from pttHotkeyRef (kept current by loadCATCfg).
|
||||||
|
// Hold-to-talk by default; toggle mode latches. We swallow the key (so the
|
||||||
|
// dedicated PTT key doesn't type a character) and ignore auto-repeat.
|
||||||
|
useEffect(() => {
|
||||||
|
const down = (e: KeyboardEvent) => {
|
||||||
|
const cfg = pttHotkeyRef.current;
|
||||||
|
if (!cfg.enabled || !cfg.code || e.code !== cfg.code) return;
|
||||||
|
// Allow AltGr (Ctrl+Alt together) — French/other layouts need it to reach
|
||||||
|
// keys like "\" — but never hijack a real Ctrl- or Win-shortcut.
|
||||||
|
if (e.metaKey || (e.ctrlKey && !e.altKey)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.repeat) return;
|
||||||
|
// Surface the result: this feature was impossible to diagnose because every
|
||||||
|
// error was swallowed. The line names the key, the action and any failure.
|
||||||
|
const fail = (err: any) => { UILog(`ptt hotkey: ${e.code} FAILED: ${err}`).catch(() => {}); };
|
||||||
|
if (cfg.toggle) {
|
||||||
|
pttToggledRef.current = !pttToggledRef.current;
|
||||||
|
const on = pttToggledRef.current;
|
||||||
|
UILog(`ptt hotkey: ${e.code} toggle → ${on ? 'KEY' : 'UNKEY'}`).catch(() => {});
|
||||||
|
(on ? PTTHotkeyDown() : PTTHotkeyUp())?.catch?.(fail);
|
||||||
|
} else if (!pttHeldRef.current) {
|
||||||
|
pttHeldRef.current = true;
|
||||||
|
UILog(`ptt hotkey: ${e.code} down → KEY`).catch(() => {});
|
||||||
|
PTTHotkeyDown().catch(fail);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const up = (e: KeyboardEvent) => {
|
||||||
|
const cfg = pttHotkeyRef.current;
|
||||||
|
if (!cfg.enabled || !cfg.code || e.code !== cfg.code) return;
|
||||||
|
if (cfg.toggle) return; // toggle unkeys on the next press, not on release
|
||||||
|
if (pttHeldRef.current) {
|
||||||
|
pttHeldRef.current = false;
|
||||||
|
PTTHotkeyUp();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Losing focus mid-hold would never deliver keyup — release so TX can't stick.
|
||||||
|
const blur = () => {
|
||||||
|
if (pttHeldRef.current) { pttHeldRef.current = false; PTTHotkeyUp(); }
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', down, true);
|
||||||
|
window.addEventListener('keyup', up, true);
|
||||||
|
window.addEventListener('blur', blur);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', down, true);
|
||||||
|
window.removeEventListener('keyup', up, true);
|
||||||
|
window.removeEventListener('blur', blur);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Poll PstRotator for the live antenna heading (status bar). Cheap when the
|
// Poll PstRotator for the live antenna heading (status bar). Cheap when the
|
||||||
// rotator is disabled (the backend just reads settings and returns).
|
// rotator is disabled (the backend just reads settings and returns).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -2110,6 +2173,11 @@ export default function App() {
|
|||||||
try {
|
try {
|
||||||
const c = await GetCATSettings();
|
const c = await GetCATSettings();
|
||||||
if (c.digital_default) digitalDefaultRef.current = c.digital_default;
|
if (c.digital_default) digitalDefaultRef.current = c.digital_default;
|
||||||
|
pttHotkeyRef.current = {
|
||||||
|
enabled: !!(c as any).ptt_hotkey_enabled,
|
||||||
|
code: (c as any).ptt_hotkey ?? '',
|
||||||
|
toggle: !!(c as any).ptt_hotkey_toggle,
|
||||||
|
};
|
||||||
setCatBackend(c.backend ?? '');
|
setCatBackend(c.backend ?? '');
|
||||||
} catch {}
|
} catch {}
|
||||||
}, []);
|
}, []);
|
||||||
@@ -2571,6 +2639,11 @@ export default function App() {
|
|||||||
const call = String(p?.call ?? '');
|
const call = String(p?.call ?? '');
|
||||||
if (applyUdpCall(call, true)) restartRecordingForNewTarget(call);
|
if (applyUdpCall(call, true)) restartRecordingForNewTarget(call);
|
||||||
});
|
});
|
||||||
|
// Clicking a spot on the ExpertSDR (TCI) panorama fills the call, like Flex.
|
||||||
|
const unsubTciSpot = EventsOn('tci:spot_clicked', (p: any) => {
|
||||||
|
const call = String(p?.call ?? '');
|
||||||
|
if (applyUdpCall(call, true)) restartRecordingForNewTarget(call);
|
||||||
|
});
|
||||||
const unsubBulk = EventsOn('bulkupdate:progress', (p: any) => {
|
const unsubBulk = EventsOn('bulkupdate:progress', (p: any) => {
|
||||||
const total = Number(p?.total ?? 0);
|
const total = Number(p?.total ?? 0);
|
||||||
const processed = Number(p?.processed ?? 0);
|
const processed = Number(p?.processed ?? 0);
|
||||||
@@ -2605,7 +2678,7 @@ export default function App() {
|
|||||||
const file = String(p?.file ?? '').replace(/^.*[\\/]/, '');
|
const file = String(p?.file ?? '').replace(/^.*[\\/]/, '');
|
||||||
showToast(file ? t('adifmon.toastFrom', { n, file }) : t('adifmon.toast', { n }));
|
showToast(file ? t('adifmon.toastFrom', { n, file }) : t('adifmon.toast', { n }));
|
||||||
});
|
});
|
||||||
return () => { unsubDX?.(); unsubRC?.(); unsubClear?.(); unsubFlexSpot?.(); unsubProg?.(); unsubBulk?.(); unsubLog?.(); unsubAdifMon?.(); };
|
return () => { unsubDX?.(); unsubRC?.(); unsubClear?.(); unsubFlexSpot?.(); unsubTciSpot?.(); unsubProg?.(); unsubBulk?.(); unsubLog?.(); unsubAdifMon?.(); };
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -2633,6 +2706,7 @@ export default function App() {
|
|||||||
s.engine === 'icom' ? 'icom'
|
s.engine === 'icom' ? 'icom'
|
||||||
: s.engine === 'flex' ? 'flex'
|
: s.engine === 'flex' ? 'flex'
|
||||||
: s.engine === 'yaesu' ? 'yaesu'
|
: s.engine === 'yaesu' ? 'yaesu'
|
||||||
|
: s.engine === 'kenwood' ? 'kenwood'
|
||||||
: s.engine === 'serial' ? 'serial'
|
: s.engine === 'serial' ? 'serial'
|
||||||
: 'winkeyer');
|
: 'winkeyer');
|
||||||
} catch { /* keyer not configured */ }
|
} catch { /* keyer not configured */ }
|
||||||
@@ -2760,7 +2834,7 @@ export default function App() {
|
|||||||
// segment AFTER the <LOGQSO> (which logs and clears the form) still expands its
|
// segment AFTER the <LOGQSO> (which logs and clears the form) still expands its
|
||||||
// variables correctly.
|
// variables correctly.
|
||||||
const parts = rawText.split(/<LOGQSO>/i).map((pt) => resolveCW(pt));
|
const parts = rawText.split(/<LOGQSO>/i).map((pt) => resolveCW(pt));
|
||||||
const isRig = cwSourceRef.current === 'icom' || cwSourceRef.current === 'flex' || cwSourceRef.current === 'yaesu';
|
const isRig = cwSourceRef.current === 'icom' || cwSourceRef.current === 'flex' || cwSourceRef.current === 'yaesu' || cwSourceRef.current === 'kenwood';
|
||||||
for (let p = 0; p < parts.length; p++) {
|
for (let p = 0; p < parts.length; p++) {
|
||||||
if (aborted()) return; // ESC / Stop before this segment → stop sending, don't log
|
if (aborted()) return; // ESC / Stop before this segment → stop sending, don't log
|
||||||
const resolved = parts[p];
|
const resolved = parts[p];
|
||||||
@@ -2772,15 +2846,14 @@ export default function App() {
|
|||||||
// current WPM, so it scales automatically.
|
// current WPM, so it scales automatically.
|
||||||
const keyed = resolved + ' ';
|
const keyed = resolved + ' ';
|
||||||
setWkSent(resolved);
|
setWkSent(resolved);
|
||||||
const sendFn = cwSourceRef.current === 'flex' ? FlexSendCW : cwSourceRef.current === 'icom' ? IcomSendCW : cwSourceRef.current === 'yaesu' ? YaesuSendCW : null;
|
const sendFn = cwSourceRef.current === 'flex' ? FlexSendCW : cwSourceRef.current === 'icom' ? IcomSendCW : cwSourceRef.current === 'yaesu' ? YaesuSendCW : cwSourceRef.current === 'kenwood' ? KenwoodSendCW : null;
|
||||||
if (sendFn) await sendFn(keyed).catch((e) => setError(String(e?.message ?? e)));
|
if (sendFn) await sendFn(keyed).catch((e) => setError(String(e?.message ?? e)));
|
||||||
else await WinkeyerSend(keyed).catch((e) => setError(String(e?.message ?? e)));
|
else await WinkeyerSend(keyed).catch((e) => setError(String(e?.message ?? e)));
|
||||||
// Only WAIT for the CW to finish on the FINAL segment — auto-call needs the
|
// WAIT for THIS segment's CW to finish before moving on — so a <LOGQSO>
|
||||||
// send done before its gap+resend. A segment a <LOGQSO> follows is NOT waited
|
// that follows logs AFTER the text before it (e.g. "TU") has actually been
|
||||||
// on: the keyer has already buffered the CW, so logging happens AT ONCE and
|
// sent, not the instant it's buffered into the keyer. Buffering then logging
|
||||||
// never interrupts what's being sent. (Gating the log on a CW-length ESTIMATE
|
// "at once" made a macro ending in "TU <LOGQSO>" log on the very first
|
||||||
// made <LOGQSO> log up to ~10s late when the estimate ran long.)
|
// letter. Auto-call also needs the send finished before its gap+resend.
|
||||||
if (isLast) {
|
|
||||||
if (isRig) {
|
if (isRig) {
|
||||||
await waitMs(Math.round(estimateCwMs(resolved, wkWpm)) + 600);
|
await waitMs(Math.round(estimateCwMs(resolved, wkWpm)) + 600);
|
||||||
} else {
|
} else {
|
||||||
@@ -2802,9 +2875,8 @@ export default function App() {
|
|||||||
while (wkBusyRef.current && !aborted() && Date.now() < deadline) await sleep(50);
|
while (wkBusyRef.current && !aborted() && Date.now() < deadline) await sleep(50);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (aborted()) return; // aborted while this segment was sending → don't log
|
if (aborted()) return; // aborted while this segment was sending → don't log
|
||||||
if (logAfter) void save(); // log NOW — the buffered CW keeps sending
|
if (logAfter) void save(); // the preceding CW has finished → log at the token's spot
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// stopAutoCall cancels any running auto-call loop.
|
// stopAutoCall cancels any running auto-call loop.
|
||||||
@@ -2816,6 +2888,7 @@ export default function App() {
|
|||||||
if (cwSourceRef.current === 'icom') IcomStopCW().catch(() => {});
|
if (cwSourceRef.current === 'icom') IcomStopCW().catch(() => {});
|
||||||
else if (cwSourceRef.current === 'flex') FlexStopCW().catch(() => {});
|
else if (cwSourceRef.current === 'flex') FlexStopCW().catch(() => {});
|
||||||
else if (cwSourceRef.current === 'yaesu') YaesuStopCW().catch(() => {});
|
else if (cwSourceRef.current === 'yaesu') YaesuStopCW().catch(() => {});
|
||||||
|
else if (cwSourceRef.current === 'kenwood') KenwoodStopCW().catch(() => {});
|
||||||
else WinkeyerStop().catch(() => {});
|
else WinkeyerStop().catch(() => {});
|
||||||
}
|
}
|
||||||
// runAutoCall sends macro i, waits for the keyer to finish, waits the chosen
|
// runAutoCall sends macro i, waits for the keyer to finish, waits the chosen
|
||||||
@@ -3743,6 +3816,7 @@ export default function App() {
|
|||||||
if (cwSourceRef.current === 'icom') IcomStopCW().catch(() => {});
|
if (cwSourceRef.current === 'icom') IcomStopCW().catch(() => {});
|
||||||
else if (cwSourceRef.current === 'yaesu') YaesuStopCW().catch(() => {});
|
else if (cwSourceRef.current === 'yaesu') YaesuStopCW().catch(() => {});
|
||||||
else if (cwSourceRef.current === 'flex') FlexStopCW().catch(() => {});
|
else if (cwSourceRef.current === 'flex') FlexStopCW().catch(() => {});
|
||||||
|
else if (cwSourceRef.current === 'kenwood') KenwoodStopCW().catch(() => {});
|
||||||
else WinkeyerStop().catch(() => {});
|
else WinkeyerStop().catch(() => {});
|
||||||
}
|
}
|
||||||
if (!keyerLive || wkEscClearsRef.current) {
|
if (!keyerLive || wkEscClearsRef.current) {
|
||||||
@@ -5552,6 +5626,9 @@ export default function App() {
|
|||||||
centerLat={gridToLatLon(station.my_grid)?.lat ?? null}
|
centerLat={gridToLatLon(station.my_grid)?.lat ?? null}
|
||||||
centerLon={gridToLatLon(station.my_grid)?.lon ?? null}
|
centerLon={gridToLatLon(station.my_grid)?.lon ?? null}
|
||||||
rotorEnabled={rotatorHeading.enabled && rotatorHeading.ok}
|
rotorEnabled={rotatorHeading.enabled && rotatorHeading.ok}
|
||||||
|
rotors={(rotatorHeading as any).rotors}
|
||||||
|
activeRotor={(rotatorHeading as any).active}
|
||||||
|
onSelectRotor={(i) => { SetActiveRotor(i).then(() => GetRotatorHeading()).then((h: any) => setRotatorHeading(h)).catch((err) => setError(String(err?.message ?? err))); }}
|
||||||
onGoto={(az) => RotatorGoTo(Math.round(az), -1).catch((err) => setError(String(err?.message ?? err)))}
|
onGoto={(az) => RotatorGoTo(Math.round(az), -1).catch((err) => setError(String(err?.message ?? err)))}
|
||||||
onClose={() => { setShowRotor(false); writeUiPref('opslog.showRotor', '0'); }}
|
onClose={() => { setShowRotor(false); writeUiPref('opslog.showRotor', '0'); }}
|
||||||
/>
|
/>
|
||||||
@@ -5626,11 +5703,11 @@ export default function App() {
|
|||||||
// exactly when its CAT backend is. Yaesu was missing from this
|
// exactly when its CAT backend is. Yaesu was missing from this
|
||||||
// list, so the panel fell back to the WinKeyer status — which
|
// list, so the panel fell back to the WinKeyer status — which
|
||||||
// reported disconnected, no WinKeyer being attached.
|
// reported disconnected, no WinKeyer being attached.
|
||||||
status={cwSource === 'icom' || cwSource === 'flex' || cwSource === 'yaesu'
|
status={cwSource === 'icom' || cwSource === 'flex' || cwSource === 'yaesu' || cwSource === 'kenwood'
|
||||||
? {
|
? {
|
||||||
connected: catState.backend === cwSource && catState.connected,
|
connected: catState.backend === cwSource && catState.connected,
|
||||||
busy: false, wpm: wkWpm, version: 0,
|
busy: false, wpm: wkWpm, version: 0,
|
||||||
port: cwSource === 'flex' ? 'CWX' : cwSource === 'yaesu' ? 'CAT' : 'CI-V',
|
port: cwSource === 'flex' ? 'CWX' : cwSource === 'yaesu' || cwSource === 'kenwood' ? 'CAT' : 'CI-V',
|
||||||
}
|
}
|
||||||
: wkStatus}
|
: wkStatus}
|
||||||
ports={wkPorts}
|
ports={wkPorts}
|
||||||
|
|||||||
@@ -189,13 +189,20 @@ function AmpBlock({ amp, flex, showName, t }: {
|
|||||||
const txing = typeof flex?.transmitting === 'boolean'
|
const txing = typeof flex?.transmitting === 'boolean'
|
||||||
? flex.transmitting
|
? flex.transmitting
|
||||||
: fwd ? flexW >= 1 : /TRANSMIT/i.test(pg.state || '');
|
: fwd ? flexW >= 1 : /TRANSMIT/i.test(pg.state || '');
|
||||||
|
// Prefer the radio's VITA meter stream when it is actually flowing (local or
|
||||||
|
// over a VPN) — it is fast and matches SmartSDR. Fall back to the amp's OWN
|
||||||
|
// link only when the radio isn't streaming its meters (a remote link without
|
||||||
|
// the VITA path), so the reading survives but is naturally slower.
|
||||||
const liveW = flexW > 0 ? flexW : (pg.connected ? Number(pg.fwd_w) || 0 : 0);
|
const liveW = flexW > 0 ? flexW : (pg.connected ? Number(pg.fwd_w) || 0 : 0);
|
||||||
const fwdW = hold('fwd', txing ? liveW : 0, txing);
|
const fwdW = hold('fwd', txing ? liveW : 0, txing);
|
||||||
const id = meter(/^ID$|current/i);
|
const id = meter(/^ID$|current/i);
|
||||||
// Live drain current, for the same reason: peak_id latches like peak_w.
|
// Live drain current, same source preference. peak_id latches like peak_w, so
|
||||||
|
// use the instantaneous 'id' and gate it on the radio's transmit flag.
|
||||||
const idA = id ? Number(id.value) : (txing ? Number(pg.id) || undefined : 0);
|
const idA = id ? Number(id.value) : (txing ? Number(pg.id) || undefined : 0);
|
||||||
const temp = meter(/temp/i);
|
const temp = meter(/temp/i);
|
||||||
const tempC = pg.temperature > 0 ? pg.temperature : (temp ? temp.value : undefined);
|
// VITA temp matches SmartSDR (the amp's direct link exposes a different sensor
|
||||||
|
// that can read ~10°C higher); fall back to the direct temp when no VITA.
|
||||||
|
const tempC = temp ? temp.value : (pg.temperature > 0 ? pg.temperature : undefined);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col gap-1.5">
|
<div className="h-full flex flex-col gap-1.5">
|
||||||
|
|||||||
@@ -234,7 +234,14 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
|||||||
<SelectItem key={n} value={n}>
|
<SelectItem key={n} value={n}>
|
||||||
<span className="inline-flex items-center gap-2">
|
<span className="inline-flex items-center gap-2">
|
||||||
{n}
|
{n}
|
||||||
<Trash2 className="size-3 text-muted-foreground hover:text-destructive" onClick={(e) => { e.stopPropagation(); deletePreset(n); }} />
|
{/* Radix SelectItem selects on pointer-up, so an onClick here
|
||||||
|
fires too late — the menu has already loaded the preset and
|
||||||
|
closed. Intercept pointer-down (preventDefault stops Radix
|
||||||
|
claiming it) and delete there instead. */}
|
||||||
|
<Trash2
|
||||||
|
className="size-3 text-muted-foreground hover:text-destructive"
|
||||||
|
onPointerDown={(e) => { e.preventDefault(); e.stopPropagation(); deletePreset(n); }}
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -328,7 +328,10 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
// longer and the meters visibly trail the end of a transmission, which reads
|
// longer and the meters visibly trail the end of a transmission, which reads
|
||||||
// as the app lagging rather than as a peak being held.
|
// as the app lagging rather than as a peak being held.
|
||||||
const peak = useRef<Record<string, { v: number; t: number }>>({});
|
const peak = useRef<Record<string, { v: number; t: number }>>({});
|
||||||
const peakHold = (key: string, val: number, windowMs = 1000) => {
|
const peakHold = (key: string, val: number, windowMs = 1000, live = true) => {
|
||||||
|
// live=false throws the held value away at once (e.g. the carrier dropped),
|
||||||
|
// so the meter falls immediately instead of coasting for the window.
|
||||||
|
if (!live) { delete peak.current[key]; return val; }
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const p = peak.current[key];
|
const p = peak.current[key];
|
||||||
if (!p || val >= p.v || now - p.t > windowMs) { peak.current[key] = { v: val, t: now }; return val; }
|
if (!p || val >= p.v || now - p.t > windowMs) { peak.current[key] = { v: val, t: now }; return val; }
|
||||||
@@ -362,7 +365,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
let alive = true;
|
let alive = true;
|
||||||
const tick = async () => { try { const s: any = await GetPGXLStatus(); if (alive) setPg(s || { connected: false }); } catch {} };
|
const tick = async () => { try { const s: any = await GetPGXLStatus(); if (alive) setPg(s || { connected: false }); } catch {} };
|
||||||
tick();
|
tick();
|
||||||
const id = window.setInterval(tick, 2000);
|
const id = window.setInterval(tick, 250); // match the amp's 250 ms poll so the card catches every envelope peak
|
||||||
return () => { alive = false; window.clearInterval(id); };
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -1114,11 +1117,32 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
{/* Amplifier meters (FWD / ID / TEMP) — moved here from the Meters card
|
{/* Amplifier meters (FWD / ID / TEMP) — moved here from the Meters card
|
||||||
and shown compact so they sit with the amp controls. */}
|
and shown compact so they sit with the amp controls. */}
|
||||||
{(() => {
|
{(() => {
|
||||||
|
// Prefer the radio's VITA amp meters when they are flowing (local or
|
||||||
|
// over a VPN) — fast, and the same values SmartSDR shows (incl. the
|
||||||
|
// right temperature sensor). Fall back to the amp's OWN link only when
|
||||||
|
// the radio isn't streaming them (a remote link without VITA).
|
||||||
const meters = st.meters || [];
|
const meters = st.meters || [];
|
||||||
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
||||||
const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
||||||
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
||||||
if (off || amp.length === 0) return null;
|
if (off || amp.length === 0) {
|
||||||
|
const P = pg as any;
|
||||||
|
if (!P.connected) return null;
|
||||||
|
// Direct-link fallback: a 500 ms poll samples the SSB envelope at
|
||||||
|
// random points, so peak-hold it (live=txing drops it the instant
|
||||||
|
// PTT releases). The LDMOS pair can pull well past 25 A → 50 A scale.
|
||||||
|
const txing = typeof st.transmitting === 'boolean' ? st.transmitting : /TRANSMIT/i.test(P.state || '');
|
||||||
|
const w = txing ? Math.round(Number(P.fwd_w) || 0) : 0;
|
||||||
|
const idA = txing ? Number(P.id) || 0 : 0;
|
||||||
|
const temp = Number(P.temperature) || 0;
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
|
||||||
|
<MeterBar label={t('ampw.pwr')} value={peakHold('pgw', w, 2500, txing)} unit="W" lo={0} hi={2000} accent="#dc2626" />
|
||||||
|
<MeterBar label={t('ampw.id')} value={peakHold('pgid', idA, 2500, txing)} unit="A" lo={0} hi={50} accent="#16a34a" />
|
||||||
|
<MeterBar label={t('ampw.temp')} value={temp} unit="°C" lo={0} hi={80} accent="#ea580c" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
// Power comes from the radio's meter stream and nothing else. The
|
// Power comes from the radio's meter stream and nothing else. The
|
||||||
// amplifier also reports a "peakfwd", and using it was a mistake
|
// amplifier also reports a "peakfwd", and using it was a mistake
|
||||||
// twice over: it is a latched maximum that is never reset, and it
|
// twice over: it is a latched maximum that is never reset, and it
|
||||||
@@ -1133,14 +1157,13 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
}
|
}
|
||||||
const acc = /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
|
const acc = /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
|
||||||
// Drain current (ID): the PGXL reports a full-scale far too small
|
// Drain current (ID): the PGXL reports a full-scale far too small
|
||||||
// for this meter (~3 A), so 1.5 A pinned the bar near half. The
|
// for this meter, so it pinned the bar. Give it a fixed 50 A scale
|
||||||
// value itself is fine — only the bar's range was wrong. Give it a
|
// (the LDMOS pair can pull ~45-50 A near full output), overriding an
|
||||||
// fixed 25 A scale (the PGXL's LDMOS pair tops out around 20 A), and
|
// unusable reported range but keeping a sane one.
|
||||||
// only override an unusable reported range, not a sane one.
|
|
||||||
let lo = m.lo, hi = m.hi;
|
let lo = m.lo, hi = m.hi;
|
||||||
if (/amp/i.test(m.unit || '') || /^ID$|current/i.test(m.name || '')) {
|
if (/amp/i.test(m.unit || '') || /^ID$|current/i.test(m.name || '')) {
|
||||||
lo = 0;
|
lo = 0;
|
||||||
hi = m.hi >= 25 ? m.hi : 25;
|
hi = m.hi >= 50 ? m.hi : 50;
|
||||||
}
|
}
|
||||||
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={lo} hi={hi} accent={acc} />;
|
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={lo} hi={hi} accent={acc} />;
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
|
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, CancelConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign, GetSlotStats } from '../../wailsjs/go/main/App';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||||
@@ -120,6 +120,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
if (service === 'pota' || service === 'paper') { setUploadCall(''); return; }
|
if (service === 'pota' || service === 'paper') { setUploadCall(''); return; }
|
||||||
UploadCallsign(service).then((c) => setUploadCall(c || '')).catch(() => setUploadCall(''));
|
UploadCallsign(service).then((c) => setUploadCall(c || '')).catch(() => setUploadCall(''));
|
||||||
}, [service]);
|
}, [service]);
|
||||||
|
// Closing the QSL Manager (the tab's ×) unmounts this panel — cancel any download
|
||||||
|
// still running so a slow QRZ/LoTW sync doesn't keep going in the background and
|
||||||
|
// bleed its log into the next one.
|
||||||
|
useEffect(() => () => { CancelConfirmations().catch(() => {}); }, []);
|
||||||
const [potaSyncing, setPotaSyncing] = useState(false);
|
const [potaSyncing, setPotaSyncing] = useState(false);
|
||||||
const [potaRes, setPotaRes] = useState<POTASync | null>(null);
|
const [potaRes, setPotaRes] = useState<POTASync | null>(null);
|
||||||
const [potaErr, setPotaErr] = useState('');
|
const [potaErr, setPotaErr] = useState('');
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ interface Props {
|
|||||||
centerLat?: number | null; // operator latitude (projection centre)
|
centerLat?: number | null; // operator latitude (projection centre)
|
||||||
centerLon?: number | null; // operator longitude
|
centerLon?: number | null; // operator longitude
|
||||||
rotorEnabled?: boolean;
|
rotorEnabled?: boolean;
|
||||||
|
rotors?: string[]; // logical rotor names; >1 → show a selector
|
||||||
|
activeRotor?: number; // index of the selected rotor (0-based)
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
@@ -38,7 +41,7 @@ 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, onGoto, onClose }: Props) {
|
export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled, rotors, activeRotor, onSelectRotor, onGoto, onClose }: Props) {
|
||||||
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' } ],
|
||||||
@@ -100,6 +103,31 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Multiple rotors — pick which one the dial shows and turns. */}
|
||||||
|
{rotors && rotors.length > 1 && (
|
||||||
|
<div className="flex flex-wrap gap-1 px-2 pt-1.5">
|
||||||
|
{rotors.map((nm, i) => {
|
||||||
|
const active = (activeRotor ?? 0) === i;
|
||||||
|
const label = nm?.trim() || `Rotor ${i + 1}`;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => onSelectRotor?.(i)}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 min-w-[48px] px-1.5 py-0.5 rounded text-[10px] font-semibold truncate transition-colors',
|
||||||
|
active
|
||||||
|
? 'bg-success text-success-foreground'
|
||||||
|
: 'bg-muted text-muted-foreground hover:bg-muted/70',
|
||||||
|
)}
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-center p-2 min-h-0">
|
<div className="flex items-center justify-center p-2 min-h-0">
|
||||||
<svg
|
<svg
|
||||||
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
GetListsSettings, SaveListsSettings,
|
GetListsSettings, SaveListsSettings,
|
||||||
GetCATSettings, SaveCATSettings, DiscoverFlexRadios,
|
GetCATSettings, SaveCATSettings, DiscoverFlexRadios,
|
||||||
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
||||||
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
|
GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop,
|
||||||
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
|
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
|
||||||
GetAntGeniusSettings, SaveAntGeniusSettings,
|
GetAntGeniusSettings, SaveAntGeniusSettings,
|
||||||
GetTunerGeniusSettings, SaveTunerGeniusSettings,
|
GetTunerGeniusSettings, SaveTunerGeniusSettings,
|
||||||
@@ -80,7 +80,7 @@ type StationSettings = StationSettingsForm;
|
|||||||
type ListsSettings = ListsSettingsForm;
|
type ListsSettings = ListsSettingsForm;
|
||||||
type ModePreset = ModePresetForm;
|
type ModePreset = ModePresetForm;
|
||||||
type CATSettings = Omit<mainModels.CATSettings, 'convertValues'>;
|
type CATSettings = Omit<mainModels.CATSettings, 'convertValues'>;
|
||||||
type RotatorSettings = Omit<mainModels.RotatorSettings, 'convertValues'>;
|
type RotatorDevice = Omit<mainModels.RotatorDevice, 'convertValues'>;
|
||||||
type ClusterServer = Omit<clusterModels.ServerConfig, 'convertValues'>;
|
type ClusterServer = Omit<clusterModels.ServerConfig, 'convertValues'>;
|
||||||
type ClusterServerStatus = Omit<clusterModels.ServerStatus, 'convertValues'>;
|
type ClusterServerStatus = Omit<clusterModels.ServerStatus, 'convertValues'>;
|
||||||
type Profile = Omit<profileModels.Profile, 'convertValues'>;
|
type Profile = Omit<profileModels.Profile, 'convertValues'>;
|
||||||
@@ -1136,14 +1136,15 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
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,
|
||||||
yaesu_port: '', yaesu_baud: 38400, yaesu_low_lines: false, kenwood_low_lines: false, kenwood_port: '', kenwood_baud: 9600, kenwood_host: '', 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,
|
||||||
digital_default: 'FT8', share_enabled: false, share_port: 4532,
|
digital_default: 'FT8', share_enabled: false, share_port: 4532,
|
||||||
|
ptt_hotkey_enabled: false, ptt_hotkey: '', ptt_hotkey_toggle: false,
|
||||||
});
|
});
|
||||||
const [rotator, setRotator] = useState<RotatorSettings>({
|
// While true, the next key press is captured as the PTT hotkey.
|
||||||
enabled: false, type: 'pst', host: '127.0.0.1', port: 12000, has_elevation: false, rotator_num: 1,
|
const [capturingPtt, setCapturingPtt] = useState(false);
|
||||||
} as any);
|
const [rotors, setRotors] = useState<RotatorDevice[]>([]);
|
||||||
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);
|
||||||
|
|
||||||
@@ -1498,7 +1499,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
try {
|
try {
|
||||||
const [l, ls, c, ap, r, b, qd, es] = await Promise.all([
|
const [l, ls, c, ap, r, b, qd, es] = await Promise.all([
|
||||||
GetLookupSettings(), GetListsSettings(), GetCATSettings(), GetActiveProfile(),
|
GetLookupSettings(), GetListsSettings(), GetCATSettings(), GetActiveProfile(),
|
||||||
GetRotatorSettings(), GetBackupSettings(), GetQSLDefaults(), GetExternalServices(),
|
GetRotators(), GetBackupSettings(), GetQSLDefaults(), GetExternalServices(),
|
||||||
]);
|
]);
|
||||||
setLookup(l);
|
setLookup(l);
|
||||||
setActiveProfile(ap as Profile);
|
setActiveProfile(ap as Profile);
|
||||||
@@ -1511,7 +1512,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
await reloadProfiles();
|
await reloadProfiles();
|
||||||
await reloadClusterServers();
|
await reloadClusterServers();
|
||||||
setCatCfg(c);
|
setCatCfg(c);
|
||||||
setRotator(r);
|
setRotors((r ?? []) as any);
|
||||||
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 {}
|
||||||
@@ -1552,7 +1553,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
try { setActiveProfile(await GetActiveProfile() as Profile); } catch {}
|
try { setActiveProfile(await GetActiveProfile() as Profile); } catch {}
|
||||||
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 { setRotator(await GetRotatorSettings() as any); } catch {}
|
try { setRotors(((await GetRotators()) ?? []) as any); } 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 {}
|
||||||
@@ -1744,7 +1745,7 @@ 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 SaveRotatorSettings(rotator as any);
|
await SaveRotators(rotors 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);
|
||||||
@@ -2641,6 +2642,18 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</label>
|
</label>
|
||||||
<span className="text-xs text-muted-foreground">{t('cat.lowerLinesHint')}</span>
|
<span className="text-xs text-muted-foreground">{t('cat.lowerLinesHint')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('cat.kwDataMode')}</Label>
|
||||||
|
<Select value={(catCfg as any).kenwood_data_mode || 'usb'} onValueChange={(v) => setCatCfg((s) => ({ ...s, kenwood_data_mode: v } as any))}>
|
||||||
|
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="usb">{t('cat.kwDataUsb')}</SelectItem>
|
||||||
|
<SelectItem value="data">{t('cat.kwDataMd6')}</SelectItem>
|
||||||
|
<SelectItem value="keep">{t('cat.kwDataKeep')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<span className="text-xs text-muted-foreground">{t('cat.kwDataHint')}</span>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{catCfg.backend === 'icom' && (
|
{catCfg.backend === 'icom' && (
|
||||||
@@ -2811,6 +2824,54 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* PTT hotkey — a keyboard key that keys the rig while OpsLog is focused.
|
||||||
|
Uses the Audio → PTT method (CAT / RTS / DTR), falling back to CAT. */}
|
||||||
|
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={!!(catCfg as any).ptt_hotkey_enabled}
|
||||||
|
onCheckedChange={(c) => setCatCfg((s) => ({ ...s, ptt_hotkey_enabled: !!c } as any))}
|
||||||
|
/>
|
||||||
|
{t('cat.pttKey')}
|
||||||
|
</label>
|
||||||
|
{(catCfg as any).ptt_hotkey_enabled && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (!capturingPtt) return;
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.code === 'Escape') { setCapturingPtt(false); return; }
|
||||||
|
setCatCfg((s) => ({ ...s, ptt_hotkey: e.code } as any));
|
||||||
|
setCapturingPtt(false);
|
||||||
|
}}
|
||||||
|
onClick={() => setCapturingPtt(true)}
|
||||||
|
className="min-w-[140px] rounded-md border border-border bg-muted/40 px-3 py-1.5 text-sm font-mono hover:bg-muted focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
>
|
||||||
|
{capturingPtt ? t('cat.pttKeyPress') : ((catCfg as any).ptt_hotkey || t('cat.pttKeyNone'))}
|
||||||
|
</button>
|
||||||
|
{(catCfg as any).ptt_hotkey && !capturingPtt && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCatCfg((s) => ({ ...s, ptt_hotkey: '' } as any))}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{t('cat.pttKeyClear')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={!!(catCfg as any).ptt_hotkey_toggle}
|
||||||
|
onCheckedChange={(c) => setCatCfg((s) => ({ ...s, ptt_hotkey_toggle: !!c } as any))}
|
||||||
|
/>
|
||||||
|
{t('cat.pttKeyToggle')}
|
||||||
|
</label>
|
||||||
|
<p className="text-[11px] text-muted-foreground">{t('cat.pttKeyHint')}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{catCfg.backend === 'omnirig' && (
|
{catCfg.backend === 'omnirig' && (
|
||||||
<>
|
<>
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
@@ -2830,12 +2891,12 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function testRotator() {
|
async function testRotator(dev: RotatorDevice, sub = 1) {
|
||||||
setRotatorTesting(true);
|
setRotatorTesting(true);
|
||||||
setRotatorTest(null);
|
setRotatorTest(null);
|
||||||
try {
|
try {
|
||||||
await TestRotator(rotator as any);
|
await TestRotatorDevice(dev as any, sub);
|
||||||
setRotatorTest({ ok: true, msg: (rotator as any).type === 'rotgenius' ? t('rot.testOkRG') : t('cat.rotatorOk') });
|
setRotatorTest({ ok: true, msg: (dev as any).type === 'rotgenius' ? t('rot.testOkRG') : t('cat.rotatorOk') });
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setRotatorTest({ ok: false, msg: String(e?.message ?? e) });
|
setRotatorTest({ ok: false, msg: String(e?.message ?? e) });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -3224,6 +3285,18 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* PowerGenius XL asks for a remote-access code when reached from
|
||||||
|
outside the LAN (banner "V… AUTH"), like the Tuner/Antenna Genius.
|
||||||
|
Blank on the LAN. */}
|
||||||
|
{isPGXL && (
|
||||||
|
<div className="space-y-1 max-w-xs">
|
||||||
|
<Label>{t('amp.password')}</Label>
|
||||||
|
<Input type="password" value={(amp as any).password ?? ''}
|
||||||
|
onChange={(e) => patchAmp(i, { password: e.target.value } as any)}
|
||||||
|
placeholder={t('amp.passwordPh')} className="font-mono" />
|
||||||
|
<p className="text-[11px] text-muted-foreground">{t('amp.passwordHint')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Band-follow, for any amp that takes its band from a transceiver
|
{/* Band-follow, for any amp that takes its band from a transceiver
|
||||||
CAT link (ACOM and SPE both do) — never PowerGenius, which is
|
CAT link (ACOM and SPE both do) — never PowerGenius, which is
|
||||||
@@ -3305,26 +3378,46 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RotatorPanel() {
|
function RotatorPanel() {
|
||||||
const isRG = (rotator as any).type === 'rotgenius';
|
// Rotors are a list, like amplifiers: add with +, remove with the trash icon.
|
||||||
const isARCO = (rotator as any).type === 'arco';
|
// A Rotator Genius with "two rotors" contributes 2 addressable rotors.
|
||||||
|
const patch = (i: number, p: Partial<RotatorDevice>) => setRotors((l) => l.map((d, j) => (j === i ? { ...d, ...p } : d)) as any);
|
||||||
|
const addRotor = () => setRotors((l) => [...l, {
|
||||||
|
id: '', name: '', type: 'pst', host: '127.0.0.1', port: 12000, has_elevation: false,
|
||||||
|
rotator_num: 1, dual: false, motorized: true, name2: '', motorized2: false,
|
||||||
|
transport: 'tcp', com_port: '', baud: 9600,
|
||||||
|
} as any]);
|
||||||
|
const removeRotor = (i: number) => setRotors((l) => l.filter((_, j) => j !== i));
|
||||||
|
const anyPst = rotors.some((d) => ((d as any).type ?? 'pst') === 'pst');
|
||||||
|
// The motorized-antenna choice only matters once more than one rotor exists.
|
||||||
|
const multi = rotors.length > 1 || rotors.some((d) => (d as any).type === 'rotgenius' && (d as any).dual);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader title="Rotator" hint={anyPst ? t('rot.hint') : undefined} />
|
||||||
title="Rotator"
|
|
||||||
hint={isRG || isARCO ? undefined : t('rot.hint')}
|
|
||||||
/>
|
|
||||||
<div className="space-y-4 max-w-xl">
|
<div className="space-y-4 max-w-xl">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
{rotors.length === 0 && <p className="text-sm text-muted-foreground">{t('rot.none')}</p>}
|
||||||
<Checkbox checked={rotator.enabled} onCheckedChange={(c) => setRotator((s) => ({ ...s, enabled: !!c }))} />
|
{rotors.map((d, i) => {
|
||||||
{t('rot.enable')}
|
const dev = d as any;
|
||||||
</label>
|
const isRG = dev.type === 'rotgenius';
|
||||||
|
const isARCO = dev.type === 'arco';
|
||||||
|
const transport = dev.transport ?? 'tcp';
|
||||||
|
return (
|
||||||
|
<div key={dev.id || i} className="rounded-xl border border-border bg-card/40 p-3 space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Compass className="size-4 text-primary shrink-0" />
|
||||||
|
<Input className="h-8 flex-1" value={dev.name ?? ''} placeholder={`Rotor ${i + 1}`}
|
||||||
|
onChange={(e) => patch(i, { name: e.target.value })} />
|
||||||
|
<Button size="icon" variant="ghost" className="size-8 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => removeRotor(i)} title={t('rot.remove')}>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>{t('rot.type')}</Label>
|
<Label>{t('rot.type')}</Label>
|
||||||
{/* Each backend gets its default port: Rotator Genius 9006, ARCO 4001
|
{/* Each backend gets its default port: Rotator Genius 9006, ARCO 4001
|
||||||
(placeholder — must match the ARCO's LAN menu), PstRotator 12000. */}
|
(placeholder — must match the ARCO's LAN menu), PstRotator 12000. */}
|
||||||
<Select value={(rotator as any).type ?? 'pst'}
|
<Select value={dev.type ?? 'pst'}
|
||||||
onValueChange={(v) => setRotator((s) => ({ ...s, type: v, port: v === 'rotgenius' ? 9006 : v === 'arco' ? 4001 : 12000 } as any))}>
|
onValueChange={(v) => patch(i, { type: v as any, port: v === 'rotgenius' ? 9006 : v === 'arco' ? 4001 : 12000 })}>
|
||||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="pst">PstRotator (UDP)</SelectItem>
|
<SelectItem value="pst">PstRotator (UDP)</SelectItem>
|
||||||
@@ -3333,11 +3426,11 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
{isRG && (
|
{isRG && !dev.dual && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>{t('rot.rotatorNum')}</Label>
|
<Label>{t('rot.rotatorNum')}</Label>
|
||||||
<Select value={String((rotator as any).rotator_num ?? 1)}
|
<Select value={String(dev.rotator_num ?? 1)}
|
||||||
onValueChange={(v) => setRotator((s) => ({ ...s, rotator_num: parseInt(v, 10) || 1 } as any))}>
|
onValueChange={(v) => patch(i, { rotator_num: parseInt(v, 10) || 1 })}>
|
||||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="1">1</SelectItem>
|
<SelectItem value="1">1</SelectItem>
|
||||||
@@ -3350,8 +3443,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
{isARCO && (
|
{isARCO && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Connection</Label>
|
<Label>Connection</Label>
|
||||||
<Select value={(rotator as any).transport ?? 'tcp'}
|
<Select value={transport} onValueChange={(v) => patch(i, { transport: v as any })}>
|
||||||
onValueChange={(v) => setRotator((s) => ({ ...s, transport: v } as any))}>
|
|
||||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="tcp">Network (LAN)</SelectItem>
|
<SelectItem value="tcp">Network (LAN)</SelectItem>
|
||||||
@@ -3361,12 +3453,18 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isARCO && (rotator as any).transport === 'serial' ? (
|
{/* Rotator Genius drives both its ports — one entry = two rotors. */}
|
||||||
|
{isRG && (
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={!!dev.dual} onCheckedChange={(c) => patch(i, { dual: !!c })} />
|
||||||
|
{t('rot.rgDual')}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{isARCO && transport === 'serial' ? (
|
||||||
<div className="space-y-1 max-w-xs">
|
<div className="space-y-1 max-w-xs">
|
||||||
<Label>COM port</Label>
|
<Label>COM port</Label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Select value={(rotator as any).com_port || '_'}
|
<Select value={dev.com_port || '_'} onValueChange={(v) => patch(i, { com_port: v === '_' ? '' : v })}>
|
||||||
onValueChange={(v) => setRotator((s) => ({ ...s, com_port: v === '_' ? '' : v } as any))}>
|
|
||||||
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
|
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
|
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
|
||||||
@@ -3376,8 +3474,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<Button size="sm" variant="outline" className="h-9" onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
<Button size="sm" variant="outline" className="h-9" onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
||||||
<ArrowDown className="size-3.5 rotate-90" />
|
<ArrowDown className="size-3.5 rotate-90" />
|
||||||
</Button>
|
</Button>
|
||||||
<Select value={String((rotator as any).baud || 9600)}
|
<Select value={String(dev.baud || 9600)} onValueChange={(v) => patch(i, { baud: Number(v) })}>
|
||||||
onValueChange={(v) => setRotator((s) => ({ ...s, baud: Number(v) } as any))}>
|
|
||||||
<SelectTrigger className="h-9 w-28"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-9 w-28"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{[4800, 9600, 19200, 38400, 57600].map((b) => (
|
{[4800, 9600, 19200, 38400, 57600].map((b) => (
|
||||||
@@ -3391,44 +3488,85 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div className="space-y-1 col-span-2">
|
<div className="space-y-1 col-span-2">
|
||||||
<Label>Host / IP</Label>
|
<Label>Host / IP</Label>
|
||||||
<Input
|
<Input value={dev.host ?? ''} onChange={(e) => patch(i, { host: e.target.value })}
|
||||||
value={rotator.host ?? ''}
|
placeholder={isRG || isARCO ? '192.168.1.60' : '127.0.0.1'} className="font-mono" />
|
||||||
onChange={(e) => setRotator((s) => ({ ...s, host: e.target.value }))}
|
|
||||||
placeholder={isRG || isARCO ? '192.168.1.60' : '127.0.0.1'}
|
|
||||||
className="font-mono"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>{isRG || isARCO ? 'TCP port' : 'UDP port'}</Label>
|
<Label>{isRG || isARCO ? 'TCP port' : 'UDP port'}</Label>
|
||||||
<PortInput
|
<PortInput value={dev.port} onChange={(n) => patch(i, { port: n })}
|
||||||
value={rotator.port}
|
fallback={isRG ? 9006 : isARCO ? 4001 : 12000} className="font-mono" />
|
||||||
onChange={(n) => setRotator((s) => ({ ...s, port: n }))} fallback={isRG ? 9006 : isARCO ? 4001 : 12000}
|
|
||||||
className="font-mono"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isRG && !isARCO && (
|
{!isRG && !isARCO && (
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={rotator.has_elevation} onCheckedChange={(c) => setRotator((s) => ({ ...s, has_elevation: !!c }))} />
|
<Checkbox checked={!!dev.has_elevation} onCheckedChange={(c) => patch(i, { has_elevation: !!c })} />
|
||||||
This rotator supports elevation (VHF / satellite)
|
This rotator supports elevation (VHF / satellite)
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
{isRG && <p className="text-xs text-muted-foreground">{t('rot.rgHint')}</p>}
|
{isRG && <p className="text-xs text-muted-foreground">{t('rot.rgHint')}</p>}
|
||||||
{isARCO && <p className="text-xs text-muted-foreground">{t('rot.arcoHint')}</p>}
|
{isARCO && <p className="text-xs text-muted-foreground">{t('rot.arcoHint')}</p>}
|
||||||
<div className="flex items-center gap-2 pt-2">
|
{/* Which antenna this rotor carries — only relevant with >1 rotor. */}
|
||||||
<Button variant="outline" size="sm" onClick={testRotator} disabled={rotatorTesting}>
|
{multi && (
|
||||||
{rotatorTesting ? t('hw.sending') : t('hw.testRotator')}
|
<div className="space-y-1 max-w-xs">
|
||||||
|
<Label>{isRG && dev.dual ? t('rot.antenna1') : t('rot.antenna')}</Label>
|
||||||
|
<Select value={dev.motorized ? 'motor' : 'std'} onValueChange={(v) => patch(i, { motorized: v === 'motor' })}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="std">{t('rot.antStd')}</SelectItem>
|
||||||
|
<SelectItem value="motor">{t('rot.antMotor')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Second logical rotor of a dual Rotator Genius: its own name + antenna. */}
|
||||||
|
{isRG && dev.dual && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('rot.name2')}</Label>
|
||||||
|
<Input value={dev.name2 ?? ''} placeholder={`${dev.name || 'Rotor'} 2`}
|
||||||
|
onChange={(e) => patch(i, { name2: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('rot.antenna2')}</Label>
|
||||||
|
<Select value={dev.motorized2 ? 'motor' : 'std'} onValueChange={(v) => patch(i, { motorized2: v === 'motor' })}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="std">{t('rot.antStd')}</SelectItem>
|
||||||
|
<SelectItem value="motor">{t('rot.antMotor')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => testRotator(d, 1)} disabled={rotatorTesting}>
|
||||||
|
{rotatorTesting ? t('hw.sending') : (isRG && dev.dual ? t('rot.testRotor1') : t('hw.testRotator'))}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" onClick={() => RotatorStop().catch((e) => setErr(String(e?.message ?? e)))} disabled={!rotator.enabled}>
|
{isRG && dev.dual && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => testRotator(d, 2)} disabled={rotatorTesting}>
|
||||||
|
{t('rot.testRotor2')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Button variant="outline" size="sm" onClick={addRotor}>
|
||||||
|
<Plus className="size-3.5 mr-1" /> {t('rot.add')}
|
||||||
|
</Button>
|
||||||
|
{rotors.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 pt-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => RotatorStop().catch((e) => setErr(String(e?.message ?? e)))}>
|
||||||
Stop
|
Stop
|
||||||
</Button>
|
</Button>
|
||||||
{!isRG && (
|
{anyPst && (
|
||||||
<Button variant="outline" size="sm" onClick={() => RotatorPark().catch((e) => setErr(String(e?.message ?? e)))} disabled={!rotator.enabled}>
|
<Button variant="outline" size="sm" onClick={() => RotatorPark().catch((e) => setErr(String(e?.message ?? e)))}>
|
||||||
Park
|
Park
|
||||||
</Button>
|
</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',
|
||||||
@@ -3440,8 +3578,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
From the main entry strip, click the bearing pill to rotate to the short-path azimuth.
|
From the main entry strip, click the bearing pill to rotate the active rotor to the short-path
|
||||||
Shift+click for long-path, Ctrl+click to stop.
|
azimuth. Shift+click for long-path, Ctrl+click to stop. With several rotors, pick the active one
|
||||||
|
on the compass widget.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -3479,6 +3618,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<SelectItem value="serial">{t('wk.engSerial')}</SelectItem>
|
<SelectItem value="serial">{t('wk.engSerial')}</SelectItem>
|
||||||
<SelectItem value="icom">{t('wk.engIcom')}</SelectItem>
|
<SelectItem value="icom">{t('wk.engIcom')}</SelectItem>
|
||||||
<SelectItem value="yaesu">{t('wk.engYaesu')}</SelectItem>
|
<SelectItem value="yaesu">{t('wk.engYaesu')}</SelectItem>
|
||||||
|
<SelectItem value="kenwood">{t('wk.engKenwood')}</SelectItem>
|
||||||
<SelectItem value="flex">{t('wk.engFlex')}</SelectItem>
|
<SelectItem value="flex">{t('wk.engFlex')}</SelectItem>
|
||||||
<SelectItem value="tci" disabled>{t('wk.engTci')}</SelectItem>
|
<SelectItem value="tci" disabled>{t('wk.engTci')}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -3525,6 +3665,22 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
) : wk.engine === 'kenwood' ? (
|
||||||
|
<>
|
||||||
|
{(!catCfg.enabled || catCfg.backend !== 'kenwood') && (
|
||||||
|
<p className="text-xs font-medium text-warning -mt-1 flex items-start gap-1.5">
|
||||||
|
<span aria-hidden>⚠</span>
|
||||||
|
<span>{t('wk.catWarnKenwood', { backend: catCfg.enabled ? (catCfg.backend || 'none') : 'disabled' })}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground -mt-1">{t('wk.kenwoodHint')}</p>
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('wk.speed')}</Label>
|
||||||
|
<Input type="number" min={4} max={60} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
) : wk.engine === 'flex' ? (
|
) : wk.engine === 'flex' ? (
|
||||||
<>
|
<>
|
||||||
{(!catCfg.enabled || catCfg.backend !== 'flex') && (
|
{(!catCfg.enabled || catCfg.backend !== 'flex') && (
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { TunerCard } from '@/components/TunerCard';
|
|||||||
import type { TGStatus } from '@/components/TunerGeniusPanel';
|
import type { TGStatus } from '@/components/TunerGeniusPanel';
|
||||||
import {
|
import {
|
||||||
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
|
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
|
||||||
GetRotatorHeading, RotatorGoTo, RotatorStop,
|
GetRotatorHeading, RotatorGoTo, RotatorStop, SetActiveRotor,
|
||||||
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
||||||
ListDenkoviDevices, ListSerialPorts, TestStationDevice,
|
ListDenkoviDevices, ListSerialPorts, TestStationDevice,
|
||||||
GetAmpStatuses, GetFlexState,
|
GetAmpStatuses, GetFlexState,
|
||||||
@@ -47,7 +47,7 @@ function blankDevice(): Device {
|
|||||||
return { id: '', type: 'webswitch', name: '', host: '', user: '', pass: '', labels: Array(5).fill('') };
|
return { id: '', type: 'webswitch', name: '', host: '', user: '', pass: '', labels: Array(5).fill('') };
|
||||||
}
|
}
|
||||||
|
|
||||||
type Heading = { enabled: boolean; ok: boolean; azimuth: number };
|
type Heading = { enabled: boolean; ok: boolean; azimuth: number; rotors?: string[]; active?: number };
|
||||||
|
|
||||||
// RotatorWidget shows the configured rotator (Settings → Rotator) right in the
|
// RotatorWidget shows the configured rotator (Settings → Rotator) right in the
|
||||||
// Station Control tab: a live compass you can click to turn, a heading readout, a
|
// Station Control tab: a live compass you can click to turn, a heading readout, a
|
||||||
@@ -82,6 +82,9 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
|
|||||||
centerLat={centerLat ?? null}
|
centerLat={centerLat ?? null}
|
||||||
centerLon={centerLon ?? null}
|
centerLon={centerLon ?? null}
|
||||||
rotorEnabled={hd.ok}
|
rotorEnabled={hd.ok}
|
||||||
|
rotors={hd.rotors}
|
||||||
|
activeRotor={hd.active}
|
||||||
|
onSelectRotor={(i) => { SetActiveRotor(i).then(refetch).catch((e) => setErr(String(e?.message ?? e))); }}
|
||||||
onGoto={(az) => turn(az)}
|
onGoto={(az) => turn(az)}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-0 space-y-2">
|
<div className="flex-1 min-w-0 space-y-2">
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ interface Props {
|
|||||||
wpm: number;
|
wpm: number;
|
||||||
macros: WKMacro[];
|
macros: WKMacro[];
|
||||||
sent: string; // text echoed back by the keyer as it transmits
|
sent: string; // text echoed back by the keyer as it transmits
|
||||||
source: 'winkeyer' | 'icom' | 'flex' | 'yaesu'; // CW output engine (chosen in Settings → CW Keyer)
|
source: 'winkeyer' | 'icom' | 'flex' | 'yaesu' | 'kenwood'; // CW output engine (chosen in Settings → CW Keyer)
|
||||||
breakIn?: number; // Icom CW break-in: 0=OFF, 1=SEMI, 2=FULL
|
breakIn?: number; // Icom CW break-in: 0=OFF, 1=SEMI, 2=FULL
|
||||||
onSetBreakIn?: (mode: number) => void;
|
onSetBreakIn?: (mode: number) => void;
|
||||||
onSelectPort: (p: string) => void;
|
onSelectPort: (p: string) => void;
|
||||||
@@ -101,16 +101,16 @@ export function WinkeyerPanel({
|
|||||||
<Radio className="size-4 text-primary shrink-0" />
|
<Radio className="size-4 text-primary shrink-0" />
|
||||||
{/* CW output engine (chosen in Settings → CW Keyer). */}
|
{/* CW output engine (chosen in Settings → CW Keyer). */}
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
|
||||||
{source === 'icom' ? 'Icom CW' : source === 'flex' ? 'Flex CWX' : source === 'yaesu' ? 'Yaesu CW' : 'WinKeyer'}
|
{source === 'icom' ? 'Icom CW' : source === 'flex' ? 'Flex CWX' : source === 'yaesu' ? 'Yaesu CW' : source === 'kenwood' ? 'Kenwood CW' : 'WinKeyer'}
|
||||||
</span>
|
</span>
|
||||||
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-warning animate-pulse' : 'bg-success') : 'bg-muted-foreground/40')}
|
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-warning animate-pulse' : 'bg-success') : 'bg-muted-foreground/40')}
|
||||||
title={connected ? (status.busy ? t('wkp.sending') : t('wkp.connectedV', { version: status.version })) : t('wkp.disconnected')} />
|
title={connected ? (status.busy ? t('wkp.sending') : t('wkp.connectedV', { version: status.version })) : t('wkp.disconnected')} />
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{source === 'icom' || source === 'flex' || source === 'yaesu' ? (
|
{source === 'icom' || source === 'flex' || source === 'yaesu' || source === 'kenwood' ? (
|
||||||
<span className="text-[11px] font-medium text-muted-foreground">
|
<span className="text-[11px] font-medium text-muted-foreground">
|
||||||
{source === 'flex'
|
{source === 'flex'
|
||||||
? (connected ? t('wkp.cwxReady') : t('wkp.cwxOffline'))
|
? (connected ? t('wkp.cwxReady') : t('wkp.cwxOffline'))
|
||||||
: source === 'yaesu'
|
: source === 'yaesu' || source === 'kenwood'
|
||||||
? (connected ? t('wkp.rigReady') : t('wkp.rigOffline'))
|
? (connected ? t('wkp.rigReady') : t('wkp.rigOffline'))
|
||||||
: (connected ? t('wkp.civReady') : t('wkp.civOffline'))}
|
: (connected ? t('wkp.civReady') : t('wkp.civOffline'))}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -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.23.2';
|
export const APP_VERSION = '0.23.5';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+17
-3
@@ -97,6 +97,8 @@ export function CIVTraceEnabled():Promise<boolean>;
|
|||||||
|
|
||||||
export function CWDecoderRunning():Promise<boolean>;
|
export function CWDecoderRunning():Promise<boolean>;
|
||||||
|
|
||||||
|
export function CancelConfirmations():Promise<void>;
|
||||||
|
|
||||||
export function ChatAvailable():Promise<boolean>;
|
export function ChatAvailable():Promise<boolean>;
|
||||||
|
|
||||||
export function CheckForUpdate():Promise<main.UpdateInfo>;
|
export function CheckForUpdate():Promise<main.UpdateInfo>;
|
||||||
@@ -469,7 +471,7 @@ export function GetRelayAuto():Promise<main.RelayAutoConfig>;
|
|||||||
|
|
||||||
export function GetRotatorHeading():Promise<main.RotatorHeading>;
|
export function GetRotatorHeading():Promise<main.RotatorHeading>;
|
||||||
|
|
||||||
export function GetRotatorSettings():Promise<main.RotatorSettings>;
|
export function GetRotators():Promise<Array<main.RotatorDevice>>;
|
||||||
|
|
||||||
export function GetSPEStatus():Promise<spe.Status>;
|
export function GetSPEStatus():Promise<spe.Status>;
|
||||||
|
|
||||||
@@ -607,6 +609,10 @@ export function InspectAwardImport():Promise<main.AwardImportPreview>;
|
|||||||
|
|
||||||
export function IsNewUSCounty(arg1:string,arg2:string):Promise<boolean>;
|
export function IsNewUSCounty(arg1:string,arg2:string):Promise<boolean>;
|
||||||
|
|
||||||
|
export function KenwoodSendCW(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function KenwoodStopCW():Promise<void>;
|
||||||
|
|
||||||
export function LaunchAutostartProgram(arg1:string):Promise<main.AutostartLaunchResult>;
|
export function LaunchAutostartProgram(arg1:string):Promise<main.AutostartLaunchResult>;
|
||||||
|
|
||||||
export function LaunchAutostartPrograms():Promise<Array<main.AutostartLaunchResult>>;
|
export function LaunchAutostartPrograms():Promise<Array<main.AutostartLaunchResult>>;
|
||||||
@@ -707,6 +713,10 @@ export function PGXLSetFanMode(arg1:string):Promise<void>;
|
|||||||
|
|
||||||
export function PGXLSetOperate(arg1:boolean):Promise<void>;
|
export function PGXLSetOperate(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function PTTHotkeyDown():Promise<void>;
|
||||||
|
|
||||||
|
export function PTTHotkeyUp():Promise<void>;
|
||||||
|
|
||||||
export function PickADIFMonitorFile():Promise<string>;
|
export function PickADIFMonitorFile():Promise<string>;
|
||||||
|
|
||||||
export function PickAudioFolder():Promise<string>;
|
export function PickAudioFolder():Promise<string>;
|
||||||
@@ -879,7 +889,7 @@ export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>;
|
|||||||
|
|
||||||
export function SaveRelayAuto(arg1:main.RelayAutoConfig):Promise<void>;
|
export function SaveRelayAuto(arg1:main.RelayAutoConfig):Promise<void>;
|
||||||
|
|
||||||
export function SaveRotatorSettings(arg1:main.RotatorSettings):Promise<void>;
|
export function SaveRotators(arg1:Array<main.RotatorDevice>):Promise<void>;
|
||||||
|
|
||||||
export function SaveStationDevices(arg1:Array<main.StationDevice>):Promise<void>;
|
export function SaveStationDevices(arg1:Array<main.StationDevice>):Promise<void>;
|
||||||
|
|
||||||
@@ -909,6 +919,8 @@ export function SendLogToDeveloper():Promise<void>;
|
|||||||
|
|
||||||
export function SendQSORecordingEmail(arg1:number):Promise<void>;
|
export function SendQSORecordingEmail(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function SetActiveRotor(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SetAlertEmailTo(arg1:string):Promise<void>;
|
export function SetAlertEmailTo(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function SetCATFrequency(arg1:number):Promise<void>;
|
export function SetCATFrequency(arg1:number):Promise<void>;
|
||||||
@@ -929,6 +941,8 @@ export function SetCompactMode(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
|
export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SetKenwoodKeySpeed(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SetPassphrase(arg1:string):Promise<void>;
|
export function SetPassphrase(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function SetScpEnabled(arg1:boolean):Promise<void>;
|
export function SetScpEnabled(arg1:boolean):Promise<void>;
|
||||||
@@ -1011,7 +1025,7 @@ export function TestPTT(arg1:main.AudioSettings):Promise<void>;
|
|||||||
|
|
||||||
export function TestQRZUpload():Promise<string>;
|
export function TestQRZUpload():Promise<string>;
|
||||||
|
|
||||||
export function TestRotator(arg1:main.RotatorSettings):Promise<void>;
|
export function TestRotatorDevice(arg1:main.RotatorDevice,arg2:number):Promise<void>;
|
||||||
|
|
||||||
export function TestStationDevice(arg1:main.StationDevice):Promise<main.StationTestResult>;
|
export function TestStationDevice(arg1:main.StationDevice):Promise<main.StationTestResult>;
|
||||||
|
|
||||||
|
|||||||
@@ -142,6 +142,10 @@ export function CWDecoderRunning() {
|
|||||||
return window['go']['main']['App']['CWDecoderRunning']();
|
return window['go']['main']['App']['CWDecoderRunning']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function CancelConfirmations() {
|
||||||
|
return window['go']['main']['App']['CancelConfirmations']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ChatAvailable() {
|
export function ChatAvailable() {
|
||||||
return window['go']['main']['App']['ChatAvailable']();
|
return window['go']['main']['App']['ChatAvailable']();
|
||||||
}
|
}
|
||||||
@@ -886,8 +890,8 @@ export function GetRotatorHeading() {
|
|||||||
return window['go']['main']['App']['GetRotatorHeading']();
|
return window['go']['main']['App']['GetRotatorHeading']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetRotatorSettings() {
|
export function GetRotators() {
|
||||||
return window['go']['main']['App']['GetRotatorSettings']();
|
return window['go']['main']['App']['GetRotators']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetSPEStatus() {
|
export function GetSPEStatus() {
|
||||||
@@ -1162,6 +1166,14 @@ export function IsNewUSCounty(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['IsNewUSCounty'](arg1, arg2);
|
return window['go']['main']['App']['IsNewUSCounty'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function KenwoodSendCW(arg1) {
|
||||||
|
return window['go']['main']['App']['KenwoodSendCW'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KenwoodStopCW() {
|
||||||
|
return window['go']['main']['App']['KenwoodStopCW']();
|
||||||
|
}
|
||||||
|
|
||||||
export function LaunchAutostartProgram(arg1) {
|
export function LaunchAutostartProgram(arg1) {
|
||||||
return window['go']['main']['App']['LaunchAutostartProgram'](arg1);
|
return window['go']['main']['App']['LaunchAutostartProgram'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1362,6 +1374,14 @@ export function PGXLSetOperate(arg1) {
|
|||||||
return window['go']['main']['App']['PGXLSetOperate'](arg1);
|
return window['go']['main']['App']['PGXLSetOperate'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function PTTHotkeyDown() {
|
||||||
|
return window['go']['main']['App']['PTTHotkeyDown']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PTTHotkeyUp() {
|
||||||
|
return window['go']['main']['App']['PTTHotkeyUp']();
|
||||||
|
}
|
||||||
|
|
||||||
export function PickADIFMonitorFile() {
|
export function PickADIFMonitorFile() {
|
||||||
return window['go']['main']['App']['PickADIFMonitorFile']();
|
return window['go']['main']['App']['PickADIFMonitorFile']();
|
||||||
}
|
}
|
||||||
@@ -1706,8 +1726,8 @@ export function SaveRelayAuto(arg1) {
|
|||||||
return window['go']['main']['App']['SaveRelayAuto'](arg1);
|
return window['go']['main']['App']['SaveRelayAuto'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SaveRotatorSettings(arg1) {
|
export function SaveRotators(arg1) {
|
||||||
return window['go']['main']['App']['SaveRotatorSettings'](arg1);
|
return window['go']['main']['App']['SaveRotators'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SaveStationDevices(arg1) {
|
export function SaveStationDevices(arg1) {
|
||||||
@@ -1766,6 +1786,10 @@ export function SendQSORecordingEmail(arg1) {
|
|||||||
return window['go']['main']['App']['SendQSORecordingEmail'](arg1);
|
return window['go']['main']['App']['SendQSORecordingEmail'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetActiveRotor(arg1) {
|
||||||
|
return window['go']['main']['App']['SetActiveRotor'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetAlertEmailTo(arg1) {
|
export function SetAlertEmailTo(arg1) {
|
||||||
return window['go']['main']['App']['SetAlertEmailTo'](arg1);
|
return window['go']['main']['App']['SetAlertEmailTo'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1806,6 +1830,10 @@ export function SetDVKLabel(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['SetDVKLabel'](arg1, arg2);
|
return window['go']['main']['App']['SetDVKLabel'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetKenwoodKeySpeed(arg1) {
|
||||||
|
return window['go']['main']['App']['SetKenwoodKeySpeed'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetPassphrase(arg1) {
|
export function SetPassphrase(arg1) {
|
||||||
return window['go']['main']['App']['SetPassphrase'](arg1);
|
return window['go']['main']['App']['SetPassphrase'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1970,8 +1998,8 @@ export function TestQRZUpload() {
|
|||||||
return window['go']['main']['App']['TestQRZUpload']();
|
return window['go']['main']['App']['TestQRZUpload']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TestRotator(arg1) {
|
export function TestRotatorDevice(arg1, arg2) {
|
||||||
return window['go']['main']['App']['TestRotator'](arg1);
|
return window['go']['main']['App']['TestRotatorDevice'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TestStationDevice(arg1) {
|
export function TestStationDevice(arg1) {
|
||||||
|
|||||||
@@ -1520,6 +1520,7 @@ export namespace main {
|
|||||||
transport: string;
|
transport: string;
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
|
password: string;
|
||||||
com_port: string;
|
com_port: string;
|
||||||
baud: number;
|
baud: number;
|
||||||
freq_out: boolean;
|
freq_out: boolean;
|
||||||
@@ -1540,6 +1541,7 @@ export namespace main {
|
|||||||
this.transport = source["transport"];
|
this.transport = source["transport"];
|
||||||
this.host = source["host"];
|
this.host = source["host"];
|
||||||
this.port = source["port"];
|
this.port = source["port"];
|
||||||
|
this.password = source["password"];
|
||||||
this.com_port = source["com_port"];
|
this.com_port = source["com_port"];
|
||||||
this.baud = source["baud"];
|
this.baud = source["baud"];
|
||||||
this.freq_out = source["freq_out"];
|
this.freq_out = source["freq_out"];
|
||||||
@@ -1928,6 +1930,7 @@ export namespace main {
|
|||||||
kenwood_host: string;
|
kenwood_host: string;
|
||||||
kenwood_port: string;
|
kenwood_port: string;
|
||||||
kenwood_baud: number;
|
kenwood_baud: number;
|
||||||
|
kenwood_data_mode: string;
|
||||||
yaesu_low_lines: boolean;
|
yaesu_low_lines: boolean;
|
||||||
kenwood_low_lines: boolean;
|
kenwood_low_lines: boolean;
|
||||||
icom_port: string;
|
icom_port: string;
|
||||||
@@ -1945,6 +1948,9 @@ export namespace main {
|
|||||||
digital_default: string;
|
digital_default: string;
|
||||||
share_enabled: boolean;
|
share_enabled: boolean;
|
||||||
share_port: number;
|
share_port: number;
|
||||||
|
ptt_hotkey_enabled: boolean;
|
||||||
|
ptt_hotkey: string;
|
||||||
|
ptt_hotkey_toggle: boolean;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new CATSettings(source);
|
return new CATSettings(source);
|
||||||
@@ -1970,6 +1976,7 @@ export namespace main {
|
|||||||
this.kenwood_host = source["kenwood_host"];
|
this.kenwood_host = source["kenwood_host"];
|
||||||
this.kenwood_port = source["kenwood_port"];
|
this.kenwood_port = source["kenwood_port"];
|
||||||
this.kenwood_baud = source["kenwood_baud"];
|
this.kenwood_baud = source["kenwood_baud"];
|
||||||
|
this.kenwood_data_mode = source["kenwood_data_mode"];
|
||||||
this.yaesu_low_lines = source["yaesu_low_lines"];
|
this.yaesu_low_lines = source["yaesu_low_lines"];
|
||||||
this.kenwood_low_lines = source["kenwood_low_lines"];
|
this.kenwood_low_lines = source["kenwood_low_lines"];
|
||||||
this.icom_port = source["icom_port"];
|
this.icom_port = source["icom_port"];
|
||||||
@@ -1987,6 +1994,9 @@ export namespace main {
|
|||||||
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.ptt_hotkey_enabled = source["ptt_hotkey_enabled"];
|
||||||
|
this.ptt_hotkey = source["ptt_hotkey"];
|
||||||
|
this.ptt_hotkey_toggle = source["ptt_hotkey_toggle"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class CabrilloResult {
|
export class CabrilloResult {
|
||||||
@@ -2804,11 +2814,52 @@ export namespace main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class RotatorDevice {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
has_elevation: boolean;
|
||||||
|
rotator_num: number;
|
||||||
|
dual: boolean;
|
||||||
|
motorized: boolean;
|
||||||
|
name2: string;
|
||||||
|
motorized2: boolean;
|
||||||
|
transport: string;
|
||||||
|
com_port: string;
|
||||||
|
baud: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new RotatorDevice(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.id = source["id"];
|
||||||
|
this.name = source["name"];
|
||||||
|
this.type = source["type"];
|
||||||
|
this.host = source["host"];
|
||||||
|
this.port = source["port"];
|
||||||
|
this.has_elevation = source["has_elevation"];
|
||||||
|
this.rotator_num = source["rotator_num"];
|
||||||
|
this.dual = source["dual"];
|
||||||
|
this.motorized = source["motorized"];
|
||||||
|
this.name2 = source["name2"];
|
||||||
|
this.motorized2 = source["motorized2"];
|
||||||
|
this.transport = source["transport"];
|
||||||
|
this.com_port = source["com_port"];
|
||||||
|
this.baud = source["baud"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class RotatorHeading {
|
export class RotatorHeading {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
azimuth: number;
|
azimuth: number;
|
||||||
raw: string;
|
raw: string;
|
||||||
|
rotors: string[];
|
||||||
|
active: number;
|
||||||
|
motorized: boolean;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new RotatorHeading(source);
|
return new RotatorHeading(source);
|
||||||
@@ -2820,34 +2871,9 @@ export namespace main {
|
|||||||
this.ok = source["ok"];
|
this.ok = source["ok"];
|
||||||
this.azimuth = source["azimuth"];
|
this.azimuth = source["azimuth"];
|
||||||
this.raw = source["raw"];
|
this.raw = source["raw"];
|
||||||
}
|
this.rotors = source["rotors"];
|
||||||
}
|
this.active = source["active"];
|
||||||
export class RotatorSettings {
|
this.motorized = source["motorized"];
|
||||||
enabled: boolean;
|
|
||||||
type: string;
|
|
||||||
host: string;
|
|
||||||
port: number;
|
|
||||||
has_elevation: boolean;
|
|
||||||
rotator_num: number;
|
|
||||||
transport: string;
|
|
||||||
com_port: string;
|
|
||||||
baud: number;
|
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
|
||||||
return new RotatorSettings(source);
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(source: any = {}) {
|
|
||||||
if ('string' === typeof source) source = JSON.parse(source);
|
|
||||||
this.enabled = source["enabled"];
|
|
||||||
this.type = source["type"];
|
|
||||||
this.host = source["host"];
|
|
||||||
this.port = source["port"];
|
|
||||||
this.has_elevation = source["has_elevation"];
|
|
||||||
this.rotator_num = source["rotator_num"];
|
|
||||||
this.transport = source["transport"];
|
|
||||||
this.com_port = source["com_port"];
|
|
||||||
this.baud = source["baud"];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class ScpStatus {
|
export class ScpStatus {
|
||||||
|
|||||||
@@ -869,3 +869,22 @@ func (m *Manager) YaesuDo(fn func(YaesuController) error) error {
|
|||||||
return fn(yc)
|
return fn(yc)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// KenwoodController is the Kenwood/Elecraft CW-over-CAT capability (the KY keyer),
|
||||||
|
// so a K3 can key CW through its single CAT link instead of a second COM port.
|
||||||
|
type KenwoodController interface {
|
||||||
|
SendCW(string) error
|
||||||
|
StopCW() error
|
||||||
|
SetKeySpeed(int) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// KenwoodDo dispatches a Kenwood control onto the CAT goroutine.
|
||||||
|
func (m *Manager) KenwoodDo(fn func(KenwoodController) error) error {
|
||||||
|
return m.exec(func(b Backend) error {
|
||||||
|
kc, ok := b.(KenwoodController)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("active CAT backend is not a Kenwood/Elecraft")
|
||||||
|
}
|
||||||
|
return fn(kc)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
+17
-2
@@ -62,8 +62,10 @@ type Flex struct {
|
|||||||
spotsEnabled bool // push cluster spots + manage the panadapter overlay
|
spotsEnabled bool // push cluster spots + manage the panadapter overlay
|
||||||
spotIdx map[int]bool // panadapter spot indices currently known to the radio
|
spotIdx map[int]bool // panadapter spot indices currently known to the radio
|
||||||
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
|
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
|
||||||
|
pendingSpotMode map[int]string // seq → ADIF mode, paired with pendingSpot
|
||||||
pendingSplit map[int]bool // seq → awaiting the new TX slice's index (split create)
|
pendingSplit map[int]bool // seq → awaiting the new TX slice's index (split create)
|
||||||
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
|
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
|
||||||
|
spotMode map[int]string // spot index → ADIF mode, so a click can also set the slice mode (SmartSDR tunes the spot's freq but not its mode)
|
||||||
spotByCall map[string]int // callsign → live spot index, so re-spotting a call replaces its old spot (WSJT decodes re-fire every cycle)
|
spotByCall map[string]int // callsign → live spot index, so re-spotting a call replaces its old spot (WSJT decodes re-fire every cycle)
|
||||||
sentCmds map[int]string // seq → command text, so an R<seq> error names the command
|
sentCmds map[int]string // seq → command text, so an R<seq> error names the command
|
||||||
|
|
||||||
@@ -185,7 +187,7 @@ func NewFlex(host string, port int, spotsEnabled bool) *Flex {
|
|||||||
return &Flex{
|
return &Flex{
|
||||||
host: strings.TrimSpace(host), port: port,
|
host: strings.TrimSpace(host), port: port,
|
||||||
slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled,
|
slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled,
|
||||||
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{},
|
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, pendingSpotMode: map[int]string{}, spotCall: map[int]string{}, spotMode: map[int]string{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{},
|
||||||
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
|
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
|
||||||
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
|
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
|
||||||
pinnedSlice: -1,
|
pinnedSlice: -1,
|
||||||
@@ -338,11 +340,17 @@ func (f *Flex) reader(conn net.Conn) {
|
|||||||
if idx, err := strconv.Atoi(mm[1]); err == nil {
|
if idx, err := strconv.Atoi(mm[1]); err == nil {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
call := f.spotCall[idx]
|
call := f.spotCall[idx]
|
||||||
|
mode := f.spotMode[idx]
|
||||||
handler := f.OnSpotClick
|
handler := f.OnSpotClick
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
if call != "" && handler != nil {
|
if call != "" && handler != nil {
|
||||||
debugLog.Printf("Flex: spot %d triggered → %s", idx, call)
|
debugLog.Printf("Flex: spot %d triggered → %s (mode %s)", idx, call, mode)
|
||||||
go handler(call, 0)
|
go handler(call, 0)
|
||||||
|
// SmartSDR tunes the spot's frequency on click but does NOT apply
|
||||||
|
// its mode=, so set the slice mode ourselves from what we spotted.
|
||||||
|
if mode != "" {
|
||||||
|
go func(m string) { _ = f.SetMode(m) }(mode)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -379,12 +387,15 @@ func (f *Flex) reader(conn net.Conn) {
|
|||||||
// pair it with the callsign we stashed under this seq.
|
// pair it with the callsign we stashed under this seq.
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
call, pending := f.pendingSpot[seq]
|
call, pending := f.pendingSpot[seq]
|
||||||
|
spotMode := f.pendingSpotMode[seq]
|
||||||
if pending {
|
if pending {
|
||||||
delete(f.pendingSpot, seq)
|
delete(f.pendingSpot, seq)
|
||||||
|
delete(f.pendingSpotMode, seq)
|
||||||
}
|
}
|
||||||
if pending && ok && len(parts) >= 3 {
|
if pending && ok && len(parts) >= 3 {
|
||||||
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
|
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
|
||||||
f.spotCall[idx] = call
|
f.spotCall[idx] = call
|
||||||
|
f.spotMode[idx] = spotMode
|
||||||
f.spotIdx[idx] = true
|
f.spotIdx[idx] = true
|
||||||
f.spotByCall[strings.ToUpper(call)] = idx
|
f.spotByCall[strings.ToUpper(call)] = idx
|
||||||
}
|
}
|
||||||
@@ -801,6 +812,7 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
if removed {
|
if removed {
|
||||||
delete(f.spotIdx, idx)
|
delete(f.spotIdx, idx)
|
||||||
delete(f.spotCall, idx)
|
delete(f.spotCall, idx)
|
||||||
|
delete(f.spotMode, idx)
|
||||||
} else {
|
} else {
|
||||||
f.spotIdx[idx] = true
|
f.spotIdx[idx] = true
|
||||||
}
|
}
|
||||||
@@ -1263,6 +1275,7 @@ func (f *Flex) SendSpot(s SpotInfo) error {
|
|||||||
if hadOld {
|
if hadOld {
|
||||||
delete(f.spotByCall, upperCall)
|
delete(f.spotByCall, upperCall)
|
||||||
delete(f.spotCall, old)
|
delete(f.spotCall, old)
|
||||||
|
delete(f.spotMode, old)
|
||||||
delete(f.spotIdx, old)
|
delete(f.spotIdx, old)
|
||||||
}
|
}
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
@@ -1288,6 +1301,7 @@ func (f *Flex) SendSpot(s SpotInfo) error {
|
|||||||
// (trigger) can be resolved back to the callsign.
|
// (trigger) can be resolved back to the callsign.
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
f.pendingSpot[seq] = s.Callsign
|
f.pendingSpot[seq] = s.Callsign
|
||||||
|
f.pendingSpotMode[seq] = s.Mode
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -1320,6 +1334,7 @@ func (f *Flex) ClearSpots() error {
|
|||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
f.spotIdx = map[int]bool{}
|
f.spotIdx = map[int]bool{}
|
||||||
f.spotCall = map[int]string{}
|
f.spotCall = map[int]string{}
|
||||||
|
f.spotMode = map[int]string{}
|
||||||
f.spotByCall = map[string]int{}
|
f.spotByCall = map[string]int{}
|
||||||
connected := f.conn != nil
|
connected := f.conn != nil
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
|
|||||||
+70
-11
@@ -56,6 +56,12 @@ type Kenwood struct {
|
|||||||
// a wire, which is how most operators actually put a rig on the network.
|
// a wire, which is how most operators actually put a rig on the network.
|
||||||
host string
|
host string
|
||||||
digital string // mode name logged for data (FT8 by default)
|
digital string // mode name logged for data (FT8 by default)
|
||||||
|
// dataMode chooses what CAT mode a DATA/digital mode (FT8, PSK…) sets, because
|
||||||
|
// no single Kenwood mode digit is right for every rig: "usb" → USB (the SSB-data
|
||||||
|
// base, default), "data" → the DATA mode digit MD6 (Elecraft K3/K4), "keep" →
|
||||||
|
// leave the rig's current mode untouched (safest for a TS-590SG/TS-990S whose
|
||||||
|
// data mode is a USB modifier the operator sets on the rig). See SetMode.
|
||||||
|
dataMode string
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
port serial.Port
|
port serial.Port
|
||||||
@@ -70,6 +76,15 @@ type Kenwood struct {
|
|||||||
curFreq int64
|
curFreq int64
|
||||||
curRXFreq int64
|
curRXFreq int64
|
||||||
curVFO string // "A" or "B"
|
curVFO string // "A" or "B"
|
||||||
|
// Split is confirmed with FR;/FT; because IF's split bit is empty on some
|
||||||
|
// Kenwood-dialect rigs — but that check is THROTTLED so it doesn't run every
|
||||||
|
// poll: the two extra commands tripled the poll time on a slow K3 and made the
|
||||||
|
// frequency (read from IF at the top of the poll) lag by seconds. Between
|
||||||
|
// checks the last result stands.
|
||||||
|
splitCheckAt time.Time
|
||||||
|
splitCached bool
|
||||||
|
splitVFOCached string
|
||||||
|
keyWPM int // CW keyer speed, for pacing the KY buffer (see kenwood_cw.go)
|
||||||
// Commands this rig answered "?;" to — asked once, then never again.
|
// Commands this rig answered "?;" to — asked once, then never again.
|
||||||
unsupported map[string]bool
|
unsupported map[string]bool
|
||||||
|
|
||||||
@@ -269,18 +284,31 @@ func (k *Kenwood) ReadState() (RigState, error) {
|
|||||||
// this costs two short commands per poll only where it actually works.
|
// this costs two short commands per poll only where it actually works.
|
||||||
split := f.Split
|
split := f.Split
|
||||||
if !split {
|
if !split {
|
||||||
|
// Confirm with FR;/FT; only once a second, not every poll. Running the two
|
||||||
|
// extra commands each cycle tripled the poll time on a slow K3, so the
|
||||||
|
// frequency — already read from IF above — only surfaced every couple of
|
||||||
|
// seconds. Between checks the last FR/FT result stands.
|
||||||
|
if time.Since(k.splitCheckAt) >= time.Second {
|
||||||
|
k.splitCheckAt = time.Now()
|
||||||
|
k.splitCached, k.splitVFOCached = false, ""
|
||||||
rxv, rxOK := k.askVFO("FR;")
|
rxv, rxOK := k.askVFO("FR;")
|
||||||
txv, txOK := k.askVFO("FT;")
|
txv, txOK := k.askVFO("FT;")
|
||||||
if rxOK && txOK && rxv != txv {
|
if rxOK && txOK && rxv != txv {
|
||||||
split = true
|
k.splitCached = true
|
||||||
// Trust FR over IF for which VFO is in use: they were asked in the
|
// Trust FR over IF for which VFO is in use: they were asked in the
|
||||||
// same breath, and a rig that leaves the split bit empty may be just
|
// same breath, and a rig that leaves the split bit empty may be just
|
||||||
// as vague about the VFO field.
|
// as vague about the VFO field. The block below picks the TRANSMIT
|
||||||
// f.VFO too, not just the reported state: the block below picks the
|
// VFO as "the other one" from f.VFO, so leaving them disagreeing
|
||||||
// TRANSMIT VFO as "the other one" from f.VFO, and leaving the two
|
// would read the transmit frequency off the wrong dial.
|
||||||
// disagreeing would read the transmit frequency off the wrong dial.
|
|
||||||
if rxv == "A" || rxv == "B" {
|
if rxv == "A" || rxv == "B" {
|
||||||
k.curVFO, s.Vfo, f.VFO = rxv, rxv, rxv
|
k.splitVFOCached = rxv
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if k.splitCached {
|
||||||
|
split = true
|
||||||
|
if k.splitVFOCached != "" {
|
||||||
|
k.curVFO, s.Vfo, f.VFO = k.splitVFOCached, k.splitVFOCached, k.splitVFOCached
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -338,6 +366,17 @@ func (k *Kenwood) SetMode(mode string) error {
|
|||||||
if k.port == nil {
|
if k.port == nil {
|
||||||
return fmt.Errorf("kenwood: not connected")
|
return fmt.Errorf("kenwood: not connected")
|
||||||
}
|
}
|
||||||
|
// Honour the operator's DATA-mode preference for a digital mode. No single
|
||||||
|
// mode digit fits every rig, so the operator picks: keep the rig's mode, use
|
||||||
|
// MD6 (K3/K4 DATA), or fall through to the default USB from kenwoodModeDigit.
|
||||||
|
if isKenwoodDataMode(mode) {
|
||||||
|
switch k.dataMode {
|
||||||
|
case "keep":
|
||||||
|
return nil // leave whatever data mode the operator set on the rig
|
||||||
|
case "data":
|
||||||
|
return k.write("MD6;") // Elecraft K3/K4 DATA mode
|
||||||
|
}
|
||||||
|
}
|
||||||
d := kenwoodModeDigit(mode, k.curFreq)
|
d := kenwoodModeDigit(mode, k.curFreq)
|
||||||
if d == 0 {
|
if d == 0 {
|
||||||
return fmt.Errorf("kenwood: no CAT mode for %q", mode)
|
return fmt.Errorf("kenwood: no CAT mode for %q", mode)
|
||||||
@@ -345,6 +384,25 @@ func (k *Kenwood) SetMode(mode string) error {
|
|||||||
return k.write(fmt.Sprintf("MD%c;", d))
|
return k.write(fmt.Sprintf("MD%c;", d))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetDataMode configures how a DATA/digital mode is set: "usb" (default), "data"
|
||||||
|
// (MD6, Elecraft K3/K4) or "keep" (don't change the rig's mode).
|
||||||
|
func (k *Kenwood) SetDataMode(m string) {
|
||||||
|
k.mu.Lock()
|
||||||
|
k.dataMode = strings.ToLower(strings.TrimSpace(m))
|
||||||
|
k.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// isKenwoodDataMode reports whether a mode name is a soundcard/data mode (FT8,
|
||||||
|
// PSK, JT…) rather than a voice/CW/RTTY mode the rig sets natively.
|
||||||
|
func isKenwoodDataMode(mode string) bool {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||||
|
case "", "LSB", "USB", "SSB", "CW", "CW-R", "CWR", "FM", "NFM", "AM",
|
||||||
|
"RTTY", "FSK", "RTTY-R", "FSK-R":
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (k *Kenwood) SetPTT(on bool) error {
|
func (k *Kenwood) SetPTT(on bool) error {
|
||||||
k.mu.Lock()
|
k.mu.Lock()
|
||||||
defer k.mu.Unlock()
|
defer k.mu.Unlock()
|
||||||
@@ -529,11 +587,12 @@ func kenwoodModeDigit(mode string, hz int64) byte {
|
|||||||
}
|
}
|
||||||
return '2'
|
return '2'
|
||||||
}
|
}
|
||||||
// Any other digital mode rides on the data sideband, which on Kenwood is
|
// Any other digital mode (FT8, PSK, JT…) → USB on every band. There is NO single
|
||||||
// plain USB/LSB with the rig's DATA input selected.
|
// mode digit that is right for both families: a K3 wants DATA (MD6), a TS-590SG
|
||||||
if hz > 0 && hz < 10_000_000 {
|
// wants USB-DATA (MD2 + its DATA modifier), and MD6 on the TS-590SG is FSK/RTTY
|
||||||
return '1'
|
// — wrong for FT8. USB is the safe common base (the reversed-sideband "DATA REV"
|
||||||
}
|
// only came from the old LSB-below-10-MHz rule). A rig-specific DATA mode is a
|
||||||
|
// follow-up that needs the model, not a blind default.
|
||||||
return '2'
|
return '2'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
// CW keying through the Kenwood/Elecraft keyer — the KY command.
|
||||||
|
//
|
||||||
|
// Same idea as the Yaesu KY engine and the Icom / Flex keyers: the radio holds
|
||||||
|
// the text and keys it with its own timing, so an Elecraft K3 (or any rig that
|
||||||
|
// speaks this dialect) needs NO WinKeyer and NO second COM port — the single CAT
|
||||||
|
// link does frequency, mode AND CW. That matters on a K3, whose one USB port is
|
||||||
|
// the CAT port; a separate serial keyer would need a second cable OpsLog can't
|
||||||
|
// give it.
|
||||||
|
//
|
||||||
|
// KY; → KYn; n=0 buffer has room, n=1 buffer full
|
||||||
|
// KY <text>; queue up to 24 characters (the space after KY is part
|
||||||
|
// of the command, not padding)
|
||||||
|
// KS nnn; keyer speed in WPM (three digits)
|
||||||
|
// RX; drop to receive — used to abort a send
|
||||||
|
//
|
||||||
|
// KY is the Elecraft-documented CW-over-CAT path on the K3/K4. A rig that refuses
|
||||||
|
// it answers "?;", which SendCW turns into a stated reason rather than silence.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// kenwoodCWChunk is the most characters one KY command accepts.
|
||||||
|
const kenwoodCWChunk = 24
|
||||||
|
|
||||||
|
// kenwoodCWAllowed is what the keyer can send; anything else is dropped, since an
|
||||||
|
// unsupported byte can abort the buffer and lose the rest of the message.
|
||||||
|
const kenwoodCWAllowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 /?.,-=+:;()"
|
||||||
|
|
||||||
|
// SendCW queues a message on the rig's keyer, fed in 24-character pieces, waiting
|
||||||
|
// for buffer room between pieces so a long macro doesn't lose its tail.
|
||||||
|
func (k *Kenwood) SendCW(text string) error {
|
||||||
|
msg := filterKenwoodCW(text)
|
||||||
|
if msg == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
k.mu.Lock()
|
||||||
|
defer k.mu.Unlock()
|
||||||
|
if k.port == nil {
|
||||||
|
return fmt.Errorf("kenwood: not connected")
|
||||||
|
}
|
||||||
|
for len(msg) > 0 {
|
||||||
|
n := kenwoodCWChunk
|
||||||
|
if len(msg) < n {
|
||||||
|
n = len(msg)
|
||||||
|
}
|
||||||
|
chunk := msg[:n]
|
||||||
|
msg = msg[n:]
|
||||||
|
k.waitCWBuffer(3 * time.Second)
|
||||||
|
if err := k.write("KY " + chunk + ";"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := k.afterKY(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Pace the next piece by how long this one takes to key, so we never overrun
|
||||||
|
// the 24-character buffer (the rig silently drops what doesn't fit).
|
||||||
|
if len(msg) > 0 {
|
||||||
|
time.Sleep(kenwoodCWDuration(chunk, k.keyerWPM()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// afterKY reads briefly after a KY write. An accepted KY says nothing; a REJECT
|
||||||
|
// answers "?;". Reading it straight off the port (not through the shared rx
|
||||||
|
// buffer) keeps that stray frame from being picked up by the next poll's ask —
|
||||||
|
// which would mis-mark an unrelated command unsupported and desync the link — and
|
||||||
|
// turns a silent non-transmission into a stated reason. The caller holds k.mu.
|
||||||
|
func (k *Kenwood) afterKY() error {
|
||||||
|
deadline := time.Now().Add(150 * time.Millisecond)
|
||||||
|
tmp := make([]byte, 64)
|
||||||
|
var buf []byte
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
n, err := k.port.Read(tmp)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
buf = append(buf, tmp[:n]...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(string(buf), "?;") {
|
||||||
|
return fmt.Errorf("this radio rejected CW over CAT (it answered \"?;\" to KY). " +
|
||||||
|
"Switch the keyer engine to the serial-port keyer (DTR=CW) on a COM port instead")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitCWBuffer blocks until the keyer reports room, or the deadline passes. A rig
|
||||||
|
// that never answers the KY; status query is not a reason to refuse to send — we
|
||||||
|
// go ahead, and the per-chunk pacing covers the worst case. The caller holds k.mu.
|
||||||
|
func (k *Kenwood) waitCWBuffer(within time.Duration) {
|
||||||
|
deadline := time.Now().Add(within)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if k.unsupported["KY"] {
|
||||||
|
return // this rig doesn't report buffer state — pacing covers it
|
||||||
|
}
|
||||||
|
r, err := k.ask("KY;")
|
||||||
|
if err != nil {
|
||||||
|
return // unsupported / timeout — send anyway, pacing covers it
|
||||||
|
}
|
||||||
|
if !kenwoodCWBufferFull(r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// kenwoodCWBufferFull reads the KY; status reply. Deliberately asymmetric: only a
|
||||||
|
// clear "1" after KY means full. Anything else reads as "go ahead" — refusing to
|
||||||
|
// send because a status line was phrased unexpectedly is the worse failure.
|
||||||
|
func kenwoodCWBufferFull(reply string) bool {
|
||||||
|
r := strings.TrimSpace(reply)
|
||||||
|
if !strings.HasPrefix(strings.ToUpper(r), "KY") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, c := range r[2:] {
|
||||||
|
switch c {
|
||||||
|
case '0':
|
||||||
|
return false
|
||||||
|
case '1':
|
||||||
|
return true
|
||||||
|
case ' ', ';':
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopCW aborts the message being sent. Kenwood documents no KY buffer-clear, so
|
||||||
|
// this drops the transmitter — RX; forces receive, which is what an operator
|
||||||
|
// pressing Escape wants; anything still queued is not keyed on the air.
|
||||||
|
func (k *Kenwood) StopCW() error {
|
||||||
|
k.mu.Lock()
|
||||||
|
defer k.mu.Unlock()
|
||||||
|
if k.port == nil {
|
||||||
|
return fmt.Errorf("kenwood: not connected")
|
||||||
|
}
|
||||||
|
return k.write("RX;")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetKeySpeed sets the keyer speed (WPM) via KS and remembers it for pacing.
|
||||||
|
func (k *Kenwood) SetKeySpeed(wpm int) error {
|
||||||
|
if wpm < 4 {
|
||||||
|
wpm = 4
|
||||||
|
}
|
||||||
|
if wpm > 99 {
|
||||||
|
wpm = 99 // KS is three digits but the K3 keyer tops out well below 100
|
||||||
|
}
|
||||||
|
k.mu.Lock()
|
||||||
|
defer k.mu.Unlock()
|
||||||
|
k.keyWPM = wpm
|
||||||
|
if k.port == nil {
|
||||||
|
return fmt.Errorf("kenwood: not connected")
|
||||||
|
}
|
||||||
|
return k.write(fmt.Sprintf("KS%03d;", wpm))
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyerWPM is the speed to pace the buffer by. The caller holds k.mu.
|
||||||
|
func (k *Kenwood) keyerWPM() int {
|
||||||
|
if k.keyWPM >= 4 {
|
||||||
|
return k.keyWPM
|
||||||
|
}
|
||||||
|
return 20
|
||||||
|
}
|
||||||
|
|
||||||
|
// kenwoodCWDuration estimates how long a piece of text takes to key (PARIS
|
||||||
|
// timing: a character averages 10 dits, a dit is 1.2/wpm seconds).
|
||||||
|
func kenwoodCWDuration(text string, wpm int) time.Duration {
|
||||||
|
if wpm < 4 {
|
||||||
|
wpm = 20
|
||||||
|
}
|
||||||
|
ditMs := 1200.0 / float64(wpm)
|
||||||
|
return time.Duration(float64(len(text))*10*ditMs) * time.Millisecond
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterKenwoodCW upper-cases and strips what the keyer cannot send; whitespace
|
||||||
|
// becomes a single word gap.
|
||||||
|
func filterKenwoodCW(text string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range strings.ToUpper(text) {
|
||||||
|
if r == '\t' || r == '\n' || r == '\r' {
|
||||||
|
b.WriteByte(' ')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.ContainsRune(kenwoodCWAllowed, r) {
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(strings.Fields(b.String()), " ")
|
||||||
|
}
|
||||||
@@ -300,6 +300,11 @@ func TestKenwoodSplitFromFRFT(t *testing.T) {
|
|||||||
|
|
||||||
// Split on, IF still silent about it: receive on A, transmit on B.
|
// Split on, IF still silent about it: receive on A, transmit on B.
|
||||||
rig.split = true
|
rig.split = true
|
||||||
|
// The FR/FT split re-check is throttled to once a second (it tripled the poll
|
||||||
|
// time on a slow K3), so back-to-back reads reuse the last result. Simulate the
|
||||||
|
// recheck window having elapsed — in the field the 250 ms poll covers it inside
|
||||||
|
// a second.
|
||||||
|
k.splitCheckAt = time.Time{}
|
||||||
if s, err = k.ReadState(); err != nil {
|
if s, err = k.ReadState(); err != nil {
|
||||||
t.Fatalf("read: %v", err)
|
t.Fatalf("read: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,8 +100,8 @@ func TestKenwoodModeDigit(t *testing.T) {
|
|||||||
{"RTTY", 14080000, '6'},
|
{"RTTY", 14080000, '6'},
|
||||||
{"AM", 7150000, '5'},
|
{"AM", 7150000, '5'},
|
||||||
{"FM", 145000000, '4'},
|
{"FM", 145000000, '4'},
|
||||||
{"FT8", 7074000, '1'}, // data rides on the sideband for the band
|
{"FT8", 7074000, '2'}, // data is ALWAYS USB, even below 10 MHz (K3 "DATA REV" otherwise)
|
||||||
{"FT8", 14074000, '2'},
|
{"FT8", 14074000, '2'}, // …and above
|
||||||
{"", 14074000, 0}, // nothing to set
|
{"", 14074000, 0}, // nothing to set
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
@@ -111,6 +111,23 @@ func TestKenwoodModeDigit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The DATA-mode preference only applies to soundcard/data modes, never to voice,
|
||||||
|
// CW or native RTTY — getting this wrong would hijack an SSB or CW mode change.
|
||||||
|
func TestIsKenwoodDataMode(t *testing.T) {
|
||||||
|
data := []string{"FT8", "FT4", "PSK31", "JT65", "DATA", "DIGITAL", "OLIVIA"}
|
||||||
|
notData := []string{"", "LSB", "USB", "SSB", "CW", "CW-R", "FM", "AM", "RTTY", "FSK", "RTTY-R"}
|
||||||
|
for _, m := range data {
|
||||||
|
if !isKenwoodDataMode(m) {
|
||||||
|
t.Errorf("isKenwoodDataMode(%q) = false, want true", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, m := range notData {
|
||||||
|
if isKenwoodDataMode(m) {
|
||||||
|
t.Errorf("isKenwoodDataMode(%q) = true, want false", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// What the log records for each mode digit the rig reports.
|
// What the log records for each mode digit the rig reports.
|
||||||
func TestKenwoodModeToADIF(t *testing.T) {
|
func TestKenwoodModeToADIF(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
|
|||||||
+71
-6
@@ -28,6 +28,12 @@ type TCI struct {
|
|||||||
digitalDefault string // surfaced when the rig reports a digital mode (FT8/…)
|
digitalDefault string // surfaced when the rig reports a digital mode (FT8/…)
|
||||||
spotsEnabled bool // mirror cluster spots onto the TCI panorama
|
spotsEnabled bool // mirror cluster spots onto the TCI panorama
|
||||||
|
|
||||||
|
// OnSpotClick is called when the user clicks one of our spots on the TCI
|
||||||
|
// panorama (callsign + freq), so the host can fill the entry form. Set before
|
||||||
|
// Connect. Mirrors the FlexRadio panadapter-click flow.
|
||||||
|
OnSpotClick func(callsign string, freqHz int64)
|
||||||
|
unhandledSeen map[string]bool // log each unknown TCI message type once
|
||||||
|
|
||||||
mu sync.Mutex // guards conn + writes + state
|
mu sync.Mutex // guards conn + writes + state
|
||||||
conn *websocket.Conn
|
conn *websocket.Conn
|
||||||
dialCancel context.CancelFunc // cancels an in-flight Connect dial (Interrupt/Stop)
|
dialCancel context.CancelFunc // cancels an in-flight Connect dial (Interrupt/Stop)
|
||||||
@@ -110,15 +116,32 @@ func (t *TCI) SendSpot(s SpotInfo) error {
|
|||||||
if call == "" || s.FreqHz <= 0 {
|
if call == "" || s.FreqHz <= 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
color := strings.TrimSpace(s.Color)
|
// TCI's SPOT command wants the colour as a signed 32-bit DECIMAL integer in
|
||||||
if color == "" {
|
// 0xAARRGGBB order — NOT a "0x…" hex string (e.g. "spot:UN7GK,cw,14025000,
|
||||||
color = "#FFFFA500" // opaque orange default
|
// -16776961,test;"). ExpertSDR silently drops a spot whose colour field it
|
||||||
|
// can't parse as a number, which is why spots never showed on the panorama
|
||||||
|
// while tuning (a separate command) still worked.
|
||||||
|
hex := strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(s.Color), "#"), "0x")
|
||||||
|
if hex == "" {
|
||||||
|
hex = "FFFFA500" // opaque orange default
|
||||||
|
}
|
||||||
|
if len(hex) == 6 {
|
||||||
|
hex = "FF" + hex // add full-opacity alpha when only RGB was supplied
|
||||||
|
}
|
||||||
|
argb, err := strconv.ParseUint(hex, 16, 32)
|
||||||
|
if err != nil {
|
||||||
|
argb = 0xFFFFA500
|
||||||
|
}
|
||||||
|
// Use a valid TCI modulation (usb/lsb/cw/digl…) so ExpertSDR accepts the spot;
|
||||||
|
// fall back to the raw label if we can't map it. The click-to-tune path already
|
||||||
|
// maps the mode separately, so this only affects the spot's displayed mode.
|
||||||
|
mode := adifToTCIMode(s.Mode, s.FreqHz)
|
||||||
|
if mode == "" {
|
||||||
|
mode = strings.ToLower(strings.TrimSpace(s.Mode))
|
||||||
}
|
}
|
||||||
color = "0x" + strings.TrimPrefix(color, "#")
|
|
||||||
mode := strings.ToLower(strings.TrimSpace(s.Mode))
|
|
||||||
// Commas/semicolons would break TCI's comma-separated argument parsing.
|
// Commas/semicolons would break TCI's comma-separated argument parsing.
|
||||||
text := strings.NewReplacer(",", " ", ";", " ").Replace(s.Comment)
|
text := strings.NewReplacer(",", " ", ";", " ").Replace(s.Comment)
|
||||||
return t.send(fmt.Sprintf("spot:%s,%s,%d,%s,%s;", call, mode, s.FreqHz, color, text))
|
return t.send(fmt.Sprintf("spot:%s,%s,%d,%d,%s;", call, mode, s.FreqHz, int32(argb), text))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disconnect closes the WebSocket; the reader goroutine then exits.
|
// Disconnect closes the WebSocket; the reader goroutine then exits.
|
||||||
@@ -294,6 +317,48 @@ func (t *TCI) handle(msg string) {
|
|||||||
if get(0) == "0" {
|
if get(0) == "0" {
|
||||||
t.tx = get(1) == "true"
|
t.tx = get(1) == "true"
|
||||||
}
|
}
|
||||||
|
default:
|
||||||
|
lname := strings.ToLower(name)
|
||||||
|
// A click on one of our panorama spots comes back as
|
||||||
|
// CLICKED_ON_SPOT:<call>,<hz> (legacy)
|
||||||
|
// RX_CLICKED_ON_SPOT:<rx>,<ch>,<call>,<hz>
|
||||||
|
// Neither name starts with "spot", which is why the click was silently
|
||||||
|
// ignored before. Read the callsign (the one non-numeric field) and the
|
||||||
|
// frequency (the large numeric field) positionally-independently, so both
|
||||||
|
// shapes work without depending on the exact arg order.
|
||||||
|
if strings.Contains(lname, "spot") {
|
||||||
|
var call string
|
||||||
|
var hz int64
|
||||||
|
for _, raw := range f {
|
||||||
|
v := strings.TrimSpace(raw)
|
||||||
|
if v == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||||
|
if n >= 10000 { // a real frequency, not an rx/channel index
|
||||||
|
hz = n
|
||||||
|
}
|
||||||
|
} else if call == "" {
|
||||||
|
call = strings.ToUpper(v) // callsigns always carry letters
|
||||||
|
}
|
||||||
|
}
|
||||||
|
debugLog.Printf("TCI: spot click %q → call=%s freq=%d", msg, call, hz)
|
||||||
|
if call != "" && t.OnSpotClick != nil {
|
||||||
|
cb := t.OnSpotClick
|
||||||
|
go cb(call, hz)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Log every OTHER unknown message TYPE once, so the protocol (incl. any
|
||||||
|
// spot-click notification named differently) is discoverable from the log
|
||||||
|
// without flooding it with the frequent streamed messages.
|
||||||
|
if t.unhandledSeen == nil {
|
||||||
|
t.unhandledSeen = map[string]bool{}
|
||||||
|
}
|
||||||
|
if !t.unhandledSeen[lname] {
|
||||||
|
t.unhandledSeen[lname] = true
|
||||||
|
debugLog.Printf("TCI: (unhandled once) %s", msg)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,13 @@ const (
|
|||||||
defaultPort = 9008
|
defaultPort = 9008
|
||||||
dialTimeout = 5 * time.Second
|
dialTimeout = 5 * time.Second
|
||||||
ioTimeout = 3 * time.Second
|
ioTimeout = 3 * time.Second
|
||||||
pollEvery = 1500 * time.Millisecond
|
// Poll fast enough that the amp's OWN forward/current figures make a usable
|
||||||
|
// live meter on their own — the UI prefers them over the FlexRadio VITA stream
|
||||||
|
// (which never traverses a public-IP/NAT link), so this direct reading is what
|
||||||
|
// an operator watches when running the amp over the internet. At 250 ms the
|
||||||
|
// SSB envelope is sampled often enough that peak-hold keeps a steady reading
|
||||||
|
// instead of collapsing to ~1 W in the gaps between syllables.
|
||||||
|
pollEvery = 250 * time.Millisecond
|
||||||
reconnectDelay = 2 * time.Second
|
reconnectDelay = 2 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -52,6 +58,7 @@ type Status struct {
|
|||||||
type Client struct {
|
type Client struct {
|
||||||
host string
|
host string
|
||||||
port int
|
port int
|
||||||
|
password string // remote-access code; sent as "auth <code>" when the banner announces AUTH
|
||||||
|
|
||||||
mu sync.Mutex // serialises command send/recv on the connection
|
mu sync.Mutex // serialises command send/recv on the connection
|
||||||
conn net.Conn
|
conn net.Conn
|
||||||
@@ -70,11 +77,11 @@ type Client struct {
|
|||||||
running bool
|
running bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(host string, port int) *Client {
|
func New(host string, port int, password string) *Client {
|
||||||
if port <= 0 || port > 65535 {
|
if port <= 0 || port > 65535 {
|
||||||
port = defaultPort
|
port = defaultPort
|
||||||
}
|
}
|
||||||
return &Client{host: host, port: port, stop: make(chan struct{}), status: Status{Host: host}}
|
return &Client{host: host, port: port, password: strings.TrimSpace(password), stop: make(chan struct{}), status: Status{Host: host}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Start() error {
|
func (c *Client) Start() error {
|
||||||
@@ -118,9 +125,15 @@ func (c *Client) SetFanMode(mode string) error {
|
|||||||
default:
|
default:
|
||||||
return fmt.Errorf("powergenius: invalid fan mode %q", mode)
|
return fmt.Errorf("powergenius: invalid fan mode %q", mode)
|
||||||
}
|
}
|
||||||
if _, err := c.command("setup fanmode=" + m); err != nil {
|
// Set with the bare "key=value" verb the amp uses for its own status fields
|
||||||
|
// (same convention as "operate=1"). The earlier "setup fanmode=…" carried a
|
||||||
|
// bogus prefix the amp silently ignored, so the fan never changed and the next
|
||||||
|
// status kept reporting the old mode — the revert-to-Contest the operator saw.
|
||||||
|
reply, err := c.command("fanmode=" + m)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
applog.Printf("pgxl: set fanmode=%s reply=%q", m, reply)
|
||||||
c.statusMu.Lock()
|
c.statusMu.Lock()
|
||||||
c.status.FanMode = m // optimistic
|
c.status.FanMode = m // optimistic
|
||||||
c.fanPending, c.fanPendingAt = m, time.Now()
|
c.fanPending, c.fanPendingAt = m, time.Now()
|
||||||
@@ -177,12 +190,62 @@ func (c *Client) ensureConnected() error {
|
|||||||
}
|
}
|
||||||
c.conn = conn
|
c.conn = conn
|
||||||
c.reader = bufio.NewReader(conn)
|
c.reader = bufio.NewReader(conn)
|
||||||
// Discard the version banner the device sends on connect.
|
// Banner: "V…" (LAN) or "V… AUTH" (remote → authentication required, exactly
|
||||||
|
// like the Tuner Genius / Antenna Genius). Send the remote code when the amp
|
||||||
|
// demands it, otherwise every command comes back "Unauthorized".
|
||||||
_ = conn.SetReadDeadline(time.Now().Add(ioTimeout))
|
_ = conn.SetReadDeadline(time.Now().Add(ioTimeout))
|
||||||
_, _ = c.reader.ReadString('\n')
|
banner, _ := c.reader.ReadString('\n')
|
||||||
|
banner = strings.TrimSpace(banner)
|
||||||
|
applog.Printf("pgxl: connected %s → %s, banner=%q", conn.LocalAddr(), conn.RemoteAddr(), banner)
|
||||||
|
if strings.Contains(banner, "AUTH") {
|
||||||
|
if c.password == "" {
|
||||||
|
applog.Printf("pgxl: device requires AUTH but no remote code set (Settings → Amplifier)")
|
||||||
|
} else if err := c.authLocked(); err != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
c.conn, c.reader = nil, nil
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// authLocked authenticates the remote link. Must be called with c.mu held
|
||||||
|
// (during ensureConnected). 4O3A boxes want "auth code=<pw>" and reply
|
||||||
|
// "R<seq>|<hex>|" with an EMPTY message — hex "0" means accepted, so the response
|
||||||
|
// CODE decides, not the text. The device also rejects the FIRST attempt (R|FF)
|
||||||
|
// and accepts a retry, so resend a few times before giving up.
|
||||||
|
func (c *Client) authLocked() error {
|
||||||
|
var lastHex string
|
||||||
|
for try := 1; try <= 4; try++ {
|
||||||
|
id := c.cmdID.Add(1)
|
||||||
|
_ = c.conn.SetWriteDeadline(time.Now().Add(ioTimeout))
|
||||||
|
if _, err := fmt.Fprintf(c.conn, "C%d|auth code=%s\n", id, c.password); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = c.conn.SetReadDeadline(time.Now().Add(ioTimeout))
|
||||||
|
line, err := c.reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
applog.Printf("pgxl: auth reply=%q (try %d)", line, try)
|
||||||
|
hex, msg := "", ""
|
||||||
|
if p := strings.SplitN(line, "|", 3); len(p) >= 2 {
|
||||||
|
hex = strings.TrimSpace(p[1])
|
||||||
|
if len(p) == 3 {
|
||||||
|
msg = strings.TrimSpace(p[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// hex "0" is the standard accept; also treat an explicit OK message as success.
|
||||||
|
if hex == "0" || (msg != "" && strings.Contains(strings.ToLower(msg), "ok")) {
|
||||||
|
applog.Printf("pgxl: authenticated")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
lastHex = hex
|
||||||
|
}
|
||||||
|
return fmt.Errorf("powergenius: authentication failed after 4 tries (R|%s|) — check the remote code", lastHex)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) dropConn() {
|
func (c *Client) dropConn() {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
if c.conn != nil {
|
if c.conn != nil {
|
||||||
@@ -237,9 +300,10 @@ func (c *Client) parse(resp string) {
|
|||||||
c.statusMu.Lock()
|
c.statusMu.Lock()
|
||||||
c.status.Connected = true
|
c.status.Connected = true
|
||||||
c.status.LastError = ""
|
c.status.LastError = ""
|
||||||
// One raw frame per session is enough to learn the field set — the frames
|
// Log the first REAL status frame (one that carries "key=value" fields) so the
|
||||||
// carry live meter values, so "log on change" logged every frame.
|
// field set is visible — even if an earlier reply was junk like "Unauthorized"
|
||||||
if c.lastRaw == "" {
|
// (which would otherwise latch lastRaw and hide the real frame).
|
||||||
|
if strings.Contains(data, "=") && !strings.Contains(c.lastRaw, "=") {
|
||||||
c.lastRaw = data
|
c.lastRaw = data
|
||||||
applog.Printf("pgxl: status raw=%q", data)
|
applog.Printf("pgxl: status raw=%q", data)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,8 +59,28 @@ type Server struct {
|
|||||||
ln net.Listener
|
ln net.Listener
|
||||||
conns map[net.Conn]struct{}
|
conns map[net.Conn]struct{}
|
||||||
closed bool
|
closed bool
|
||||||
|
|
||||||
|
// Optimistic frequency echo. A sharing client that follows our dial by
|
||||||
|
// polling get_freq (WSJT-X / JTDX in "Fake It" split) shifts the frequency on
|
||||||
|
// TX and restores it on RX. Freq() is the last value POLLED from the rig, and
|
||||||
|
// it lags a set_freq by up to a poll cycle (~100-200 ms). In that window the
|
||||||
|
// client — no longer transmitting — reads back the still-shifted frequency,
|
||||||
|
// mistakes it for a manual QSY and adopts it, so every over creeps the dial by
|
||||||
|
// the shift amount and it never comes back. Echoing the last commanded
|
||||||
|
// frequency until the rig confirms it (or a short deadline passes) closes the
|
||||||
|
// window: the client always reads exactly what it just set.
|
||||||
|
echoMu sync.Mutex
|
||||||
|
echoHz int64
|
||||||
|
echoAt time.Time
|
||||||
|
echoWant bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// freqEchoTTL caps how long a commanded frequency is echoed when the rig never
|
||||||
|
// reports it back (e.g. it rounded to a coarser step). Long enough to cover a
|
||||||
|
// poll cycle with margin, short enough that a genuine knob turn during the
|
||||||
|
// window surfaces quickly.
|
||||||
|
const freqEchoTTL = 2 * time.Second
|
||||||
|
|
||||||
func New(port int, rig Rig, logf func(string, ...any)) *Server {
|
func New(port int, rig Rig, logf func(string, ...any)) *Server {
|
||||||
if port <= 0 || port > 65535 {
|
if port <= 0 || port > 65535 {
|
||||||
port = 4532 // the rigctld default every client pre-fills
|
port = 4532 // the rigctld default every client pre-fills
|
||||||
@@ -193,7 +213,7 @@ func (s *Server) handle(line string) (resp string, quit bool) {
|
|||||||
return "", true
|
return "", true
|
||||||
|
|
||||||
case "f", "\\get_freq":
|
case "f", "\\get_freq":
|
||||||
return fmt.Sprintf("%d\n", s.rig.Freq()), false
|
return fmt.Sprintf("%d\n", s.reportedFreq()), false
|
||||||
case "F", "\\set_freq":
|
case "F", "\\set_freq":
|
||||||
if len(args) < 1 {
|
if len(args) < 1 {
|
||||||
return rprt(-1), false
|
return rprt(-1), false
|
||||||
@@ -210,6 +230,7 @@ func (s *Server) handle(line string) (resp string, quit bool) {
|
|||||||
s.log("rigctld: set_freq %d failed: %v", hz, err)
|
s.log("rigctld: set_freq %d failed: %v", hz, err)
|
||||||
return rprt(-9), false
|
return rprt(-9), false
|
||||||
}
|
}
|
||||||
|
s.noteSetFreq(hz)
|
||||||
return rprt(0), false
|
return rprt(0), false
|
||||||
|
|
||||||
case "m", "\\get_mode":
|
case "m", "\\get_mode":
|
||||||
@@ -263,7 +284,7 @@ func (s *Server) handle(line string) (resp string, quit bool) {
|
|||||||
case "i", "\\get_split_freq":
|
case "i", "\\get_split_freq":
|
||||||
_, tx := s.rig.Split()
|
_, tx := s.rig.Split()
|
||||||
if tx <= 0 {
|
if tx <= 0 {
|
||||||
tx = s.rig.Freq()
|
tx = s.reportedFreq()
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%d\n", tx), false
|
return fmt.Sprintf("%d\n", tx), false
|
||||||
case "I", "\\set_split_freq":
|
case "I", "\\set_split_freq":
|
||||||
@@ -279,6 +300,30 @@ func (s *Server) handle(line string) (resp string, quit bool) {
|
|||||||
|
|
||||||
func rprt(code int) string { return fmt.Sprintf("RPRT %d\n", code) }
|
func rprt(code int) string { return fmt.Sprintf("RPRT %d\n", code) }
|
||||||
|
|
||||||
|
// noteSetFreq records a frequency a client just commanded, so the next reads
|
||||||
|
// echo it back until the rig confirms the tune.
|
||||||
|
func (s *Server) noteSetFreq(hz int64) {
|
||||||
|
s.echoMu.Lock()
|
||||||
|
s.echoHz, s.echoAt, s.echoWant = hz, time.Now(), true
|
||||||
|
s.echoMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// reportedFreq is what get_freq answers: the last commanded frequency while the
|
||||||
|
// rig is still catching up to it, otherwise the live polled value. See echoWant.
|
||||||
|
func (s *Server) reportedFreq() int64 {
|
||||||
|
live := s.rig.Freq()
|
||||||
|
s.echoMu.Lock()
|
||||||
|
defer s.echoMu.Unlock()
|
||||||
|
if s.echoWant {
|
||||||
|
if live == s.echoHz || time.Since(s.echoAt) > freqEchoTTL {
|
||||||
|
s.echoWant = false // rig confirmed the tune, or we waited long enough
|
||||||
|
return live
|
||||||
|
}
|
||||||
|
return s.echoHz
|
||||||
|
}
|
||||||
|
return live
|
||||||
|
}
|
||||||
|
|
||||||
// stripVFOArg drops a leading VFO name from a command's arguments.
|
// stripVFOArg drops a leading VFO name from a command's arguments.
|
||||||
//
|
//
|
||||||
// Hamlib has two dialects. In the plain one a client sends "F 14074000"; in VFO
|
// Hamlib has two dialects. In the plain one a client sends "F 14074000"; in VFO
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type fakeRig struct {
|
|||||||
setFreqs []int64
|
setFreqs []int64
|
||||||
setModes []string
|
setModes []string
|
||||||
failSet bool
|
failSet bool
|
||||||
|
lagSet bool // record the command but do not move freq — simulate poll lag
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeRig) Freq() int64 { f.mu.Lock(); defer f.mu.Unlock(); return f.freq }
|
func (f *fakeRig) Freq() int64 { f.mu.Lock(); defer f.mu.Unlock(); return f.freq }
|
||||||
@@ -31,8 +32,10 @@ func (f *fakeRig) SetFreq(hz int64) error {
|
|||||||
if f.failSet {
|
if f.failSet {
|
||||||
return fmt.Errorf("rig refused")
|
return fmt.Errorf("rig refused")
|
||||||
}
|
}
|
||||||
f.freq = hz
|
|
||||||
f.setFreqs = append(f.setFreqs, hz)
|
f.setFreqs = append(f.setFreqs, hz)
|
||||||
|
if !f.lagSet {
|
||||||
|
f.freq = hz
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
func (f *fakeRig) SetMode(m string) error {
|
func (f *fakeRig) SetMode(m string) error {
|
||||||
@@ -138,6 +141,60 @@ func TestHandleReportsBackendFailure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The FT8 "Fake It" drift. JTDX/WSJT-X shift the dial down on TX and restore it
|
||||||
|
// on RX, and they follow the dial by polling get_freq. Our Freq() is the last
|
||||||
|
// POLLED value and lags a set_freq by a poll cycle, so right after the restore
|
||||||
|
// the client used to read the still-shifted frequency, take it for a manual QSY
|
||||||
|
// and adopt it — the dial crept down every over and never came back. get_freq
|
||||||
|
// now echoes the last commanded frequency until the rig confirms it.
|
||||||
|
func TestFakeItSplitDoesNotDriftTheDial(t *testing.T) {
|
||||||
|
// lagSet: SetFreq only records the command; we drive the poll catch-up by hand
|
||||||
|
// to land inside the exact window the drift lived in.
|
||||||
|
rig := &fakeRig{freq: 14074000, lagSet: true}
|
||||||
|
s := New(0, rig, nil)
|
||||||
|
|
||||||
|
if got, _ := s.handle("f"); got != "14074000\n" {
|
||||||
|
t.Fatalf("baseline get_freq = %q, want 14074000", got)
|
||||||
|
}
|
||||||
|
setFreq := func(hz int64) { rig.mu.Lock(); rig.freq = hz; rig.mu.Unlock() }
|
||||||
|
|
||||||
|
for cycle := 0; cycle < 5; cycle++ {
|
||||||
|
// TX: the client shifts the dial down for the over.
|
||||||
|
if got, _ := s.handle("F 14073500"); got != "RPRT 0\n" {
|
||||||
|
t.Fatalf("cycle %d TX set_freq = %q", cycle, got)
|
||||||
|
}
|
||||||
|
// Mid-TX, before the rig reports the move, the client reads back exactly
|
||||||
|
// what it commanded — not the stale 14074000.
|
||||||
|
if got, _ := s.handle("f"); got != "14073500\n" {
|
||||||
|
t.Fatalf("cycle %d TX get_freq = %q, want commanded 14073500", cycle, got)
|
||||||
|
}
|
||||||
|
setFreq(14073500) // poll catches up to the shifted dial
|
||||||
|
|
||||||
|
// RX: the client restores the dial.
|
||||||
|
if got, _ := s.handle("F 14074000"); got != "RPRT 0\n" {
|
||||||
|
t.Fatalf("cycle %d RX set_freq = %q", cycle, got)
|
||||||
|
}
|
||||||
|
// The rig has not yet reported the restore (still 14073500). Before the fix
|
||||||
|
// this returned 14073500 and JTDX adopted it — the cumulative drift. Now it
|
||||||
|
// echoes the restore, so the dial holds.
|
||||||
|
if got, _ := s.handle("f"); got != "14074000\n" {
|
||||||
|
t.Fatalf("cycle %d RX get_freq = %q, want restored 14074000 — dial drifted", cycle, got)
|
||||||
|
}
|
||||||
|
setFreq(14074000) // poll catches up to the restored dial
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once a poll confirms the commanded dial, the echo is released (in the field
|
||||||
|
// the client polls continuously, so this happens within a cycle).
|
||||||
|
if got, _ := s.handle("f"); got != "14074000\n" {
|
||||||
|
t.Fatalf("confirming get_freq = %q, want 14074000", got)
|
||||||
|
}
|
||||||
|
// A genuine knob turn now surfaces at once.
|
||||||
|
setFreq(14075000)
|
||||||
|
if got, _ := s.handle("f"); got != "14075000\n" {
|
||||||
|
t.Errorf("manual QSY get_freq = %q, want 14075000 — echo hid a real move", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// dump_state is parsed POSITIONALLY by Hamlib clients: WSJT-X reads the first
|
// dump_state is parsed POSITIONALLY by Hamlib clients: WSJT-X reads the first
|
||||||
// line as the protocol version and refuses to continue if the block is short or
|
// line as the protocol version and refuses to continue if the block is short or
|
||||||
// misshapen. Pinning its shape is what stops a well-meaning edit from silently
|
// misshapen. Pinning its shape is what stops a well-meaning edit from silently
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ const (
|
|||||||
ubReadTimeout = 4 * time.Second // was 1s — too tight for a remote link
|
ubReadTimeout = 4 * time.Second // was 1s — too tight for a remote link
|
||||||
ubKeepAlive = 15 * time.Second // OS-level TCP keepalive
|
ubKeepAlive = 15 * time.Second // OS-level TCP keepalive
|
||||||
ubMaxPollTimeout = 3 // consecutive read timeouts tolerated before reconnecting
|
ubMaxPollTimeout = 3 // consecutive read timeouts tolerated before reconnecting
|
||||||
|
// How long a just-commanded direction is trusted AFTER the motors have stopped
|
||||||
|
// but before the antenna's status confirms it. The timer is held off entirely
|
||||||
|
// while the motors are still moving, so this is only the grace period for the
|
||||||
|
// confirmation poll to arrive once the elements have settled — generous, because
|
||||||
|
// over a remote link that poll lags by several seconds.
|
||||||
|
ubPendingDirGrace = 8 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
// Protocol constants
|
// Protocol constants
|
||||||
@@ -221,12 +227,30 @@ func (c *Client) pollLoop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.statusMu.Lock()
|
c.statusMu.Lock()
|
||||||
// Keep a just-commanded direction until the antenna reports it.
|
// Keep a just-commanded direction until the antenna actually reports it.
|
||||||
|
// Over a remote link the confirmation arrives several seconds after the
|
||||||
|
// command — the motors flip the elements first — so the old fixed 4 s
|
||||||
|
// timeout expired WHILE the change was still in flight, and the stale poll
|
||||||
|
// reverted the UI to the old pattern even though the antenna was on its way
|
||||||
|
// to the new one. Now: while the motors are still moving the change is in
|
||||||
|
// progress, so hold the commanded pattern and keep resetting the timer;
|
||||||
|
// only once the motors have stopped does the short grace window run, giving
|
||||||
|
// the confirmation poll time to land. The poll only wins if the motors are
|
||||||
|
// idle AND the antenna still reports a different pattern past that window —
|
||||||
|
// i.e. the command genuinely did not take.
|
||||||
if c.pendingDirSet {
|
if c.pendingDirSet {
|
||||||
if time.Since(c.pendingDirAt) > 4*time.Second || status.Direction == c.pendingDir {
|
if status.MotorsMoving != 0 {
|
||||||
c.pendingDirSet = false
|
c.pendingDirAt = time.Now() // still repositioning — don't start the grace timer
|
||||||
} else {
|
}
|
||||||
status.Direction = c.pendingDir
|
switch {
|
||||||
|
case status.Direction == c.pendingDir:
|
||||||
|
c.pendingDirSet = false // confirmed by the antenna
|
||||||
|
case time.Since(c.pendingDirAt) > ubPendingDirGrace:
|
||||||
|
c.pendingDirSet = false // motors idle, still unconfirmed → accept the poll
|
||||||
|
log.Printf("Ultrabeam: antenna never confirmed direction %d (reports %d) — dropping the hold",
|
||||||
|
c.pendingDir, status.Direction)
|
||||||
|
default:
|
||||||
|
status.Direction = c.pendingDir // still changing, or within the grace window
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
c.lastStatus = status
|
c.lastStatus = status
|
||||||
|
|||||||
+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.23.2"
|
appVersion = "0.23.5"
|
||||||
|
|
||||||
// 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