feat(rotator): multiple rotors of any type, plus a CAT PTT hotkey
Settings -> Rotator is now a list like the amplifiers: add/remove rotors, mix PstRotator / Rotator Genius / ARCO, and a Rotator Genius can drive both its ports (one entry = two rotors). The compass gains a rotor selector, and a per-rotor motorized-antenna flag so the boom/pattern paths show only for the rotor carrying the Ultrabeam/SteppIR. Also in this batch: a keyboard PTT hotkey (hold-to-talk or toggle, reusing the audio PTT method with a CAT fallback), and TCI spot push to the panadapter with click-to-fill of the callsign.
This commit is contained in:
@@ -124,18 +124,21 @@ const (
|
||||
// into the Yaesu and Kenwood backends is what broke them in 0.22.7/0.22.8;
|
||||
// the rule since is that nothing a backend does may be steered by another
|
||||
// backend's setting.
|
||||
keyCATYaesuLowLines = "cat.yaesu.low_dtr_rts" // deassert DTR/RTS on connect
|
||||
keyCATKenwoodLowLines = "cat.kenwood.low_dtr_rts" // deassert DTR/RTS on connect
|
||||
keyCATIcomPort = "cat.icom.port" // Icom USB CI-V serial port (e.g. COM5)
|
||||
keyCATIcomBaud = "cat.icom.baud" // Icom CI-V baud (default 115200)
|
||||
keyCATIcomAddr = "cat.icom.addr" // Icom CI-V address, decimal (IC-7610 = 152 / 0x98)
|
||||
keyCATIcomNetHost = "cat.icom.net.host" // Icom network remote: rig IP/hostname
|
||||
keyCATIcomNetUser = "cat.icom.net.user" // Icom network: Network User1 ID
|
||||
keyCATIcomNetPass = "cat.icom.net.pass" // Icom network: Network User1 password
|
||||
keyCATIcomNetAudio = "cat.icom.net.audio" // Icom network: stream RX audio on 50003 (experimental)
|
||||
keyCATTCIHost = "cat.tci.host" // TCI host (Expert Electronics SunSDR / ExpertSDR2)
|
||||
keyCATTCIPort = "cat.tci.port" // TCI WebSocket port (default 40001)
|
||||
keyCATTCISpots = "cat.tci.spots" // push cluster spots to the TCI panorama
|
||||
keyCATYaesuLowLines = "cat.yaesu.low_dtr_rts" // deassert DTR/RTS on connect
|
||||
keyCATKenwoodLowLines = "cat.kenwood.low_dtr_rts" // deassert DTR/RTS on connect
|
||||
keyCATIcomPort = "cat.icom.port" // Icom USB CI-V serial port (e.g. COM5)
|
||||
keyCATIcomBaud = "cat.icom.baud" // Icom CI-V baud (default 115200)
|
||||
keyCATIcomAddr = "cat.icom.addr" // Icom CI-V address, decimal (IC-7610 = 152 / 0x98)
|
||||
keyCATIcomNetHost = "cat.icom.net.host" // Icom network remote: rig IP/hostname
|
||||
keyCATIcomNetUser = "cat.icom.net.user" // Icom network: Network User1 ID
|
||||
keyCATIcomNetPass = "cat.icom.net.pass" // Icom network: Network User1 password
|
||||
keyCATIcomNetAudio = "cat.icom.net.audio" // Icom network: stream RX audio on 50003 (experimental)
|
||||
keyCATTCIHost = "cat.tci.host" // TCI host (Expert Electronics SunSDR / ExpertSDR2)
|
||||
keyCATTCIPort = "cat.tci.port" // TCI WebSocket port (default 40001)
|
||||
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
|
||||
// global (not per-profile) like CAT/rotator. Device fields store the
|
||||
@@ -196,9 +199,25 @@ const (
|
||||
keyRotatorHasElevation = "rotator.has_elevation"
|
||||
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
|
||||
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)
|
||||
keyRotatorComPort = "rotator.com_port" // GS-232 serial transport
|
||||
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
|
||||
// "ultrabeam." prefix for backward compatibility with configs saved before the
|
||||
@@ -404,6 +423,12 @@ type CATSettings struct {
|
||||
DigitalDefault string `json:"digital_default"` // when CAT says DATA, surface this mode (FT8/FT4/RTTY/…)
|
||||
ShareEnabled bool `json:"share_enabled"` // serve CAT to other programs (Hamlib NET rigctl)
|
||||
SharePort int `json:"share_port"` // TCP port for it (default 4532)
|
||||
// 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
|
||||
@@ -6830,44 +6855,47 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
||||
if a.settings == nil {
|
||||
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
|
||||
}
|
||||
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATYaesuLowLines, keyCATKenwoodLowLines, 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, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPttHotkeyEnabled, keyCATPttHotkey, keyCATPttHotkeyToggle, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort)
|
||||
if err != nil {
|
||||
return CATSettings{}, err
|
||||
}
|
||||
out := CATSettings{
|
||||
Enabled: m[keyCATEnabled] == "1",
|
||||
Backend: m[keyCATBackend],
|
||||
OmniRigNum: 1,
|
||||
FlexHost: m[keyCATFlexHost],
|
||||
FlexPort: 4992,
|
||||
FlexSpots: m[keyCATFlexSpots] == "1",
|
||||
FlexDecodeSpots: m[keyCATFlexDecodeSpots] == "1",
|
||||
FlexDecodeSecs: 120,
|
||||
XieguPort: m[keyCATXieguPort],
|
||||
XieguBaud: 19200,
|
||||
XieguAddr: cat.XieguDefaultAddr,
|
||||
YaesuPort: m[keyCATYaesuPort],
|
||||
YaesuBaud: 38400,
|
||||
KenwoodPort: m[keyCATKenwoodPort],
|
||||
KenwoodHost: m[keyCATKenwoodHost],
|
||||
KenwoodBaud: 9600,
|
||||
YaesuLowLines: m[keyCATYaesuLowLines] == "1",
|
||||
KenwoodLowLines: m[keyCATKenwoodLowLines] == "1",
|
||||
IcomPort: m[keyCATIcomPort],
|
||||
IcomBaud: 115200,
|
||||
IcomAddr: 0x98, // IC-7610 default
|
||||
IcomNetHost: m[keyCATIcomNetHost],
|
||||
IcomNetUser: m[keyCATIcomNetUser],
|
||||
IcomNetPass: m[keyCATIcomNetPass],
|
||||
IcomNetAudio: m[keyCATIcomNetAudio] == "1",
|
||||
TCIHost: m[keyCATTCIHost],
|
||||
TCIPort: 40001,
|
||||
TCISpots: m[keyCATTCISpots] == "1",
|
||||
PollMs: 250,
|
||||
DelayMs: 0,
|
||||
DigitalDefault: m[keyCATDigitalDefault],
|
||||
ShareEnabled: m[keyCATShareEnabled] == "1",
|
||||
SharePort: 4532,
|
||||
Enabled: m[keyCATEnabled] == "1",
|
||||
Backend: m[keyCATBackend],
|
||||
OmniRigNum: 1,
|
||||
FlexHost: m[keyCATFlexHost],
|
||||
FlexPort: 4992,
|
||||
FlexSpots: m[keyCATFlexSpots] == "1",
|
||||
FlexDecodeSpots: m[keyCATFlexDecodeSpots] == "1",
|
||||
FlexDecodeSecs: 120,
|
||||
XieguPort: m[keyCATXieguPort],
|
||||
XieguBaud: 19200,
|
||||
XieguAddr: cat.XieguDefaultAddr,
|
||||
YaesuPort: m[keyCATYaesuPort],
|
||||
YaesuBaud: 38400,
|
||||
KenwoodPort: m[keyCATKenwoodPort],
|
||||
KenwoodHost: m[keyCATKenwoodHost],
|
||||
KenwoodBaud: 9600,
|
||||
YaesuLowLines: m[keyCATYaesuLowLines] == "1",
|
||||
KenwoodLowLines: m[keyCATKenwoodLowLines] == "1",
|
||||
IcomPort: m[keyCATIcomPort],
|
||||
IcomBaud: 115200,
|
||||
IcomAddr: 0x98, // IC-7610 default
|
||||
IcomNetHost: m[keyCATIcomNetHost],
|
||||
IcomNetUser: m[keyCATIcomNetUser],
|
||||
IcomNetPass: m[keyCATIcomNetPass],
|
||||
IcomNetAudio: m[keyCATIcomNetAudio] == "1",
|
||||
TCIHost: m[keyCATTCIHost],
|
||||
TCIPort: 40001,
|
||||
TCISpots: m[keyCATTCISpots] == "1",
|
||||
PTTHotkeyEnabled: m[keyCATPttHotkeyEnabled] == "1",
|
||||
PTTHotkey: m[keyCATPttHotkey],
|
||||
PTTHotkeyToggle: m[keyCATPttHotkeyToggle] == "1",
|
||||
PollMs: 250,
|
||||
DelayMs: 0,
|
||||
DigitalDefault: m[keyCATDigitalDefault],
|
||||
ShareEnabled: m[keyCATShareEnabled] == "1",
|
||||
SharePort: 4532,
|
||||
}
|
||||
if n, _ := strconv.Atoi(m[keyCATFlexPort]); n > 0 && n <= 65535 {
|
||||
out.FlexPort = n
|
||||
@@ -6999,41 +7027,44 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
||||
shareEnabled = "1"
|
||||
}
|
||||
for k, v := range map[string]string{
|
||||
keyCATEnabled: enabled,
|
||||
keyCATBackend: s.Backend,
|
||||
keyCATOmniRigNum: strconv.Itoa(s.OmniRigNum),
|
||||
keyCATOmniRigVFO: strings.ToUpper(strings.TrimSpace(s.OmniRigVFO)),
|
||||
keyCATFlexHost: strings.TrimSpace(s.FlexHost),
|
||||
keyCATFlexPort: strconv.Itoa(s.FlexPort),
|
||||
keyCATFlexSpots: flexSpots,
|
||||
keyCATFlexDecodeSpots: flexDecodeSpots,
|
||||
keyCATFlexDecodeSecs: strconv.Itoa(s.FlexDecodeSecs),
|
||||
keyCATXieguPort: strings.TrimSpace(s.XieguPort),
|
||||
keyCATXieguBaud: strconv.Itoa(s.XieguBaud),
|
||||
keyCATXieguAddr: strconv.Itoa(s.XieguAddr),
|
||||
keyCATXieguPTTLine: strings.ToLower(strings.TrimSpace(s.XieguPTTLine)),
|
||||
keyCATYaesuPort: strings.TrimSpace(s.YaesuPort),
|
||||
keyCATYaesuBaud: strconv.Itoa(s.YaesuBaud),
|
||||
keyCATKenwoodPort: strings.TrimSpace(s.KenwoodPort),
|
||||
keyCATKenwoodHost: strings.TrimSpace(s.KenwoodHost),
|
||||
keyCATKenwoodBaud: strconv.Itoa(s.KenwoodBaud),
|
||||
keyCATYaesuLowLines: b01(s.YaesuLowLines),
|
||||
keyCATKenwoodLowLines: b01(s.KenwoodLowLines),
|
||||
keyCATIcomPort: strings.TrimSpace(s.IcomPort),
|
||||
keyCATIcomBaud: strconv.Itoa(s.IcomBaud),
|
||||
keyCATIcomAddr: strconv.Itoa(s.IcomAddr),
|
||||
keyCATIcomNetHost: strings.TrimSpace(s.IcomNetHost),
|
||||
keyCATIcomNetUser: strings.TrimSpace(s.IcomNetUser),
|
||||
keyCATIcomNetPass: s.IcomNetPass,
|
||||
keyCATIcomNetAudio: icomNetAudio,
|
||||
keyCATTCIHost: strings.TrimSpace(s.TCIHost),
|
||||
keyCATTCIPort: strconv.Itoa(s.TCIPort),
|
||||
keyCATTCISpots: tciSpots,
|
||||
keyCATPollMs: strconv.Itoa(s.PollMs),
|
||||
keyCATDelayMs: strconv.Itoa(s.DelayMs),
|
||||
keyCATDigitalDefault: strings.ToUpper(strings.TrimSpace(s.DigitalDefault)),
|
||||
keyCATShareEnabled: shareEnabled,
|
||||
keyCATSharePort: strconv.Itoa(s.SharePort),
|
||||
keyCATEnabled: enabled,
|
||||
keyCATBackend: s.Backend,
|
||||
keyCATOmniRigNum: strconv.Itoa(s.OmniRigNum),
|
||||
keyCATOmniRigVFO: strings.ToUpper(strings.TrimSpace(s.OmniRigVFO)),
|
||||
keyCATFlexHost: strings.TrimSpace(s.FlexHost),
|
||||
keyCATFlexPort: strconv.Itoa(s.FlexPort),
|
||||
keyCATFlexSpots: flexSpots,
|
||||
keyCATFlexDecodeSpots: flexDecodeSpots,
|
||||
keyCATFlexDecodeSecs: strconv.Itoa(s.FlexDecodeSecs),
|
||||
keyCATXieguPort: strings.TrimSpace(s.XieguPort),
|
||||
keyCATXieguBaud: strconv.Itoa(s.XieguBaud),
|
||||
keyCATXieguAddr: strconv.Itoa(s.XieguAddr),
|
||||
keyCATXieguPTTLine: strings.ToLower(strings.TrimSpace(s.XieguPTTLine)),
|
||||
keyCATYaesuPort: strings.TrimSpace(s.YaesuPort),
|
||||
keyCATYaesuBaud: strconv.Itoa(s.YaesuBaud),
|
||||
keyCATKenwoodPort: strings.TrimSpace(s.KenwoodPort),
|
||||
keyCATKenwoodHost: strings.TrimSpace(s.KenwoodHost),
|
||||
keyCATKenwoodBaud: strconv.Itoa(s.KenwoodBaud),
|
||||
keyCATYaesuLowLines: b01(s.YaesuLowLines),
|
||||
keyCATKenwoodLowLines: b01(s.KenwoodLowLines),
|
||||
keyCATIcomPort: strings.TrimSpace(s.IcomPort),
|
||||
keyCATIcomBaud: strconv.Itoa(s.IcomBaud),
|
||||
keyCATIcomAddr: strconv.Itoa(s.IcomAddr),
|
||||
keyCATIcomNetHost: strings.TrimSpace(s.IcomNetHost),
|
||||
keyCATIcomNetUser: strings.TrimSpace(s.IcomNetUser),
|
||||
keyCATIcomNetPass: s.IcomNetPass,
|
||||
keyCATIcomNetAudio: icomNetAudio,
|
||||
keyCATTCIHost: strings.TrimSpace(s.TCIHost),
|
||||
keyCATTCIPort: strconv.Itoa(s.TCIPort),
|
||||
keyCATTCISpots: tciSpots,
|
||||
keyCATPttHotkeyEnabled: b01(s.PTTHotkeyEnabled),
|
||||
keyCATPttHotkey: strings.TrimSpace(s.PTTHotkey),
|
||||
keyCATPttHotkeyToggle: b01(s.PTTHotkeyToggle),
|
||||
keyCATPollMs: strconv.Itoa(s.PollMs),
|
||||
keyCATDelayMs: strconv.Itoa(s.DelayMs),
|
||||
keyCATDigitalDefault: strings.ToUpper(strings.TrimSpace(s.DigitalDefault)),
|
||||
keyCATShareEnabled: shareEnabled,
|
||||
keyCATSharePort: strconv.Itoa(s.SharePort),
|
||||
} {
|
||||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||
return err
|
||||
@@ -8949,6 +8980,26 @@ func (a *App) pttUnkey() {
|
||||
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 {
|
||||
cfg, err := a.GetAudioSettings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.PTTMethod == "" || cfg.PTTMethod == "none" {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("no PTT method configured and CAT not active")
|
||||
}
|
||||
cfg.PTTMethod = "cat"
|
||||
}
|
||||
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 the transmitter for ~600ms using the GIVEN settings (the live
|
||||
// UI selection), so the user can test a method/port without saving first —
|
||||
@@ -12946,7 +12997,14 @@ func (a *App) reloadCAT() {
|
||||
case "tci":
|
||||
// Expert Electronics TCI (WebSocket) — SunSDR / ExpertSDR2, or any
|
||||
// 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:
|
||||
// Unknown backend → stop and emit a dummy state so the UI shows it.
|
||||
a.cat.Stop()
|
||||
@@ -13254,246 +13312,414 @@ func (a *App) DuplicateProfile(id int64, newName string) (profile.Profile, error
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// --- Rotator bindings (PstRotator UDP v0) ---
|
||||
// --- Rotator bindings ---
|
||||
|
||||
// RotatorSettings is the JSON shape for the Hardware → Rotator panel.
|
||||
type RotatorSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Type string `json:"type"` // "pst" (PstRotator UDP) | "rotgenius" (4O3A native TCP) | "arco" (microHAM ARCO, Yaesu GS-232A over TCP)
|
||||
const keyRotatorsList = "rotators.json"
|
||||
|
||||
// RotatorDevice is one configured rotor in Settings → Rotator (a list, like the
|
||||
// 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
|
||||
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)
|
||||
RotatorNum int `json:"rotator_num"` // Rotator Genius index: 1 or 2
|
||||
Transport string `json:"transport"` // GS-232 controller: "tcp" (LAN) | "serial" (COM)
|
||||
RotatorNum int `json:"rotator_num"` // Rotator Genius internal index (1/2) when not Dual
|
||||
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
|
||||
// Baud for the serial transport. An ARCO's virtual COM ignores it; an ERC runs
|
||||
// at the rate set in its own configuration, so it has to be selectable.
|
||||
Baud int `json:"baud"`
|
||||
Baud int `json:"baud"` // GS-232 serial baud (an ERC needs it; an ARCO ignores it)
|
||||
}
|
||||
|
||||
// GetRotatorSettings returns the persisted rotator config with defaults.
|
||||
func (a *App) GetRotatorSettings() (RotatorSettings, error) {
|
||||
out := RotatorSettings{Type: "pst", Host: "127.0.0.1", Port: 12000, RotatorNum: 1}
|
||||
// logicalRotor is one addressable rotor. Flattening the device list expands a
|
||||
// Dual Rotator Genius into two.
|
||||
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 {
|
||||
return out, fmt.Errorf("db not initialized")
|
||||
return nil, fmt.Errorf("db not initialized")
|
||||
}
|
||||
m, err := a.settings.GetMany(a.ctx,
|
||||
keyRotatorEnabled, keyRotatorHost, keyRotatorPort, keyRotatorHasElevation, keyRotatorType, keyRotatorNum,
|
||||
keyRotatorTransport, keyRotatorComPort, keyRotatorBaud)
|
||||
if err != nil {
|
||||
return out, err
|
||||
raw, _ := a.settings.Get(a.ctx, keyRotatorsList)
|
||||
if strings.TrimSpace(raw) != "" {
|
||||
var list []RotatorDevice
|
||||
if err := json.Unmarshal([]byte(raw), &list); err == nil {
|
||||
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 != "" {
|
||||
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
|
||||
return a.migrateLegacyRotors(), nil
|
||||
}
|
||||
|
||||
// SaveRotatorSettings persists the rotator config. Connection is per-call
|
||||
// (UDP, no socket to (re)open) so no reload step is needed.
|
||||
func (a *App) SaveRotatorSettings(s RotatorSettings) error {
|
||||
// migrateLegacyRotors builds a device list from the pre-list flat settings keys
|
||||
// (the single rotor shipped in 0.23.2, plus the unreleased two-rotor keys).
|
||||
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 {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
if s.Type != "rotgenius" && s.Type != "arco" {
|
||||
s.Type = "pst"
|
||||
}
|
||||
if s.Host == "" {
|
||||
s.Host = "127.0.0.1"
|
||||
}
|
||||
if s.Port <= 0 || s.Port > 65535 {
|
||||
s.Port = map[string]int{"rotgenius": 9006, "pst": 12000, "arco": 4001}[s.Type]
|
||||
}
|
||||
if s.RotatorNum != 2 {
|
||||
s.RotatorNum = 1
|
||||
}
|
||||
if s.Transport != "serial" {
|
||||
s.Transport = "tcp"
|
||||
}
|
||||
for k, v := range map[string]string{
|
||||
keyRotatorEnabled: boolStr(s.Enabled),
|
||||
keyRotatorType: s.Type,
|
||||
keyRotatorHost: s.Host,
|
||||
keyRotatorPort: strconv.Itoa(s.Port),
|
||||
keyRotatorHasElevation: boolStr(s.HasElevation),
|
||||
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
|
||||
for i := range list {
|
||||
d := &list[i]
|
||||
if strings.TrimSpace(d.ID) == "" {
|
||||
d.ID = fmt.Sprintf("rotor-%d-%d", time.Now().Unix(), i)
|
||||
}
|
||||
d.Type = normRotorType(d.Type)
|
||||
if strings.TrimSpace(d.Host) == "" {
|
||||
d.Host = "127.0.0.1"
|
||||
}
|
||||
if d.Port <= 0 || d.Port > 65535 {
|
||||
d.Port = rotatorDefaultPort(d.Type)
|
||||
}
|
||||
if d.RotatorNum != 2 {
|
||||
d.RotatorNum = 1
|
||||
}
|
||||
if d.Transport != "serial" {
|
||||
d.Transport = "tcp"
|
||||
}
|
||||
if d.Baud <= 0 {
|
||||
d.Baud = 9600
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// arcoClient builds the GS-232 client for the configured ARCO transport:
|
||||
// USB virtual COM (serial) or the LAN CONTROL PROTOCOL TCP port.
|
||||
func arcoClient(s RotatorSettings) *gs232.Client {
|
||||
if s.Transport == "serial" {
|
||||
return gs232.NewSerial(s.ComPort, s.Baud)
|
||||
b, err := json.Marshal(list)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return gs232.New(s.Host, s.Port)
|
||||
return a.settings.Set(a.ctx, keyRotatorsList, string(b))
|
||||
}
|
||||
|
||||
// RotatorHeading is the live antenna heading for the status bar.
|
||||
// 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.
|
||||
func arcoClient(l rotorLink) *gs232.Client {
|
||||
if l.Transport == "serial" {
|
||||
return gs232.NewSerial(l.ComPort, l.Baud)
|
||||
}
|
||||
return gs232.New(l.Host, l.Port)
|
||||
}
|
||||
|
||||
// RotatorHeading is the live antenna heading for the status bar and compass.
|
||||
type RotatorHeading struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
OK bool `json:"ok"`
|
||||
Azimuth int `json:"azimuth"`
|
||||
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
|
||||
// Enabled=false when the rotator isn't configured. Polled by the status bar.
|
||||
// activeRotor resolves the currently selected logical rotor. ok is false when no
|
||||
// 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 {
|
||||
s, err := a.GetRotatorSettings()
|
||||
if err != nil || !s.Enabled {
|
||||
lr, rotors, idx, ok := a.activeRotor()
|
||||
if !ok {
|
||||
return RotatorHeading{Enabled: false}
|
||||
}
|
||||
if s.Type == "rotgenius" {
|
||||
st, raw, herr := rotgenius.New(s.Host, s.Port).Heading(s.RotatorNum)
|
||||
names := make([]string, len(rotors))
|
||||
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 {
|
||||
return RotatorHeading{Enabled: true, OK: false, Raw: herr.Error()}
|
||||
base.Raw = herr.Error()
|
||||
return base
|
||||
}
|
||||
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}
|
||||
}
|
||||
if s.Type == "arco" {
|
||||
az, raw, herr := arcoClient(s).Heading()
|
||||
base.OK = true
|
||||
base.Azimuth = st.Azimuth
|
||||
base.Raw = raw
|
||||
return base
|
||||
case "arco":
|
||||
az, raw, herr := arcoClient(link).Heading()
|
||||
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
|
||||
base.Raw = raw
|
||||
return base
|
||||
default:
|
||||
az, raw, herr := pst.New(link.Host, link.Port).Heading()
|
||||
if herr != nil {
|
||||
base.Raw = raw
|
||||
return base
|
||||
}
|
||||
base.OK = true
|
||||
base.Azimuth = az
|
||||
base.Raw = raw
|
||||
return base
|
||||
}
|
||||
az, raw, herr := pst.New(s.Host, s.Port).Heading()
|
||||
if herr != nil {
|
||||
return RotatorHeading{Enabled: true, OK: false, Raw: raw}
|
||||
}
|
||||
return RotatorHeading{Enabled: true, OK: true, Azimuth: az, Raw: raw}
|
||||
}
|
||||
|
||||
// RotatorGoTo points the antenna at the given azimuth (and optional
|
||||
// elevation if the rotator is configured for it).
|
||||
// RotatorGoTo points the active rotor at the given azimuth (and optional
|
||||
// elevation if it is configured for it).
|
||||
func (a *App) RotatorGoTo(az int, el int) error {
|
||||
s, err := a.GetRotatorSettings()
|
||||
if err != nil {
|
||||
return err
|
||||
lr, _, _, ok := a.activeRotor()
|
||||
if !ok {
|
||||
return fmt.Errorf("no rotator configured")
|
||||
}
|
||||
if !s.Enabled {
|
||||
return fmt.Errorf("rotator disabled in settings")
|
||||
link := lr.Link
|
||||
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 {
|
||||
s, err := a.GetRotatorSettings()
|
||||
if err != nil {
|
||||
return err
|
||||
lr, _, _, ok := a.activeRotor()
|
||||
if !ok {
|
||||
return fmt.Errorf("no rotator configured")
|
||||
}
|
||||
if !s.Enabled {
|
||||
return fmt.Errorf("rotator disabled in settings")
|
||||
link := lr.Link
|
||||
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
|
||||
// PstRotator itself). Park is a PstRotator concept — the 4O3A native protocol
|
||||
// has no park command.
|
||||
// RotatorPark moves the active rotor to its parked position (PstRotator only;
|
||||
// the 4O3A native and ARCO GS-232 protocols have no park command).
|
||||
func (a *App) RotatorPark() error {
|
||||
s, err := a.GetRotatorSettings()
|
||||
if err != nil {
|
||||
return err
|
||||
lr, _, _, ok := a.activeRotor()
|
||||
if !ok {
|
||||
return fmt.Errorf("no rotator configured")
|
||||
}
|
||||
if !s.Enabled {
|
||||
return fmt.Errorf("rotator disabled in settings")
|
||||
}
|
||||
if s.Type == "rotgenius" {
|
||||
link := lr.Link
|
||||
switch link.Type {
|
||||
case "rotgenius":
|
||||
return fmt.Errorf("park is a PstRotator feature; not available on the Rotator Genius")
|
||||
}
|
||||
if s.Type == "arco" {
|
||||
case "arco":
|
||||
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
|
||||
// verify the UDP link without actually moving the antenna. We use 0° as
|
||||
// the test target — pick a known direction the user expects to see.
|
||||
// Returns nil on success or a descriptive error.
|
||||
func (a *App) TestRotator(s RotatorSettings) error {
|
||||
if s.Host == "" {
|
||||
s.Host = "127.0.0.1"
|
||||
// TestRotatorDevice verifies a rotor device's link (sub picks the Rotator Genius
|
||||
// port 1/2 for a Dual device). Takes the live panel device so it can be tested
|
||||
// before saving.
|
||||
func (a *App) TestRotatorDevice(d RotatorDevice, sub int) error {
|
||||
return testRotorLink(deviceLink(d, sub))
|
||||
}
|
||||
|
||||
// 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 s.Port <= 0 || s.Port > 65535 {
|
||||
s.Port = 9006
|
||||
}
|
||||
rn := s.RotatorNum
|
||||
if l.Port <= 0 || l.Port > 65535 {
|
||||
l.Port = rotatorDefaultPort(l.Type)
|
||||
}
|
||||
switch l.Type {
|
||||
case "rotgenius":
|
||||
rn := l.Num
|
||||
if rn != 2 {
|
||||
rn = 1
|
||||
}
|
||||
// Match the button ("Test — point to 0°"): actually command the move, like
|
||||
// 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.
|
||||
return rotgenius.New(s.Host, s.Port).GoTo(rn, 0)
|
||||
}
|
||||
if s.Type == "arco" {
|
||||
if s.Port <= 0 || s.Port > 65535 {
|
||||
s.Port = 4001
|
||||
}
|
||||
if s.Transport == "serial" && strings.TrimSpace(s.ComPort) == "" {
|
||||
return rotgenius.New(l.Host, l.Port).GoTo(rn, 0)
|
||||
case "arco":
|
||||
if l.Transport == "serial" && strings.TrimSpace(l.ComPort) == "" {
|
||||
return fmt.Errorf("select the ARCO's COM port first")
|
||||
}
|
||||
// A heading query proves the link AND that the ARCO port really speaks
|
||||
// GS-232 — without moving the antenna.
|
||||
_, _, err := arcoClient(s).Heading()
|
||||
_, _, err := arcoClient(l).Heading()
|
||||
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 {
|
||||
@@ -13510,9 +13736,9 @@ type StationDevice struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // "webswitch" | "kmtronic" | "dingtian" | "denkovi" | "usbrelay"
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"` // IP for network boards; FTDI serial for denkovi; COM port for usbrelay
|
||||
User string `json:"user,omitempty"` // KMTronic HTTP auth; Dingtian HTTP session ID (blank = none)
|
||||
Pass string `json:"pass,omitempty"` // KMTronic HTTP auth; Dingtian relay password (0–9999)
|
||||
Host string `json:"host"` // IP for network boards; FTDI serial for denkovi; COM port for usbrelay
|
||||
User string `json:"user,omitempty"` // KMTronic HTTP auth; Dingtian HTTP session ID (blank = none)
|
||||
Pass string `json:"pass,omitempty"` // KMTronic HTTP auth; Dingtian relay password (0–9999)
|
||||
Channels int `json:"channels,omitempty"` // usbrelay/denkovi/dingtian: number of relays
|
||||
Labels []string `json:"labels"` // per-relay label (index 0 = relay 1)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
[
|
||||
{
|
||||
"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 (e.g. an ARCO and a Rotator Genius); a Rotator Genius set to “drives two rotors” adds a second one. The compass shows a selector to pick the active rotor, which it turns, displays and points to the DX. Each rotor can be named.",
|
||||
"Rotors: mark which one carries the motorized antenna (Ultrabeam/SteppIR). The boom and reverse/bidirectional pattern paths then show only for that rotor; a plain Yagi on another rotor shows just its heading.",
|
||||
"CAT: a keyboard PTT hotkey. Pick a key in Settings → CAT and it keys the transmitter while OpsLog is focused — hold-to-talk, or a toggle. It uses the Audio PTT method (CAT/RTS/DTR), falling back to CAT keying."
|
||||
],
|
||||
"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 (ex. un ARCO et un Rotator Genius) ; un Rotator Genius réglé sur « pilote deux rotors » en ajoute un second. Le compas affiche un sélecteur pour choisir le rotor actif, qu'il tourne, affiche et pointe vers le DX. Chaque rotor peut être nommé.",
|
||||
"Rotors : indiquez lequel porte l'antenne motorisée (Ultrabeam/SteppIR). Les tracés de boom et de diagramme inverse/bidirectionnel n'apparaissent alors que pour ce rotor ; une Yagi simple sur un autre rotor n'affiche que son cap.",
|
||||
"CAT : une touche PTT au clavier. Choisissez une touche dans Réglages → CAT et elle passe la radio en émission quand OpsLog a le focus — maintien ou bascule. Elle utilise la méthode PTT audio (CAT/RTS/DTR), avec repli sur le CAT."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.23.2",
|
||||
"date": "",
|
||||
|
||||
+72
-7
@@ -20,7 +20,7 @@ import {
|
||||
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna, FlexApplyBandPower,
|
||||
GetSecretStatus, UnlockSecrets,
|
||||
RefreshCtyDat, DownloadAllReferenceLists,
|
||||
RotatorGoTo, RotatorStop, GetRotatorHeading,
|
||||
RotatorGoTo, RotatorStop, GetRotatorHeading, SetActiveRotor,
|
||||
GetDBConnectionInfo, GetLogbookRevision,
|
||||
GetUltrabeamStatus, SetUltrabeamDirection, UILog,
|
||||
GetAntGeniusStatus, GetAntGeniusSettings, AntGeniusActivate,
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
OpenExternalURL,
|
||||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
|
||||
ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
|
||||
GetCATSettings,
|
||||
GetCATSettings, PTTHotkeyDown, PTTHotkeyUp,
|
||||
GetSolarData,
|
||||
GetQSORate,
|
||||
LoTWUserInfo,
|
||||
@@ -628,6 +628,11 @@ export default function App() {
|
||||
// can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default
|
||||
// in Preferences > Hardware > CAT interface.
|
||||
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
|
||||
// window after manual edits and skip CAT updates during it.
|
||||
const catFreezeUntilRef = useRef<number>(0);
|
||||
@@ -1764,23 +1769,28 @@ export default function App() {
|
||||
: null
|
||||
), [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[]>(() => {
|
||||
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 === 2) return [rotorAz, (rotorAz + 180) % 360];
|
||||
}
|
||||
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
|
||||
// show where the antenna physically points (boom) vs where it radiates when
|
||||
// the Ultrabeam is reversed/bidirectional.
|
||||
const boomHeading = rotorAz;
|
||||
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';
|
||||
}, [ubStatus.enabled, ubStatus.connected, ubStatus.direction]);
|
||||
}, [activeRotorMotorized, ubStatus.enabled, ubStatus.connected, ubStatus.direction]);
|
||||
|
||||
// Portable UI toggles (mirrored to the DB via writeUiPref / syncPortablePrefs).
|
||||
const [showRotor, setShowRotor] = useState(() => localStorage.getItem('opslog.showRotor') !== '0');
|
||||
@@ -1927,6 +1937,48 @@ export default function App() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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;
|
||||
if (e.ctrlKey || e.altKey || e.metaKey) return; // plain key only
|
||||
e.preventDefault();
|
||||
if (e.repeat) return;
|
||||
if (cfg.toggle) {
|
||||
pttToggledRef.current = !pttToggledRef.current;
|
||||
(pttToggledRef.current ? PTTHotkeyDown() : PTTHotkeyUp())?.catch?.(() => {});
|
||||
} else if (!pttHeldRef.current) {
|
||||
pttHeldRef.current = true;
|
||||
PTTHotkeyDown().catch(() => {});
|
||||
}
|
||||
};
|
||||
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
|
||||
// rotator is disabled (the backend just reads settings and returns).
|
||||
useEffect(() => {
|
||||
@@ -2110,6 +2162,11 @@ export default function App() {
|
||||
try {
|
||||
const c = await GetCATSettings();
|
||||
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 ?? '');
|
||||
} catch {}
|
||||
}, []);
|
||||
@@ -2571,6 +2628,11 @@ export default function App() {
|
||||
const call = String(p?.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 total = Number(p?.total ?? 0);
|
||||
const processed = Number(p?.processed ?? 0);
|
||||
@@ -2605,7 +2667,7 @@ export default function App() {
|
||||
const file = String(p?.file ?? '').replace(/^.*[\\/]/, '');
|
||||
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
|
||||
}, []);
|
||||
|
||||
@@ -5552,6 +5614,9 @@ export default function App() {
|
||||
centerLat={gridToLatLon(station.my_grid)?.lat ?? null}
|
||||
centerLon={gridToLatLon(station.my_grid)?.lon ?? null}
|
||||
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)))}
|
||||
onClose={() => { setShowRotor(false); writeUiPref('opslog.showRotor', '0'); }}
|
||||
/>
|
||||
|
||||
@@ -24,6 +24,9 @@ interface Props {
|
||||
centerLat?: number | null; // operator latitude (projection centre)
|
||||
centerLon?: number | null; // operator longitude
|
||||
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
|
||||
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)];
|
||||
}
|
||||
|
||||
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(
|
||||
() => [ { 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' } ],
|
||||
@@ -100,6 +103,31 @@ export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLa
|
||||
)}
|
||||
</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">
|
||||
<svg
|
||||
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
GetListsSettings, SaveListsSettings,
|
||||
GetCATSettings, SaveCATSettings, DiscoverFlexRadios,
|
||||
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
||||
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
|
||||
GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop,
|
||||
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
|
||||
GetAntGeniusSettings, SaveAntGeniusSettings,
|
||||
GetTunerGeniusSettings, SaveTunerGeniusSettings,
|
||||
@@ -80,7 +80,7 @@ type StationSettings = StationSettingsForm;
|
||||
type ListsSettings = ListsSettingsForm;
|
||||
type ModePreset = ModePresetForm;
|
||||
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 ClusterServerStatus = Omit<clusterModels.ServerStatus, 'convertValues'>;
|
||||
type Profile = Omit<profileModels.Profile, 'convertValues'>;
|
||||
@@ -1140,10 +1140,11 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
|
||||
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
|
||||
digital_default: 'FT8', share_enabled: false, share_port: 4532,
|
||||
ptt_hotkey_enabled: false, ptt_hotkey: '', ptt_hotkey_toggle: false,
|
||||
});
|
||||
const [rotator, setRotator] = useState<RotatorSettings>({
|
||||
enabled: false, type: 'pst', host: '127.0.0.1', port: 12000, has_elevation: false, rotator_num: 1,
|
||||
} as any);
|
||||
// While true, the next key press is captured as the PTT hotkey.
|
||||
const [capturingPtt, setCapturingPtt] = useState(false);
|
||||
const [rotors, setRotors] = useState<RotatorDevice[]>([]);
|
||||
const [rotatorTesting, setRotatorTesting] = useState(false);
|
||||
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
|
||||
@@ -1498,7 +1499,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
try {
|
||||
const [l, ls, c, ap, r, b, qd, es] = await Promise.all([
|
||||
GetLookupSettings(), GetListsSettings(), GetCATSettings(), GetActiveProfile(),
|
||||
GetRotatorSettings(), GetBackupSettings(), GetQSLDefaults(), GetExternalServices(),
|
||||
GetRotators(), GetBackupSettings(), GetQSLDefaults(), GetExternalServices(),
|
||||
]);
|
||||
setLookup(l);
|
||||
setActiveProfile(ap as Profile);
|
||||
@@ -1511,7 +1512,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
await reloadProfiles();
|
||||
await reloadClusterServers();
|
||||
setCatCfg(c);
|
||||
setRotator(r);
|
||||
setRotors((r ?? []) as any);
|
||||
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
||||
try { setAntgenius(await GetAntGeniusSettings() 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 { setLookup(await GetLookupSettings() 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 { setAntgenius(await GetAntGeniusSettings() 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 SaveCATSettings(catCfg as any);
|
||||
await SaveRotatorSettings(rotator as any);
|
||||
await SaveRotators(rotors as any);
|
||||
await SaveUltrabeamSettings(ultrabeam as any);
|
||||
await SaveAntGeniusSettings(antgenius as any);
|
||||
await SaveTunerGeniusSettings(tunergenius as any);
|
||||
@@ -2811,6 +2812,54 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</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' && (
|
||||
<>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
@@ -2830,12 +2879,12 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
);
|
||||
}
|
||||
|
||||
async function testRotator() {
|
||||
async function testRotator(dev: RotatorDevice, sub = 1) {
|
||||
setRotatorTesting(true);
|
||||
setRotatorTest(null);
|
||||
try {
|
||||
await TestRotator(rotator as any);
|
||||
setRotatorTest({ ok: true, msg: (rotator as any).type === 'rotgenius' ? t('rot.testOkRG') : t('cat.rotatorOk') });
|
||||
await TestRotatorDevice(dev as any, sub);
|
||||
setRotatorTest({ ok: true, msg: (dev as any).type === 'rotgenius' ? t('rot.testOkRG') : t('cat.rotatorOk') });
|
||||
} catch (e: any) {
|
||||
setRotatorTest({ ok: false, msg: String(e?.message ?? e) });
|
||||
} finally {
|
||||
@@ -3305,130 +3354,195 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
}
|
||||
|
||||
function RotatorPanel() {
|
||||
const isRG = (rotator as any).type === 'rotgenius';
|
||||
const isARCO = (rotator as any).type === 'arco';
|
||||
// Rotors are a list, like amplifiers: add with +, remove with the trash icon.
|
||||
// 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 (
|
||||
<>
|
||||
<SectionHeader
|
||||
title="Rotator"
|
||||
hint={isRG || isARCO ? undefined : t('rot.hint')}
|
||||
/>
|
||||
<SectionHeader title="Rotator" hint={anyPst ? t('rot.hint') : undefined} />
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={rotator.enabled} onCheckedChange={(c) => setRotator((s) => ({ ...s, enabled: !!c }))} />
|
||||
{t('rot.enable')}
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>{t('rot.type')}</Label>
|
||||
{/* Each backend gets its default port: Rotator Genius 9006, ARCO 4001
|
||||
(placeholder — must match the ARCO's LAN menu), PstRotator 12000. */}
|
||||
<Select value={(rotator as any).type ?? 'pst'}
|
||||
onValueChange={(v) => setRotator((s) => ({ ...s, type: v, port: v === 'rotgenius' ? 9006 : v === 'arco' ? 4001 : 12000 } as any))}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="pst">PstRotator (UDP)</SelectItem>
|
||||
<SelectItem value="rotgenius">Rotator Genius (4O3A, native)</SelectItem>
|
||||
<SelectItem value="arco">GS-232A controller (microHAM ARCO, ERC…)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{isRG && (
|
||||
<div className="space-y-1">
|
||||
<Label>{t('rot.rotatorNum')}</Label>
|
||||
<Select value={String((rotator as any).rotator_num ?? 1)}
|
||||
onValueChange={(v) => setRotator((s) => ({ ...s, rotator_num: parseInt(v, 10) || 1 } as any))}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">1</SelectItem>
|
||||
<SelectItem value="2">2</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{rotors.length === 0 && <p className="text-sm text-muted-foreground">{t('rot.none')}</p>}
|
||||
{rotors.map((d, i) => {
|
||||
const dev = d as any;
|
||||
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="space-y-1">
|
||||
<Label>{t('rot.type')}</Label>
|
||||
{/* Each backend gets its default port: Rotator Genius 9006, ARCO 4001
|
||||
(placeholder — must match the ARCO's LAN menu), PstRotator 12000. */}
|
||||
<Select value={dev.type ?? 'pst'}
|
||||
onValueChange={(v) => patch(i, { type: v as any, port: v === 'rotgenius' ? 9006 : v === 'arco' ? 4001 : 12000 })}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="pst">PstRotator (UDP)</SelectItem>
|
||||
<SelectItem value="rotgenius">Rotator Genius (4O3A, native)</SelectItem>
|
||||
<SelectItem value="arco">GS-232A controller (microHAM ARCO, ERC…)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{isRG && !dev.dual && (
|
||||
<div className="space-y-1">
|
||||
<Label>{t('rot.rotatorNum')}</Label>
|
||||
<Select value={String(dev.rotator_num ?? 1)}
|
||||
onValueChange={(v) => patch(i, { rotator_num: parseInt(v, 10) || 1 })}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">1</SelectItem>
|
||||
<SelectItem value="2">2</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{/* The ARCO is reachable over the LAN (TCP) or its USB virtual COM. */}
|
||||
{isARCO && (
|
||||
<div className="space-y-1">
|
||||
<Label>Connection</Label>
|
||||
<Select value={transport} onValueChange={(v) => patch(i, { transport: v as any })}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="tcp">Network (LAN)</SelectItem>
|
||||
<SelectItem value="serial">USB (serial COM)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 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">
|
||||
<Label>COM port</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={dev.com_port || '_'} onValueChange={(v) => patch(i, { com_port: v === '_' ? '' : v })}>
|
||||
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
|
||||
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="sm" variant="outline" className="h-9" onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
||||
<ArrowDown className="size-3.5 rotate-90" />
|
||||
</Button>
|
||||
<Select value={String(dev.baud || 9600)} onValueChange={(v) => patch(i, { baud: Number(v) })}>
|
||||
<SelectTrigger className="h-9 w-28"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{[4800, 9600, 19200, 38400, 57600].map((b) => (
|
||||
<SelectItem key={b} value={String(b)}>{b} baud</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1 col-span-2">
|
||||
<Label>Host / IP</Label>
|
||||
<Input value={dev.host ?? ''} onChange={(e) => patch(i, { host: e.target.value })}
|
||||
placeholder={isRG || isARCO ? '192.168.1.60' : '127.0.0.1'} className="font-mono" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{isRG || isARCO ? 'TCP port' : 'UDP port'}</Label>
|
||||
<PortInput value={dev.port} onChange={(n) => patch(i, { port: n })}
|
||||
fallback={isRG ? 9006 : isARCO ? 4001 : 12000} className="font-mono" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isRG && !isARCO && (
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={!!dev.has_elevation} onCheckedChange={(c) => patch(i, { has_elevation: !!c })} />
|
||||
This rotator supports elevation (VHF / satellite)
|
||||
</label>
|
||||
)}
|
||||
{isRG && <p className="text-xs text-muted-foreground">{t('rot.rgHint')}</p>}
|
||||
{isARCO && <p className="text-xs text-muted-foreground">{t('rot.arcoHint')}</p>}
|
||||
{/* Which antenna this rotor carries — only relevant with >1 rotor. */}
|
||||
{multi && (
|
||||
<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>
|
||||
{isRG && dev.dual && (
|
||||
<Button variant="outline" size="sm" onClick={() => testRotator(d, 2)} disabled={rotatorTesting}>
|
||||
{t('rot.testRotor2')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* The ARCO is reachable over the LAN (TCP) or its USB virtual COM. */}
|
||||
{isARCO && (
|
||||
<div className="space-y-1">
|
||||
<Label>Connection</Label>
|
||||
<Select value={(rotator as any).transport ?? 'tcp'}
|
||||
onValueChange={(v) => setRotator((s) => ({ ...s, transport: v } as any))}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="tcp">Network (LAN)</SelectItem>
|
||||
<SelectItem value="serial">USB (serial COM)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isARCO && (rotator as any).transport === 'serial' ? (
|
||||
<div className="space-y-1 max-w-xs">
|
||||
<Label>COM port</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={(rotator as any).com_port || '_'}
|
||||
onValueChange={(v) => setRotator((s) => ({ ...s, com_port: v === '_' ? '' : v } as any))}>
|
||||
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
|
||||
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="sm" variant="outline" className="h-9" onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
||||
<ArrowDown className="size-3.5 rotate-90" />
|
||||
</Button>
|
||||
<Select value={String((rotator as any).baud || 9600)}
|
||||
onValueChange={(v) => setRotator((s) => ({ ...s, baud: Number(v) } as any))}>
|
||||
<SelectTrigger className="h-9 w-28"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{[4800, 9600, 19200, 38400, 57600].map((b) => (
|
||||
<SelectItem key={b} value={String(b)}>{b} baud</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1 col-span-2">
|
||||
<Label>Host / IP</Label>
|
||||
<Input
|
||||
value={rotator.host ?? ''}
|
||||
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 className="space-y-1">
|
||||
<Label>{isRG || isARCO ? 'TCP port' : 'UDP port'}</Label>
|
||||
<PortInput
|
||||
value={rotator.port}
|
||||
onChange={(n) => setRotator((s) => ({ ...s, port: n }))} fallback={isRG ? 9006 : isARCO ? 4001 : 12000}
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isRG && !isARCO && (
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={rotator.has_elevation} onCheckedChange={(c) => setRotator((s) => ({ ...s, has_elevation: !!c }))} />
|
||||
This rotator supports elevation (VHF / satellite)
|
||||
</label>
|
||||
)}
|
||||
{isRG && <p className="text-xs text-muted-foreground">{t('rot.rgHint')}</p>}
|
||||
{isARCO && <p className="text-xs text-muted-foreground">{t('rot.arcoHint')}</p>}
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<Button variant="outline" size="sm" onClick={testRotator} disabled={rotatorTesting}>
|
||||
{rotatorTesting ? t('hw.sending') : t('hw.testRotator')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => RotatorStop().catch((e) => setErr(String(e?.message ?? e)))} disabled={!rotator.enabled}>
|
||||
Stop
|
||||
</Button>
|
||||
{!isRG && (
|
||||
<Button variant="outline" size="sm" onClick={() => RotatorPark().catch((e) => setErr(String(e?.message ?? e)))} disabled={!rotator.enabled}>
|
||||
Park
|
||||
);
|
||||
})}
|
||||
<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
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{anyPst && (
|
||||
<Button variant="outline" size="sm" onClick={() => RotatorPark().catch((e) => setErr(String(e?.message ?? e)))}>
|
||||
Park
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{rotatorTest && (
|
||||
<div className={cn(
|
||||
'text-xs rounded-md p-2.5 border',
|
||||
@@ -3440,8 +3554,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
From the main entry strip, click the bearing pill to rotate to the short-path azimuth.
|
||||
Shift+click for long-path, Ctrl+click to stop.
|
||||
From the main entry strip, click the bearing pill to rotate the active rotor to the short-path
|
||||
azimuth. Shift+click for long-path, Ctrl+click to stop. With several rotors, pick the active one
|
||||
on the compass widget.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -13,7 +13,7 @@ import { TunerCard } from '@/components/TunerCard';
|
||||
import type { TGStatus } from '@/components/TunerGeniusPanel';
|
||||
import {
|
||||
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
|
||||
GetRotatorHeading, RotatorGoTo, RotatorStop,
|
||||
GetRotatorHeading, RotatorGoTo, RotatorStop, SetActiveRotor,
|
||||
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
||||
ListDenkoviDevices, ListSerialPorts, TestStationDevice,
|
||||
GetAmpStatuses, GetFlexState,
|
||||
@@ -47,7 +47,7 @@ function blankDevice(): Device {
|
||||
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
|
||||
// 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}
|
||||
centerLon={centerLon ?? null}
|
||||
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)}
|
||||
/>
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
|
||||
@@ -273,11 +273,11 @@ const en: Dict = {
|
||||
'cat.hint': "Reads the rig's frequency / band / mode and pushes them into the entry strip in real time. Use OmniRig (free, any rig) or — for FlexRadio — the native SmartSDR API (no OmniRig needed, real-time, no second-click mode bug).",
|
||||
'ag2.hint': 'OpsLog talks to the 4O3A Antenna Genius switch over TCP (GSCP protocol). The port is fixed at 9007, so only the device IP is needed. A docked widget then lets you switch antennas per port (A/B).', 'ag2.password': 'Remote password', 'ag2.passwordPh': 'blank on LAN', 'ag2.passwordHint': 'Only needed when reaching the device remotely — it then announces "AG AUTH" and rejects commands until you log in. Leave blank on the local network.',
|
||||
'tg2.hint': 'OpsLog talks to the 4O3A Tuner Genius XL over TCP (port fixed at 9010), so only the device IP is needed. A docked widget then shows SWR and forward power and offers Tune, Bypass and Operate/Standby. Control it directly (not through the radio) so OpsLog uses just one of the box\'s connection slots.', 'tg2.enable': 'Enable Tuner Genius control', 'tg2.portHint': 'The TCP port is fixed at 9010 on the device.', 'tg2.password': 'Remote code', 'tg2.passwordPh': 'blank on LAN', 'tg2.passwordHint': 'Only needed when reaching the device remotely — it then announces "AUTH" and rejects commands until you log in. Leave blank on the local network.',
|
||||
'rot.enable': 'Enable rotator control', 'rot.testOkRG': 'Connected — the Rotator Genius accepted the command (moving to 0°).', 'rot.type': 'Rotator type', 'rot.rotatorNum': 'Rotator #', 'rot.rgHint': 'Talks directly to a 4O3A Rotator Genius over TCP (default port 9006) — no PstRotator needed. Rotator # selects which of the two rotators to drive.', 'rot.arcoHint': "Talks directly to any controller set to Yaesu GS-232A — no PstRotator needed. microHAM ARCO: set Config → LAN → CONTROL PROTOCOL (or USB CONTROL PROTOCOL) to 'Yaesu GS-232A'; on USB the baud rate does not matter. ERC (Easy Rotor Control, incl. ERC Mini): its emulation MUST be set to GS-232 — an ERC left on Hy-Gain DCU-1 speaks a different command set and will not answer — then pick its COM port and the same baud rate as in the ERC configuration.", 'rot.hint': "OpsLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing.",
|
||||
'rot.enable': 'Enable rotator control', 'rot.testOkRG': 'Connected — the Rotator Genius accepted the command (moving to 0°).', 'rot.type': 'Rotator type', 'rot.rotatorNum': 'Rotator #', 'rot.dual': 'Two rotors', 'rot.rotor1': 'Rotor 1', 'rot.rotor2': 'Rotor 2', 'rot.name': 'Name', 'rot.antenna': 'Antenna', 'rot.antenna1': 'Rotor 1 antenna', 'rot.antenna2': 'Rotor 2 antenna', 'rot.name1': 'Rotor 1 name', 'rot.name2': 'Rotor 2 name', 'rot.antStd': 'Standard antenna', 'rot.antMotor': 'Motorized (Ultrabeam/SteppIR)', 'rot.add': 'Add rotor', 'rot.remove': 'Remove rotor', 'rot.none': 'No rotor configured. Click “Add rotor” to add one.', 'rot.rgDual': 'This Rotator Genius drives two rotors (adds a second one)', 'rot.testRotor1': 'Test rotor 1', 'rot.testRotor2': 'Test rotor 2', 'rot.dualHint': 'Two independent rotors of any type — for example an ARCO and a Rotator Genius side by side. Configure each below; OpsLog shows a Rotor 1/2 toggle on the compass to pick which one it turns and displays. Set each rotor to "Motorized" so the boom/pattern paths (reverse, bidirectional) show only for the one that carries the Ultrabeam/SteppIR. (For a single Rotator Genius driving two rotors, set both to Rotator Genius with the same host and rotator # 1 and 2.)', 'rot.rgHint': 'Talks directly to a 4O3A Rotator Genius over TCP (default port 9006) — no PstRotator needed. Rotator # selects which of the two rotators to drive.', 'rot.arcoHint': "Talks directly to any controller set to Yaesu GS-232A — no PstRotator needed. microHAM ARCO: set Config → LAN → CONTROL PROTOCOL (or USB CONTROL PROTOCOL) to 'Yaesu GS-232A'; on USB the baud rate does not matter. ERC (Easy Rotor Control, incl. ERC Mini): its emulation MUST be set to GS-232 — an ERC left on Hy-Gain DCU-1 speaks a different command set and will not answer — then pick its COM port and the same baud rate as in the ERC configuration.", 'rot.hint': "OpsLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing.",
|
||||
'extsvc.hint': 'Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 1–2 min delay so a mis-logged QSO can still be fixed first).',
|
||||
'hw.motorTxInhibit': 'Inhibit transmission while the antenna is moving', 'hw.motorTxInhibitHint': 'Blocks the FlexRadio from transmitting while the elements move (needs FlexRadio in API mode + this antenna enabled). No effect with other radios.', 'hw.motorAntenna': 'Ultrabeam / Steppir', 'hw.motorEnable': 'Enable antenna control', 'hw.motorType': 'Antenna type', 'hw.motorTransport': 'Connection', 'hw.motorTcp': 'Network (TCP)', 'hw.motorSerial': 'Serial (COM)', 'hw.motorCom': 'Serial port', 'hw.motorBaud': 'Baud', 'hw.steppirHint': 'SteppIR controllers are RS-232 serial (the DATA OUT DB9 port). Serial = a USB↔RS-232 (FTDI) adapter, shown as a COM port. Network = a serial-to-Ethernet bridge (as for the Ultrabeam).', 'hw.steppirRange': 'Tunable range', 'hw.steppirRangeHint': "The SteppIR's frequency coverage. On a band outside this range (e.g. 30 m on a 20 m–6 m SteppIR) OpsLog won't try to tune the antenna and won't inhibit transmission. Default 13–54 MHz (20 m–6 m); widen the low edge (e.g. 6) for a 40 m-equipped SteppIR.", 'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer',
|
||||
// CAT panel body
|
||||
'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig', 'cat.optFlex': 'FlexRadio (API)', 'cat.share': 'Share CAT with other programs', 'cat.shareHint': 'A native CAT backend owns the radio serial port, so no other program can reach it. This lets WSJT-X, JTDX, MSHV or Log4OM talk to the rig THROUGH OpsLog: in the other program pick the rig model "Hamlib NET rigctl" and enter 127.0.0.1:4532. Works with every backend, not only the native ones.', 'cat.sharePort': 'Sharing port', 'cat.optXiegu': 'Xiegu (USB)', 'cat.xieguPort': 'Xiegu CAT port', 'cat.xieguBaudHint': 'Must match the radio menu (G90 default: 19200).', 'cat.xieguAddrHint': 'Factory CI-V address of the G90/X6100 family: 0x70.', 'cat.xieguPTTLine': 'How the rig is keyed', 'cat.xieguPTTCiv': 'CI-V command', 'cat.xieguPTTHint': 'A G90 does not transmit on the CI-V command: interfaces like the DE-19 key it on RTS or DTR. Pick the line yours uses \u2014 it is also what lets WSJT-X transmit through the shared CAT link.', 'cat.optYaesu': 'Yaesu (USB)', 'cat.optKenwood': 'Kenwood (USB, network)', 'cat.civTrace': 'Log the CAT protocol', 'cat.civTraceHint': 'Writes every CAT frame to and from the radio into the log \u2014 CI-V as hex, Kenwood as the text it exchanges. For reporting a rig that answers oddly \u2014 a button that does the wrong thing, a frequency that jumps. Session only: it is a diagnostic, and it makes the log large.', 'cat.kenwoodPort': 'Kenwood COM port', 'cat.kenwoodPortHint': 'The rig\u2019s CAT/USB serial port (TS-590, TS-890, TS-990, TS-2000, and Elecraft K3/K4 which speak the same dialect).', 'cat.kenwoodBaudHint': 'Must match MENU on the radio: a TS-590 leaves the factory at 9600, a TS-890 at 115200.', 'cat.kenwoodHost': 'Or over the network (host:port)', 'cat.kenwoodHostHint': 'A serial-over-network bridge \u2014 ser2net, an Ethernet-serial adapter, a Raspberry Pi at the radio. Filled in, it is used INSTEAD of the COM port above. This is not the radio\u2019s own RJ45 socket, which speaks Kenwood\u2019s KNS protocol and is not supported.', 'cat.yaesuPort': 'Yaesu CAT port', 'cat.yaesuPortHint': 'The rig CAT port — on an FTDX10/FTDX101 over USB this is the ENHANCED COM port, not the standard one.', 'cat.yaesuBaudHint': 'Must match the radio menu (FTDX10/FTDX101 default: 38400).', 'cat.lowerLines': 'Lower the DTR and RTS lines on connect', 'cat.lowerLinesHint': 'Windows raises both when the port opens, and some interfaces read either line as PTT — the radio then transmits for as long as OpsLog runs. Tick this only if that happens: other interfaces need RTS raised to transmit at all, and lowering it makes the rig go silent.', 'cat.optIcom': 'Icom (CI-V USB)', 'cat.optIcomNet': 'Icom (CI-V network)', 'cat.optTci': 'TCI', 'flxpw.title': 'FlexRadio \u2014 power per band and mode', 'flxpw.hint': 'TX power applied when the band or mode changes. Leave a box empty to leave the power alone.', 'flxpw.band': 'Band', 'flxb.title': 'FlexRadio \u2014 per-band antennas and power', 'flxb.hint': 'Antennas are applied when the band changes; power when the band or the mode changes. Leave a power box empty to leave it alone.', 'flxb.rxAnt': 'RX antenna', 'flxb.txAnt': 'TX antenna', 'flxb.noRadio': 'No antennas reported yet \u2014 connect the FlexRadio, then reopen this panel.', 'flxpw.phone': 'Phone', 'flxpw.digi': 'Digital', 'flxpw.noBands': 'No bands configured \u2014 add them in Lists \u2192 Bands.', 'flxpw.saved': 'Saved',
|
||||
'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig', 'cat.optFlex': 'FlexRadio (API)', 'cat.share': 'Share CAT with other programs', 'cat.shareHint': 'A native CAT backend owns the radio serial port, so no other program can reach it. This lets WSJT-X, JTDX, MSHV or Log4OM talk to the rig THROUGH OpsLog: in the other program pick the rig model "Hamlib NET rigctl" and enter 127.0.0.1:4532. Works with every backend, not only the native ones.', 'cat.sharePort': 'Sharing port', 'cat.pttKey': 'Enable PTT hotkey', 'cat.pttKeyPress': 'Press a key…', 'cat.pttKeyNone': 'Click to set a key', 'cat.pttKeyClear': 'Clear', 'cat.pttKeyToggle': 'Toggle mode (press to key, press again to unkey)', 'cat.pttKeyHint': 'While OpsLog is focused, this key keys the transmitter — held down by default (release to stop), or latched in toggle mode. It uses the Audio → PTT method (CAT / RTS / DTR), falling back to CAT keying. Pick a key you never type while logging (e.g. Pause, ScrollLock, or a footswitch mapped to one) — OpsLog swallows it so it never lands in a field.', 'cat.optXiegu': 'Xiegu (USB)', 'cat.xieguPort': 'Xiegu CAT port', 'cat.xieguBaudHint': 'Must match the radio menu (G90 default: 19200).', 'cat.xieguAddrHint': 'Factory CI-V address of the G90/X6100 family: 0x70.', 'cat.xieguPTTLine': 'How the rig is keyed', 'cat.xieguPTTCiv': 'CI-V command', 'cat.xieguPTTHint': 'A G90 does not transmit on the CI-V command: interfaces like the DE-19 key it on RTS or DTR. Pick the line yours uses \u2014 it is also what lets WSJT-X transmit through the shared CAT link.', 'cat.optYaesu': 'Yaesu (USB)', 'cat.optKenwood': 'Kenwood (USB, network)', 'cat.civTrace': 'Log the CAT protocol', 'cat.civTraceHint': 'Writes every CAT frame to and from the radio into the log \u2014 CI-V as hex, Kenwood as the text it exchanges. For reporting a rig that answers oddly \u2014 a button that does the wrong thing, a frequency that jumps. Session only: it is a diagnostic, and it makes the log large.', 'cat.kenwoodPort': 'Kenwood COM port', 'cat.kenwoodPortHint': 'The rig\u2019s CAT/USB serial port (TS-590, TS-890, TS-990, TS-2000, and Elecraft K3/K4 which speak the same dialect).', 'cat.kenwoodBaudHint': 'Must match MENU on the radio: a TS-590 leaves the factory at 9600, a TS-890 at 115200.', 'cat.kenwoodHost': 'Or over the network (host:port)', 'cat.kenwoodHostHint': 'A serial-over-network bridge \u2014 ser2net, an Ethernet-serial adapter, a Raspberry Pi at the radio. Filled in, it is used INSTEAD of the COM port above. This is not the radio\u2019s own RJ45 socket, which speaks Kenwood\u2019s KNS protocol and is not supported.', 'cat.yaesuPort': 'Yaesu CAT port', 'cat.yaesuPortHint': 'The rig CAT port — on an FTDX10/FTDX101 over USB this is the ENHANCED COM port, not the standard one.', 'cat.yaesuBaudHint': 'Must match the radio menu (FTDX10/FTDX101 default: 38400).', 'cat.lowerLines': 'Lower the DTR and RTS lines on connect', 'cat.lowerLinesHint': 'Windows raises both when the port opens, and some interfaces read either line as PTT — the radio then transmits for as long as OpsLog runs. Tick this only if that happens: other interfaces need RTS raised to transmit at all, and lowering it makes the rig go silent.', 'cat.optIcom': 'Icom (CI-V USB)', 'cat.optIcomNet': 'Icom (CI-V network)', 'cat.optTci': 'TCI', 'flxpw.title': 'FlexRadio \u2014 power per band and mode', 'flxpw.hint': 'TX power applied when the band or mode changes. Leave a box empty to leave the power alone.', 'flxpw.band': 'Band', 'flxb.title': 'FlexRadio \u2014 per-band antennas and power', 'flxb.hint': 'Antennas are applied when the band changes; power when the band or the mode changes. Leave a power box empty to leave it alone.', 'flxb.rxAnt': 'RX antenna', 'flxb.txAnt': 'TX antenna', 'flxb.noRadio': 'No antennas reported yet \u2014 connect the FlexRadio, then reopen this panel.', 'flxpw.phone': 'Phone', 'flxpw.digi': 'Digital', 'flxpw.noBands': 'No bands configured \u2014 add them in Lists \u2192 Bands.', 'flxpw.saved': 'Saved',
|
||||
'cat.icomNetHost': 'Rig IP / hostname', 'cat.icomNetUser': 'Network user (ID)', 'cat.icomNetPass': 'Network password',
|
||||
'cat.icomNetHint': "Connects to the rig's built-in LAN server directly — no RS-BA1 or Remote Utility needed (close them first). Use the Network User1 ID/Password set in the rig's Network menu. A rig in standby is powered on automatically.",
|
||||
'cat.icomNetAudio': 'Stream RX audio over the network (experimental)',
|
||||
@@ -681,10 +681,10 @@ const fr: Dict = {
|
||||
'cat.hint': "Lit la fréquence / bande / mode du poste et les injecte dans le bandeau de saisie en temps réel. Utilise OmniRig (gratuit, tout poste) ou — pour FlexRadio — l'API native SmartSDR (sans OmniRig, temps réel, sans le bug du mode au 2ᵉ clic).",
|
||||
'ag2.hint': "OpsLog dialogue avec le switch 4O3A Antenna Genius en TCP (protocole GSCP). Le port est fixé à 9007, seule l'IP de l'appareil est nécessaire. Un widget ancré permet ensuite de commuter les antennes par port (A/B).", 'ag2.password': 'Mot de passe distant', 'ag2.passwordPh': 'vide en LAN', 'ag2.passwordHint': "Nécessaire seulement à distance — l'appareil annonce alors « AG AUTH » et refuse les commandes tant qu'on n'est pas identifié. Laisse vide sur le réseau local.",
|
||||
'tg2.hint': "OpsLog dialogue avec le 4O3A Tuner Genius XL en TCP (port fixé à 9010), seule l'IP de l'appareil est nécessaire. Un widget ancré affiche le ROS et la puissance directe et propose Accord, Bypass et Operate/Standby. Pilotage direct (pas via la radio) pour n'utiliser qu'une des connexions de la boîte.", 'tg2.enable': "Activer le contrôle du Tuner Genius", 'tg2.portHint': "Le port TCP est fixé à 9010 sur l'appareil.", 'tg2.password': 'Code distant', 'tg2.passwordPh': 'vide en LAN', 'tg2.passwordHint': "Nécessaire seulement à distance — l'appareil annonce alors « AUTH » et refuse les commandes tant qu'on n'est pas identifié. Laisse vide sur le réseau local.",
|
||||
'rot.enable': 'Activer le contrôle du rotator', 'rot.testOkRG': 'Connecté — le Rotator Genius a accepté la commande (rotation vers 0°).', 'rot.type': 'Type de rotator', 'rot.rotatorNum': 'Rotator n°', 'rot.rgHint': 'Parle directement à un Rotator Genius 4O3A en TCP (port 9006 par défaut) — sans PstRotator. Le n° choisit lequel des deux rotators du boîtier piloter.', 'rot.arcoHint': "Parle directement à tout contrôleur réglé sur Yaesu GS-232A — sans PstRotator. microHAM ARCO : régler Config → LAN → CONTROL PROTOCOL (ou USB CONTROL PROTOCOL) sur « Yaesu GS-232A » ; en USB la vitesse est sans importance. ERC (Easy Rotor Control, ERC Mini compris) : son émulation DOIT être réglée sur GS-232 — un ERC laissé en Hy-Gain DCU-1 parle un autre jeu de commandes et ne répondra pas — puis choisir son port COM et la même vitesse que dans la configuration de l'ERC.", 'rot.hint': "OpsLog envoie des commandes UDP à PstRotator. Active l'écouteur UDP de PstRotator (Setup → Communication → UDP) avant de tester.",
|
||||
'rot.enable': 'Activer le contrôle du rotator', 'rot.testOkRG': 'Connecté — le Rotator Genius a accepté la commande (rotation vers 0°).', 'rot.type': 'Type de rotator', 'rot.rotatorNum': 'Rotator n°', 'rot.dual': 'Deux rotors', 'rot.rotor1': 'Rotor 1', 'rot.rotor2': 'Rotor 2', 'rot.name': 'Nom', 'rot.antenna': 'Antenne', 'rot.antenna1': 'Antenne rotor 1', 'rot.antenna2': 'Antenne rotor 2', 'rot.name1': 'Nom du rotor 1', 'rot.name2': 'Nom du rotor 2', 'rot.antStd': 'Antenne standard', 'rot.antMotor': 'Motorisée (Ultrabeam/SteppIR)', 'rot.add': 'Ajouter un rotor', 'rot.remove': 'Supprimer le rotor', 'rot.none': 'Aucun rotor configuré. Cliquez sur « Ajouter un rotor ».', 'rot.rgDual': 'Ce Rotator Genius pilote deux rotors (en ajoute un second)', 'rot.testRotor1': 'Tester rotor 1', 'rot.testRotor2': 'Tester rotor 2', 'rot.dualHint': 'Deux rotors indépendants de n\'importe quel type — par exemple un ARCO et un Rotator Genius côte à côte. Configurez chacun ci-dessous ; OpsLog affiche un sélecteur Rotor 1/2 sur le compas pour choisir celui qu\'il tourne et affiche. Réglez chaque rotor sur « Motorisée » pour que les tracés de boom/diagramme (inverse, bidirectionnel) n\'apparaissent que pour celui qui porte l\'Ultrabeam/SteppIR. (Pour un seul Rotator Genius pilotant deux rotors, réglez les deux sur Rotator Genius avec le même hôte et le rotator n° 1 et 2.)', 'rot.rgHint': 'Parle directement à un Rotator Genius 4O3A en TCP (port 9006 par défaut) — sans PstRotator. Le n° choisit lequel des deux rotators du boîtier piloter.', 'rot.arcoHint': "Parle directement à tout contrôleur réglé sur Yaesu GS-232A — sans PstRotator. microHAM ARCO : régler Config → LAN → CONTROL PROTOCOL (ou USB CONTROL PROTOCOL) sur « Yaesu GS-232A » ; en USB la vitesse est sans importance. ERC (Easy Rotor Control, ERC Mini compris) : son émulation DOIT être réglée sur GS-232 — un ERC laissé en Hy-Gain DCU-1 parle un autre jeu de commandes et ne répondra pas — puis choisir son port COM et la même vitesse que dans la configuration de l'ERC.", 'rot.hint': "OpsLog envoie des commandes UDP à PstRotator. Active l'écouteur UDP de PstRotator (Setup → Communication → UDP) avant de tester.",
|
||||
'extsvc.hint': "Envoie les QSO enregistrés vers des carnets en ligne. Chaque service upload automatiquement à chaque nouveau QSO si activé ; le délai est propre à chaque service (immédiat, ou 1–2 min pour corriger un QSO mal saisi avant).",
|
||||
'hw.motorTxInhibit': "Inhiber la transmission pendant que l'antenne bouge", 'hw.motorTxInhibitHint': "Empêche le FlexRadio d'émettre pendant que les éléments bougent (nécessite le FlexRadio en API + cette antenne activée). Sans effet avec les autres radios.", 'hw.motorAntenna': 'Antenne motorisée', 'hw.motorEnable': "Activer le contrôle de l'antenne", 'hw.motorType': "Type d'antenne", 'hw.motorTransport': 'Connexion', 'hw.motorTcp': 'Réseau (TCP)', 'hw.motorSerial': 'Série (COM)', 'hw.motorCom': 'Port série', 'hw.motorBaud': 'Débit', 'hw.steppirHint': "Les contrôleurs SteppIR sont en RS-232 série (port DB9 « DATA OUT »). Série = un adaptateur USB↔RS-232 (FTDI), vu comme un port COM. Réseau = un pont série-Ethernet (comme pour l'Ultrabeam).", 'hw.steppirRange': 'Plage accordable', 'hw.steppirRangeHint': "La couverture en fréquence de la SteppIR. Sur une bande hors de cette plage (p. ex. 30 m avec une SteppIR 20 m-6 m), OpsLog n'essaie pas d'accorder l'antenne et n'inhibe pas l'émission. Défaut 13-54 MHz (20 m-6 m) ; abaisse la borne basse (p. ex. 6) pour une SteppIR équipée 40 m.", 'hw.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal',
|
||||
'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig', 'cat.optFlex': 'FlexRadio (API)', 'cat.share': 'Partager le CAT avec les autres logiciels', 'cat.shareHint': "Un backend CAT natif occupe le port série de la radio, aucun autre logiciel ne peut donc y accéder. Ceci permet à WSJT-X, JTDX, MSHV ou Log4OM de dialoguer avec la radio À TRAVERS OpsLog : dans l'autre logiciel, choisissez le modèle « Hamlib NET rigctl » et saisissez 127.0.0.1:4532. Fonctionne avec tous les backends, pas seulement les natifs.", 'cat.sharePort': 'Port de partage', 'cat.optXiegu': 'Xiegu (USB)', 'cat.xieguPort': 'Port CAT Xiegu', 'cat.xieguBaudHint': 'Doit correspondre au menu de la radio (G90 par défaut : 19200).', 'cat.xieguAddrHint': "Adresse CI-V d'usine de la famille G90/X6100 : 0x70.", 'cat.xieguPTTLine': 'Passage en \u00e9mission', 'cat.xieguPTTCiv': 'Commande CI-V', 'cat.xieguPTTHint': 'Un G90 ne passe pas en \u00e9mission sur la commande CI-V : les interfaces comme le DE-19 le pilotent par RTS ou DTR. Choisissez la ligne de la v\u00f4tre \u2014 c\u2019est aussi ce qui permet \u00e0 WSJT-X d\u2019\u00e9mettre via le partage CAT.', 'cat.optYaesu': 'Yaesu (USB)', 'cat.optKenwood': 'Kenwood (USB, réseau)', 'cat.civTrace': 'Journaliser le protocole CAT', 'cat.civTraceHint': '\u00c9crit dans le journal chaque trame CAT \u00e9chang\u00e9e avec la radio \u2014 CI-V en hexad\u00e9cimal, Kenwood en texte. Pour signaler une radio qui r\u00e9pond de travers \u2014 un bouton qui fait autre chose, une fr\u00e9quence qui saute. Valable pour la session seulement : c\u2019est un diagnostic, et le journal grossit vite.', 'cat.kenwoodPort': 'Port COM Kenwood', 'cat.kenwoodPortHint': 'Le port s\u00e9rie CAT/USB de la radio (TS-590, TS-890, TS-990, TS-2000, ainsi que les Elecraft K3/K4 qui parlent le m\u00eame dialecte).', 'cat.kenwoodBaudHint': 'Doit correspondre au MENU de la radio : un TS-590 sort d\u2019usine \u00e0 9600, un TS-890 \u00e0 115200.', 'cat.kenwoodHost': 'Ou par le r\u00e9seau (h\u00f4te:port)', 'cat.kenwoodHostHint': 'Un pont s\u00e9rie-r\u00e9seau \u2014 ser2net, un bo\u00eetier Ethernet-s\u00e9rie, un Raspberry Pi pr\u00e8s de la radio. S\u2019il est rempli, il est utilis\u00e9 \u00c0 LA PLACE du port COM ci-dessus. Il ne s\u2019agit pas de la prise RJ45 de la radio, qui parle le protocole KNS de Kenwood et n\u2019est pas prise en charge.', 'cat.yaesuPort': 'Port CAT Yaesu', 'cat.yaesuPortHint': "Le port CAT de la radio — sur un FTDX10/FTDX101 en USB c'est le port COM ENHANCED, pas le standard.", 'cat.yaesuBaudHint': 'Doit correspondre au menu de la radio (FTDX10/FTDX101 par défaut : 38400).', 'cat.lowerLines': 'Abaisser les lignes DTR et RTS à la connexion', 'cat.lowerLinesHint': 'Windows les lève à l’ouverture du port, et certaines interfaces lisent l’une ou l’autre comme PTT — la radio émet alors tant qu’OpsLog tourne. À cocher seulement dans ce cas : d’autres interfaces ont besoin de RTS haut pour émettre, et l’abaisser rend la radio muette.', 'cat.optIcom': 'Icom (CI-V USB)', 'cat.optIcomNet': 'Icom (CI-V réseau)', 'cat.optTci': 'TCI', 'flxpw.title': 'FlexRadio \u2014 puissance par bande et par mode', 'flxpw.hint': 'Puissance d\u2019\u00e9mission appliqu\u00e9e au changement de bande ou de mode. Laissez une case vide pour ne pas toucher \u00e0 la puissance.', 'flxpw.band': 'Bande', 'flxb.title': 'FlexRadio \u2014 antennes et puissance par bande', 'flxb.hint': 'Les antennes sont appliqu\u00e9es au changement de bande ; la puissance au changement de bande ou de mode. Laissez une case de puissance vide pour ne pas y toucher.', 'flxb.rxAnt': 'Antenne RX', 'flxb.txAnt': 'Antenne TX', 'flxb.noRadio': 'Aucune antenne remont\u00e9e \u2014 connectez le FlexRadio, puis rouvrez ce panneau.', 'flxpw.phone': 'Phonie', 'flxpw.digi': 'Num\u00e9rique', 'flxpw.noBands': 'Aucune bande configur\u00e9e \u2014 ajoutez-les dans Listes \u2192 Bandes.', 'flxpw.saved': 'Enregistr\u00e9',
|
||||
'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig', 'cat.optFlex': 'FlexRadio (API)', 'cat.share': 'Partager le CAT avec les autres logiciels', 'cat.shareHint': "Un backend CAT natif occupe le port série de la radio, aucun autre logiciel ne peut donc y accéder. Ceci permet à WSJT-X, JTDX, MSHV ou Log4OM de dialoguer avec la radio À TRAVERS OpsLog : dans l'autre logiciel, choisissez le modèle « Hamlib NET rigctl » et saisissez 127.0.0.1:4532. Fonctionne avec tous les backends, pas seulement les natifs.", 'cat.sharePort': 'Port de partage', 'cat.pttKey': 'Activer la touche PTT', 'cat.pttKeyPress': 'Appuyez sur une touche…', 'cat.pttKeyNone': 'Cliquez pour définir une touche', 'cat.pttKeyClear': 'Effacer', 'cat.pttKeyToggle': 'Mode bascule (appui = émission, nouvel appui = arrêt)', 'cat.pttKeyHint': "Quand OpsLog a le focus, cette touche passe la radio en émission — maintenue par défaut (relâcher pour arrêter), ou verrouillée en mode bascule. Elle utilise la méthode PTT de Audio → PTT (CAT / RTS / DTR), avec repli sur le CAT. Choisissez une touche que vous ne tapez jamais en journalisant (ex. Pause, Arrêt défil., ou une pédale mappée dessus) — OpsLog l'intercepte pour qu'elle n'atterrisse pas dans un champ.", 'cat.optXiegu': 'Xiegu (USB)', 'cat.xieguPort': 'Port CAT Xiegu', 'cat.xieguBaudHint': 'Doit correspondre au menu de la radio (G90 par défaut : 19200).', 'cat.xieguAddrHint': "Adresse CI-V d'usine de la famille G90/X6100 : 0x70.", 'cat.xieguPTTLine': 'Passage en \u00e9mission', 'cat.xieguPTTCiv': 'Commande CI-V', 'cat.xieguPTTHint': 'Un G90 ne passe pas en \u00e9mission sur la commande CI-V : les interfaces comme le DE-19 le pilotent par RTS ou DTR. Choisissez la ligne de la v\u00f4tre \u2014 c\u2019est aussi ce qui permet \u00e0 WSJT-X d\u2019\u00e9mettre via le partage CAT.', 'cat.optYaesu': 'Yaesu (USB)', 'cat.optKenwood': 'Kenwood (USB, réseau)', 'cat.civTrace': 'Journaliser le protocole CAT', 'cat.civTraceHint': '\u00c9crit dans le journal chaque trame CAT \u00e9chang\u00e9e avec la radio \u2014 CI-V en hexad\u00e9cimal, Kenwood en texte. Pour signaler une radio qui r\u00e9pond de travers \u2014 un bouton qui fait autre chose, une fr\u00e9quence qui saute. Valable pour la session seulement : c\u2019est un diagnostic, et le journal grossit vite.', 'cat.kenwoodPort': 'Port COM Kenwood', 'cat.kenwoodPortHint': 'Le port s\u00e9rie CAT/USB de la radio (TS-590, TS-890, TS-990, TS-2000, ainsi que les Elecraft K3/K4 qui parlent le m\u00eame dialecte).', 'cat.kenwoodBaudHint': 'Doit correspondre au MENU de la radio : un TS-590 sort d\u2019usine \u00e0 9600, un TS-890 \u00e0 115200.', 'cat.kenwoodHost': 'Ou par le r\u00e9seau (h\u00f4te:port)', 'cat.kenwoodHostHint': 'Un pont s\u00e9rie-r\u00e9seau \u2014 ser2net, un bo\u00eetier Ethernet-s\u00e9rie, un Raspberry Pi pr\u00e8s de la radio. S\u2019il est rempli, il est utilis\u00e9 \u00c0 LA PLACE du port COM ci-dessus. Il ne s\u2019agit pas de la prise RJ45 de la radio, qui parle le protocole KNS de Kenwood et n\u2019est pas prise en charge.', 'cat.yaesuPort': 'Port CAT Yaesu', 'cat.yaesuPortHint': "Le port CAT de la radio — sur un FTDX10/FTDX101 en USB c'est le port COM ENHANCED, pas le standard.", 'cat.yaesuBaudHint': 'Doit correspondre au menu de la radio (FTDX10/FTDX101 par défaut : 38400).', 'cat.lowerLines': 'Abaisser les lignes DTR et RTS à la connexion', 'cat.lowerLinesHint': 'Windows les lève à l’ouverture du port, et certaines interfaces lisent l’une ou l’autre comme PTT — la radio émet alors tant qu’OpsLog tourne. À cocher seulement dans ce cas : d’autres interfaces ont besoin de RTS haut pour émettre, et l’abaisser rend la radio muette.', 'cat.optIcom': 'Icom (CI-V USB)', 'cat.optIcomNet': 'Icom (CI-V réseau)', 'cat.optTci': 'TCI', 'flxpw.title': 'FlexRadio \u2014 puissance par bande et par mode', 'flxpw.hint': 'Puissance d\u2019\u00e9mission appliqu\u00e9e au changement de bande ou de mode. Laissez une case vide pour ne pas toucher \u00e0 la puissance.', 'flxpw.band': 'Bande', 'flxb.title': 'FlexRadio \u2014 antennes et puissance par bande', 'flxb.hint': 'Les antennes sont appliqu\u00e9es au changement de bande ; la puissance au changement de bande ou de mode. Laissez une case de puissance vide pour ne pas y toucher.', 'flxb.rxAnt': 'Antenne RX', 'flxb.txAnt': 'Antenne TX', 'flxb.noRadio': 'Aucune antenne remont\u00e9e \u2014 connectez le FlexRadio, puis rouvrez ce panneau.', 'flxpw.phone': 'Phonie', 'flxpw.digi': 'Num\u00e9rique', 'flxpw.noBands': 'Aucune bande configur\u00e9e \u2014 ajoutez-les dans Listes \u2192 Bandes.', 'flxpw.saved': 'Enregistr\u00e9',
|
||||
'cat.icomNetHost': 'IP / nom d\'hôte du poste', 'cat.icomNetUser': 'Utilisateur réseau (ID)', 'cat.icomNetPass': 'Mot de passe réseau',
|
||||
'cat.icomNetHint': "Se connecte directement au serveur LAN intégré du poste — sans RS-BA1 ni Remote Utility (ferme-les d'abord). Utilise l'ID/mot de passe Network User1 configurés dans le menu Network du poste. Un poste en veille est allumé automatiquement.",
|
||||
'cat.icomNetAudio': 'Diffuser l’audio RX par le réseau (expérimental)',
|
||||
|
||||
Vendored
+9
-3
@@ -469,7 +469,7 @@ export function GetRelayAuto():Promise<main.RelayAutoConfig>;
|
||||
|
||||
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>;
|
||||
|
||||
@@ -707,6 +707,10 @@ export function PGXLSetFanMode(arg1:string):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 PickAudioFolder():Promise<string>;
|
||||
@@ -879,7 +883,7 @@ export function SaveQSLDefaults(arg1:main.QSLDefaults):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>;
|
||||
|
||||
@@ -909,6 +913,8 @@ export function SendLogToDeveloper():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 SetCATFrequency(arg1:number):Promise<void>;
|
||||
@@ -1011,7 +1017,7 @@ export function TestPTT(arg1:main.AudioSettings):Promise<void>;
|
||||
|
||||
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>;
|
||||
|
||||
|
||||
@@ -886,8 +886,8 @@ export function GetRotatorHeading() {
|
||||
return window['go']['main']['App']['GetRotatorHeading']();
|
||||
}
|
||||
|
||||
export function GetRotatorSettings() {
|
||||
return window['go']['main']['App']['GetRotatorSettings']();
|
||||
export function GetRotators() {
|
||||
return window['go']['main']['App']['GetRotators']();
|
||||
}
|
||||
|
||||
export function GetSPEStatus() {
|
||||
@@ -1362,6 +1362,14 @@ export function 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() {
|
||||
return window['go']['main']['App']['PickADIFMonitorFile']();
|
||||
}
|
||||
@@ -1706,8 +1714,8 @@ export function SaveRelayAuto(arg1) {
|
||||
return window['go']['main']['App']['SaveRelayAuto'](arg1);
|
||||
}
|
||||
|
||||
export function SaveRotatorSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveRotatorSettings'](arg1);
|
||||
export function SaveRotators(arg1) {
|
||||
return window['go']['main']['App']['SaveRotators'](arg1);
|
||||
}
|
||||
|
||||
export function SaveStationDevices(arg1) {
|
||||
@@ -1766,6 +1774,10 @@ export function SendQSORecordingEmail(arg1) {
|
||||
return window['go']['main']['App']['SendQSORecordingEmail'](arg1);
|
||||
}
|
||||
|
||||
export function SetActiveRotor(arg1) {
|
||||
return window['go']['main']['App']['SetActiveRotor'](arg1);
|
||||
}
|
||||
|
||||
export function SetAlertEmailTo(arg1) {
|
||||
return window['go']['main']['App']['SetAlertEmailTo'](arg1);
|
||||
}
|
||||
@@ -1970,8 +1982,8 @@ export function TestQRZUpload() {
|
||||
return window['go']['main']['App']['TestQRZUpload']();
|
||||
}
|
||||
|
||||
export function TestRotator(arg1) {
|
||||
return window['go']['main']['App']['TestRotator'](arg1);
|
||||
export function TestRotatorDevice(arg1, arg2) {
|
||||
return window['go']['main']['App']['TestRotatorDevice'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function TestStationDevice(arg1) {
|
||||
|
||||
@@ -1945,6 +1945,9 @@ export namespace main {
|
||||
digital_default: string;
|
||||
share_enabled: boolean;
|
||||
share_port: number;
|
||||
ptt_hotkey_enabled: boolean;
|
||||
ptt_hotkey: string;
|
||||
ptt_hotkey_toggle: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new CATSettings(source);
|
||||
@@ -1987,6 +1990,9 @@ export namespace main {
|
||||
this.digital_default = source["digital_default"];
|
||||
this.share_enabled = source["share_enabled"];
|
||||
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 {
|
||||
@@ -2804,11 +2810,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 {
|
||||
enabled: boolean;
|
||||
ok: boolean;
|
||||
azimuth: number;
|
||||
raw: string;
|
||||
rotors: string[];
|
||||
active: number;
|
||||
motorized: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new RotatorHeading(source);
|
||||
@@ -2820,34 +2867,9 @@ export namespace main {
|
||||
this.ok = source["ok"];
|
||||
this.azimuth = source["azimuth"];
|
||||
this.raw = source["raw"];
|
||||
}
|
||||
}
|
||||
export class RotatorSettings {
|
||||
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"];
|
||||
this.rotors = source["rotors"];
|
||||
this.active = source["active"];
|
||||
this.motorized = source["motorized"];
|
||||
}
|
||||
}
|
||||
export class ScpStatus {
|
||||
|
||||
@@ -28,6 +28,12 @@ type TCI struct {
|
||||
digitalDefault string // surfaced when the rig reports a digital mode (FT8/…)
|
||||
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
|
||||
conn *websocket.Conn
|
||||
dialCancel context.CancelFunc // cancels an in-flight Connect dial (Interrupt/Stop)
|
||||
@@ -294,6 +300,32 @@ func (t *TCI) handle(msg string) {
|
||||
if get(0) == "0" {
|
||||
t.tx = get(1) == "true"
|
||||
}
|
||||
default:
|
||||
lname := strings.ToLower(name)
|
||||
// A click on one of our panorama spots comes back as a "spot…" message.
|
||||
// The exact shape isn't well documented, so read a callsign (+ freq) from
|
||||
// any spot-related message and fire the callback. The log line lets us pin
|
||||
// the real format against a radio if it differs.
|
||||
if strings.HasPrefix(lname, "spot") {
|
||||
debugLog.Printf("TCI: spot event: %s", msg)
|
||||
call := get(0)
|
||||
hz, _ := strconv.ParseInt(get(2), 10, 64)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user