diff --git a/app.go b/app.go index b02fb3c..15baa51 100644 --- a/app.go +++ b/app.go @@ -51,6 +51,7 @@ import ( "hamlog/internal/qslcard" "hamlog/internal/qso" "hamlog/internal/relaydev" + "hamlog/internal/rigctld" "hamlog/internal/rotator/gs232" "hamlog/internal/rotator/pst" "hamlog/internal/rotgenius" @@ -108,6 +109,8 @@ const ( keyCATPollMs = "cat.poll_ms" keyCATDelayMs = "cat.delay_ms" // pause between commands keyCATDigitalDefault = "cat.digital_default" // mode to use when CAT reports DATA + keyCATShareEnabled = "cat.share.enabled" // expose CAT to other programs (Hamlib NET rigctl) + keyCATSharePort = "cat.share.port" // TCP port for that server (rigctld default 4532) keyCATYaesuPort = "cat.yaesu.port" // Yaesu CAT serial port (e.g. COM4) keyCATYaesuBaud = "cat.yaesu.baud" // Yaesu CAT baud (FTDX10/101 default 38400) keyCATIcomPort = "cat.icom.port" // Icom USB CI-V serial port (e.g. COM5) @@ -359,6 +362,8 @@ type CATSettings struct { PollMs int `json:"poll_ms"` // poll interval in ms (default 250) DelayMs int `json:"delay_ms"` // pause between commands (default 0) 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) } // ModePreset is a mode entry with default RST values to auto-populate @@ -474,6 +479,11 @@ type App struct { lookup *lookup.Manager cache *lookup.Cache cat *cat.Manager + // catShare serves OpsLog's CAT link to other programs over the Hamlib NET + // rigctl protocol. It exists because a native backend OWNS the rig's serial + // port: without it, choosing native CAT locks WSJT-X and friends out of the + // radio entirely. nil when the operator has not enabled sharing. + catShare *rigctld.Server dxcc *dxcc.Manager cluster *cluster.Manager // Cluster spots/lines are processed OFF the socket-read goroutine. Enriching a @@ -1431,6 +1441,12 @@ func (a *App) shutdown(ctx context.Context) { // backend: without this the rig never gets a disconnect and holds its single // control session for minutes, refusing every new login (even from the Icom // Remote Utility) until it times out on its own. + if a.catShare != nil { + // Before the CAT stop, so no client is mid-command against a backend that + // is disconnecting — and so the port is free for the next launch. + a.catShare.Stop() + a.catShare = nil + } if a.cat != nil { a.cat.Stop() } @@ -6566,7 +6582,7 @@ 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, keyCATYaesuPort, keyCATYaesuBaud, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault) + m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATYaesuPort, keyCATYaesuBaud, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort) if err != nil { return CATSettings{}, err } @@ -6594,6 +6610,8 @@ func (a *App) GetCATSettings() (CATSettings, error) { 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 @@ -6604,6 +6622,9 @@ func (a *App) GetCATSettings() (CATSettings, error) { if n, _ := strconv.Atoi(m[keyCATTCIPort]); n > 0 && n <= 65535 { out.TCIPort = n } + if n, _ := strconv.Atoi(m[keyCATSharePort]); n > 0 && n <= 65535 { + out.SharePort = n + } if n, _ := strconv.Atoi(m[keyCATYaesuBaud]); n > 0 { out.YaesuBaud = n } @@ -6648,6 +6669,9 @@ func (a *App) SaveCATSettings(s CATSettings) error { if s.FlexPort <= 0 || s.FlexPort > 65535 { s.FlexPort = 4992 } + if s.SharePort <= 0 || s.SharePort > 65535 { + s.SharePort = 4532 + } if s.YaesuBaud <= 0 { s.YaesuBaud = 38400 } @@ -6689,6 +6713,10 @@ func (a *App) SaveCATSettings(s CATSettings) error { if s.DigitalDefault == "" { s.DigitalDefault = "FT8" } + shareEnabled := "0" + if s.ShareEnabled { + shareEnabled = "1" + } for k, v := range map[string]string{ keyCATEnabled: enabled, keyCATBackend: s.Backend, @@ -6714,6 +6742,8 @@ func (a *App) SaveCATSettings(s CATSettings) error { 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 @@ -12051,6 +12081,7 @@ func (a *App) reloadCAT() { a.catFlexSpots = s.Enabled && ((s.Backend == "flex" && s.FlexSpots) || (s.Backend == "tci" && s.TCISpots)) a.catFlexDecodeSpots = s.Enabled && s.Backend == "flex" && s.FlexDecodeSpots a.catFlexDecodeSecs = s.FlexDecodeSecs + a.reloadCATShare(s) if !s.Enabled { a.cat.Stop() return @@ -14776,3 +14807,54 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus { } return out } + +// ── CAT sharing (Hamlib NET rigctl server) ──────────────────────────────── + +// catShareRig adapts the CAT manager to what the rigctld server needs. A thin +// interface rather than a direct dependency, so the protocol stays testable +// without a radio — and so sharing works with EVERY backend, not just the +// native ones: an operator on OmniRig or Flex gets the same server. +type catShareRig struct{ a *App } + +func (r catShareRig) Freq() int64 { return r.a.cat.State().FreqHz } +func (r catShareRig) Mode() string { return r.a.cat.State().Mode } + +// Split reports the flag and the OTHER VFO's frequency. RigState follows ADIF — +// FreqHz is where we TRANSMIT and RxFreqHz where we listen — while a rigctl +// client asks for the split TX frequency, which is FreqHz. Getting this pair +// backwards would make a client transmit on the listening frequency. +func (r catShareRig) Split() (bool, int64) { + st := r.a.cat.State() + if !st.Split { + return false, 0 + } + return true, st.FreqHz +} + +func (r catShareRig) SetFreq(hz int64) error { return r.a.cat.SetFrequency(hz) } +func (r catShareRig) SetMode(m string) error { return r.a.cat.SetMode(m) } +func (r catShareRig) SetPTT(on bool) error { return r.a.cat.SetPTT(on) } + +// reloadCATShare starts, stops or restarts the sharing server to match the +// settings. Called from reloadCAT so one "Save & Close" settles both. +func (a *App) reloadCATShare(s CATSettings) { + want := s.Enabled && s.ShareEnabled + // Always tear down first: the port may have changed, and a listener bound to + // the old one would keep answering while the client is told to use the new. + if a.catShare != nil { + a.catShare.Stop() + a.catShare = nil + } + if !want { + return + } + srv := rigctld.New(s.SharePort, catShareRig{a: a}, applog.Printf) + if err := srv.Start(); err != nil { + // The usual cause is another rigctld — or a previous OpsLog — already on + // the port. Logged rather than surfaced: CAT itself is unaffected, and the + // operator finds it in the log the moment a client fails to connect. + applog.Printf("cat share: %v", err) + return + } + a.catShare = srv +} diff --git a/changelog.json b/changelog.json index 4706c1e..725a162 100644 --- a/changelog.json +++ b/changelog.json @@ -3,6 +3,7 @@ "version": "0.21.9", "date": "2026-07-28", "en": [ + "CAT sharing: OpsLog can now serve its rig connection to other programs (Settings → CAT → Share CAT). WSJT-X, JTDX, MSHV or Log4OM connect with rig model \"Hamlib NET rigctl\" at 127.0.0.1:4532 — needed because a native CAT backend holds the radio serial port on its own. Works with every backend.", "Native Yaesu CAT: a new backend talks to an FTDX10/FTDX101 directly over its serial port, without OmniRig — frequency, mode, VFO A/B, split and PTT. Pick \"Yaesu (native CAT)\" in Settings → CAT. First release, feedback welcome.", "Changing band from OpsLog now updates the displayed frequency straight away. The rig moved, but its answer arrived inside the short grace window that protects what you are typing and was discarded — so the frequency stayed on the old band until you nudged the VFO.", "Bulk edit can now set the contacted station gridsquare, so a batch of QSOs imported without a locator can be corrected in one go.", @@ -13,6 +14,7 @@ "CQ and ITU zones you correct by hand in your profile stay corrected. They are derived from cty.dat, which gives the zones of the whole entity — in a country spanning several, the automatic value is wrong and it came back at every restart. They now only fill in when empty, and recompute when the callsign or grid changes." ], "fr": [ + "Partage du CAT : OpsLog peut désormais servir sa liaison radio aux autres logiciels (Réglages → CAT → Partager le CAT). WSJT-X, JTDX, MSHV ou Log4OM s'y connectent avec le modèle « Hamlib NET rigctl » sur 127.0.0.1:4532 — nécessaire car un backend CAT natif occupe seul le port série. Fonctionne avec tous les backends.", "CAT Yaesu natif : un nouveau backend dialogue directement avec un FTDX10/FTDX101 par son port série, sans OmniRig — fréquence, mode, VFO A/B, split et PTT. À choisir dans Réglages → CAT sous « Yaesu (CAT natif) ». Première version, retours bienvenus.", "Changer de bande depuis OpsLog met désormais la fréquence affichée à jour immédiatement. La radio se déplaçait bien, mais sa réponse arrivait pendant le court délai qui protège votre saisie et était ignorée — la fréquence restait donc sur l'ancienne bande jusqu'à ce qu'on touche le VFO.", "L'édition groupée peut désormais définir le locator de la station contactée : un lot de QSO importés sans locator se corrige en une fois.", diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 1d8f40c..b75984d 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -1064,7 +1064,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan yaesu_port: '', yaesu_baud: 38400, 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', + digital_default: 'FT8', share_enabled: false, share_port: 4532, }); const [rotator, setRotator] = useState({ enabled: false, type: 'pst', host: '127.0.0.1', port: 12000, has_elevation: false, rotator_num: 1, @@ -2590,6 +2590,28 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan + {/* CAT sharing. A native backend owns the rig's serial port, so without + this WSJT-X and friends are locked out of the radio entirely. */} +
+ +

{t('cat.shareHint')}

+ {catCfg.share_enabled && ( +
+ + setCatCfg((s) => ({ ...s, share_port: n }))} + /> +
+ )} +
{catCfg.backend === 'omnirig' && ( <>