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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user