From b3fdd0b7fa20f01e12f51f5c3a04f28797da782f Mon Sep 17 00:00:00 2001 From: Gregory Salaun Date: Tue, 4 Aug 2026 09:00:54 +0200 Subject: [PATCH] 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. --- app.go | 740 ++++++++++++------ changelog.json | 14 + frontend/src/App.tsx | 79 +- frontend/src/components/RotorCompass.tsx | 30 +- frontend/src/components/SettingsModal.tsx | 381 +++++---- .../src/components/StationControlPanel.tsx | 7 +- frontend/src/lib/i18n.tsx | 8 +- frontend/wailsjs/go/main/App.d.ts | 12 +- frontend/wailsjs/go/main/App.js | 24 +- frontend/wailsjs/go/models.ts | 78 +- internal/cat/tci.go | 32 + 11 files changed, 964 insertions(+), 441 deletions(-) diff --git a/app.go b/app.go index 6b913b3..60f8d58 100644 --- a/app.go +++ b/app.go @@ -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) } diff --git a/changelog.json b/changelog.json index 31bab49..78dd05b 100644 --- a/changelog.json +++ b/changelog.json @@ -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": "", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6e763c9..a550325 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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('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(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(() => { 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'); }} /> diff --git a/frontend/src/components/RotorCompass.tsx b/frontend/src/components/RotorCompass.tsx index 95e6ff4..d8beb75 100644 --- a/frontend/src/components/RotorCompass.tsx +++ b/frontend/src/components/RotorCompass.tsx @@ -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 )} + {/* Multiple rotors — pick which one the dial shows and turns. */} + {rotors && rotors.length > 1 && ( +
+ {rotors.map((nm, i) => { + const active = (activeRotor ?? 0) === i; + const label = nm?.trim() || `Rotor ${i + 1}`; + return ( + + ); + })} +
+ )} +
; -type RotatorSettings = Omit; +type RotatorDevice = Omit; type ClusterServer = Omit; type ClusterServerStatus = Omit; type Profile = Omit; @@ -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({ - 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([]); 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
)} + {/* 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. */} +
+ + {(catCfg as any).ptt_hotkey_enabled && ( + <> +
+ + {(catCfg as any).ptt_hotkey && !capturingPtt && ( + + )} +
+ +

{t('cat.pttKeyHint')}

+ + )} +
{catCfg.backend === 'omnirig' && ( <>