From 2dd68da284b943198413b611147c4b3001fb3d28 Mon Sep 17 00:00:00 2001 From: rouggy Date: Sun, 23 Aug 2026 18:49:40 +0200 Subject: [PATCH] feat(flex): chase a split pile-up on the skimmer's report marker Working a DXpedition split means guessing where it listens. The useful information is not the callsign the DX answered but the FREQUENCY that station was calling on, and a CW skimmer already marks it: SDC posts each decoded report to the panadapter as a spot. Those spots reach OpsLog through the radio's spot feed. On a marker, the TRANSMIT slice moves there plus a signed offset; the receive slice never moves, because losing the DX is worse than missing a call. The marker text is a setting -- it is chosen in SDC by the operator, so any constant here would be wrong for whoever chose otherwise -- and the switch is a button beside SPLIT, since it is turned on when a DXpedition appears and off when it is worked. Moves are throttled and ignore a marker landing where the slice already is: a busy pile-up produces several reports a second and the slice would otherwise never be anywhere long enough to call. The spot feed is now subscribed to unconditionally. It was tied to OpsLog's own spot overlay, so an operator running SDC with the overlay off received nothing -- the reason no foreign spot was ever logged. The connect-time 'spot clear' stays behind the overlay flag: it wipes every spot on the radio, a skimmer's included. --- app.go | 3 + changelog.json | 8 +- flexrstchase.go | 178 ++++++++++++++++++++++++++ frontend/src/components/FlexPanel.tsx | 42 ++++++ frontend/src/lib/i18n.tsx | 4 +- frontend/wailsjs/go/main/App.d.ts | 6 + frontend/wailsjs/go/main/App.js | 12 ++ frontend/wailsjs/go/models.ts | 18 +++ internal/cat/cat.go | 2 + internal/cat/flex.go | 91 ++++++++++++- 10 files changed, 356 insertions(+), 8 deletions(-) create mode 100644 flexrstchase.go diff --git a/app.go b/app.go index 48df10d..837ebed 100644 --- a/app.go +++ b/app.go @@ -15140,6 +15140,9 @@ func (a *App) reloadCAT() { // looking at the same signal, and only the click's origin differs. a.FlexZoomForSpot(mode, hz) } + // Spots posted by ANOTHER program — a CW skimmer, in practice. See + // flexrstchase.go: a marked report is where the DX was just listening. + fb.OnForeignSpot = a.handleForeignSpot a.cat.Start(fb) case "xiegu": // Xiegu G90/X6100/X6200/X5105 — CI-V, but a REDUCED command set: no scope, diff --git a/changelog.json b/changelog.json index d89b1cc..fb83cb8 100644 --- a/changelog.json +++ b/changelog.json @@ -9,7 +9,9 @@ "Deleting QSOs now says how many rows were actually removed, and writes it to the log. A delete that removes nothing used to look exactly like one that worked.", "Deleting QSOs is fast again when 'delete from the remote services' is on. Club Log is only asked about contacts that were actually uploaded to it — asking about the others earned a 403 each time, one network round trip per QSO — the withdrawals now run in the background instead of holding the window, the rows behind a selection are read in one query rather than one per QSO, and repeated refusals stop the run rather than earning a longer block.", "The HAMLOG.online confirmation import now fills the Results view with the contacts it confirmed, flagged new entity / band / mode / slot, and can export the unmatched ones as ADIF. Those are the interesting half: each is a contact their site holds and the log does not confirm — a minute of drift, a portable call, or a QSO genuinely missing.", - "Awards: 'county' joins the searchable QSO fields — the CNTY field as it stands. The existing 'us_county' keys the county to its state, which is right for the United States and wrong everywhere else: an RDA district or a Japanese city code is already unique." + "Awards: 'county' joins the searchable QSO fields — the CNTY field as it stands. The existing 'us_county' keys the county to its state, which is right for the United States and wrong everywhere else: an RDA district or a Japanese city code is already unique.", + "FlexRadio: a split pile-up chaser. When a CW skimmer (SDC) marks a report on the panadapter, OpsLog moves the TRANSMIT slice there — where the DX was listening a second ago — with a signed offset in Hz. The receive slice never moves. The marker text is yours to set (599 by default, whatever SDC is configured to write), and the button lives on the FlexRadio panel next to SPLIT, not in a settings page.", + "The radio's spot feed is now subscribed to even when OpsLog draws no spots of its own — that feed is how another program's spots arrive. Clearing the panadapter at connect still only happens when the overlay is OpsLog's: 'spot clear' removes every spot on the radio, a skimmer's included." ], "fr": [ "Édition de QSO : HAMLOG.online rejoint l'onglet QSL Info — son propre canal dans la liste, avec envoyé/reçu et les deux dates, et une ligne dans le tableau de statut. Ses quatre colonnes passent aussi du groupe QSL au groupe Uploads, à côté de Club Log et QRZ.com.", @@ -18,7 +20,9 @@ "La suppression de QSO indique maintenant combien de lignes ont réellement été supprimées, et l'écrit dans le journal. Une suppression sans effet ressemblait exactement à une suppression réussie.", "La suppression de QSO redevient rapide quand « supprimer aussi des services externes » est activé. Club Log n'est plus interrogé que pour les contacts qui y ont réellement été envoyés — pour les autres il répondait 403, un aller-retour réseau par QSO — les retraits se font désormais en arrière-plan au lieu de bloquer la fenêtre, les lignes d'une sélection sont lues en une seule requête au lieu d'une par QSO, et une série de refus interrompt le traitement au lieu d'aggraver le blocage.", "L'import des confirmations HAMLOG.online alimente maintenant la vue Résultats avec les contacts confirmés, marqués nouvelle entité / bande / mode / slot, et peut exporter les non-rapprochés en ADIF. C'est la moitié intéressante : chacun est un contact que leur site détient et que le journal ne confirme pas — minute décalée, indicatif portable, ou QSO réellement absent.", - "Diplômes : « county » rejoint les champs de QSO interrogeables — le champ CNTY tel quel. Le « us_county » existant associe le comté à son État, ce qui est juste aux États-Unis et faux ailleurs : un district RDA ou un code de ville japonais est déjà unique." + "Diplômes : « county » rejoint les champs de QSO interrogeables — le champ CNTY tel quel. Le « us_county » existant associe le comté à son État, ce qui est juste aux États-Unis et faux ailleurs : un district RDA ou un code de ville japonais est déjà unique.", + "FlexRadio : chasseur de pile-up en split. Quand un skimmer CW (SDC) marque un report sur le panadapter, OpsLog déplace la slice d'ÉMISSION dessus — là où le DX écoutait une seconde plus tôt — avec un décalage signé en Hz. La slice de réception ne bouge jamais. Le texte du marqueur se règle (599 par défaut, ce que SDC est configuré à écrire), et le bouton est sur le panneau FlexRadio à côté de SPLIT, pas dans les réglages.", + "Le flux de spots de la radio est désormais suivi même si OpsLog n'affiche aucun spot : c'est par lui qu'arrivent les spots des autres programmes. Le nettoyage du panadapter à la connexion reste réservé au cas où l'affichage est celui d'OpsLog : « spot clear » efface tous les spots de la radio, y compris ceux d'un skimmer." ] }, { diff --git a/flexrstchase.go b/flexrstchase.go new file mode 100644 index 0000000..bc39139 --- /dev/null +++ b/flexrstchase.go @@ -0,0 +1,178 @@ +package main + +// Chasing a split pile-up by the report the DX just sent. +// +// Working a DXpedition in split means guessing where it is listening. The DX +// answers one station, sends "5NN", and moves on; the useful information is +// therefore not the callsign it answered but the FREQUENCY that callsign was +// transmitting on, because the DX's receiver was there a second ago. +// +// A CW skimmer already knows this. SDC (Software Defined Connector) decodes the +// whole pile-up and marks each report on the panadapter as a spot — the marker +// TEXT is configured in SDC by the operator ("599" for a fresh report, "X" for +// an older one, by default). Those spots reach OpsLog already: it subscribes to +// the radio's spot feed, and every spot another program posts arrives on +// Flex.OnForeignSpot. +// +// So the whole feature is: recognise the marker, move the TRANSMIT slice there, +// leave the receive slice on the DX. Nothing here decodes anything. +// +// Three deliberate choices: +// +// - the marker text is a SETTING, not a constant. It is chosen in SDC, and +// any guess made here would be wrong for the operator who chose otherwise. +// - only the transmit slice moves, never the receive slice. Losing the DX is +// a worse outcome than a missed call. +// - the offset is signed and in Hz, because working "up a bit" from where the +// last station was answered is exactly how a pile-up is chased. + +import ( + "encoding/json" + "strconv" + "strings" + "sync" + "time" + + "hamlog/internal/applog" + "hamlog/internal/cat" +) + +const keyFlexRSTChase = "flex.rst_chase" + +// FlexRSTChase is the whole configuration. +type FlexRSTChase struct { + Enabled bool `json:"enabled"` + // Markers are the skimmer's marker texts, comma-separated ("599,5NN"). + // Matched against the spot's callsign field, case-insensitively and whole: + // a spot IS the marker or it is an ordinary callsign, and a substring rule + // would drag in any station whose call happens to contain the digits. + Markers string `json:"markers"` + // OffsetHz is added to the marker's frequency. Signed: chasing upward from + // the last station worked is the usual tactic, downward happens too. + OffsetHz int `json:"offset_hz"` + // SplitOnly refuses to act when the radio is not in split. On by default: + // out of split the transmit slice IS the receive slice, so "move the TX + // slice" would take the operator off the DX they are listening to. + SplitOnly bool `json:"split_only"` +} + +var defaultFlexRSTChase = FlexRSTChase{Enabled: false, Markers: "599", OffsetHz: 0, SplitOnly: true} + +// rstChaseMinGap throttles the moves. A skimmer marks every report it decodes, +// and a busy pile-up produces several a second; without a floor the transmit +// slice would twitch continuously and never be anywhere long enough to call. +const rstChaseMinGap = 700 * time.Millisecond + +// rstChaseMinStep ignores a marker that lands where the slice already is. +// Re-sending the same frequency is not free: it is a command to the radio and a +// slice status back, several times a second, for no change at all. +const rstChaseMinStep = 20 // Hz + +var ( + rstChaseMu sync.Mutex + rstChaseLast time.Time + rstChaseFreq int64 +) + +// GetFlexRSTChase returns the stored configuration (defaults when unset). +func (a *App) GetFlexRSTChase() FlexRSTChase { + s := defaultFlexRSTChase + if a.settings == nil { + return s + } + if v, _ := a.settings.Get(a.ctx, a.profileScope()+keyFlexRSTChase); strings.TrimSpace(v) != "" { + _ = json.Unmarshal([]byte(v), &s) + } + return normRSTChase(s) +} + +// SaveFlexRSTChase stores the configuration. +func (a *App) SaveFlexRSTChase(s FlexRSTChase) error { + b, err := json.Marshal(normRSTChase(s)) + if err != nil { + return err + } + a.setSetting(keyFlexRSTChase, string(b)) + return nil +} + +// SetFlexRSTChaseEnabled flips the switch alone. +// +// Its own binding because this belongs on a button in the panel, not in a +// settings page: it is turned on when a DXpedition appears and off when it is +// worked, which is a thing done mid-QSO with one hand. +func (a *App) SetFlexRSTChaseEnabled(on bool) error { + s := a.GetFlexRSTChase() + s.Enabled = on + return a.SaveFlexRSTChase(s) +} + +func normRSTChase(s FlexRSTChase) FlexRSTChase { + if strings.TrimSpace(s.Markers) == "" { + s.Markers = defaultFlexRSTChase.Markers + } + // A pile-up is a few kHz wide. Anything past that is a typo (Hz entered as + // if it were kHz), and honouring it would transmit far outside the segment. + if s.OffsetHz > 10000 { + s.OffsetHz = 10000 + } + if s.OffsetHz < -10000 { + s.OffsetHz = -10000 + } + return s +} + +// rstChaseMarkerSet splits the configured markers into a comparison set. +func rstChaseMarkerSet(markers string) map[string]bool { + out := map[string]bool{} + for _, m := range strings.FieldsFunc(markers, func(r rune) bool { return r == ',' || r == ';' || r == ' ' }) { + if m = strings.ToUpper(strings.TrimSpace(m)); m != "" { + out[m] = true + } + } + return out +} + +// handleForeignSpot is what a skimmer's spot arrives at. +func (a *App) handleForeignSpot(callsign string, freqHz int64) { + cfg := a.GetFlexRSTChase() + if !cfg.Enabled || a.cat == nil { + return + } + if !rstChaseMarkerSet(cfg.Markers)[strings.ToUpper(strings.TrimSpace(callsign))] { + return // an ordinary spot: another station's callsign, not a report + } + if cfg.SplitOnly && !a.cat.State().Split { + return + } + target := freqHz + int64(cfg.OffsetHz) + if target <= 0 { + return + } + now := time.Now() + rstChaseMu.Lock() + if now.Sub(rstChaseLast) < rstChaseMinGap { + rstChaseMu.Unlock() + return + } + if rstChaseFreq != 0 && absInt64(target-rstChaseFreq) < rstChaseMinStep { + rstChaseMu.Unlock() + return + } + rstChaseLast, rstChaseFreq = now, target + rstChaseMu.Unlock() + + if err := a.cat.FlexDo(func(fc cat.FlexController) error { + return fc.SetTXSliceFrequency(target) + }); err != nil { + applog.Printf("rst chase: moving the TX slice to %s failed: %v", hzText(target), err) + return + } + applog.Printf("rst chase: %s marked at %s → TX slice to %s (offset %+d Hz)", + strings.ToUpper(callsign), hzText(freqHz), hzText(target), cfg.OffsetHz) +} + +// hzText renders a frequency the way an operator reads one on a dial. +func hzText(hz int64) string { + return strconv.FormatFloat(float64(hz)/1000, 'f', 3, 64) + " kHz" +} diff --git a/frontend/src/components/FlexPanel.tsx b/frontend/src/components/FlexPanel.tsx index e70b47a..bfcdc7d 100644 --- a/frontend/src/components/FlexPanel.tsx +++ b/frontend/src/components/FlexPanel.tsx @@ -8,6 +8,7 @@ import { GetTunerGeniusStatus, GetTunerGeniusSettings, GetAmpStatuses, AmpOperate, AmpPower, AmpPowerLevel, FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice, + GetFlexRSTChase, SaveFlexRSTChase, SetFlexRSTChaseEnabled, FlexSetRIT, FlexSetRITFreq, FlexSetXIT, FlexSetXITFreq, FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel, FlexSetLMSNR, FlexSetLMSNRLevel, FlexSetLMSANF, FlexSetLMSANFLevel, @@ -304,6 +305,17 @@ function powerLevelLabel(pl?: string): string { // host can keep the WinKeyer (which actually sends the macros) in sync. export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number) => void; onReportRST?: (rst: string) => void } = {}) { const { t } = useI18n(); + // Split-pile-up chaser (see flexrstchase.go). Held here so the button can + // paint its state without polling the backend for it. + const [rstChase, setRstChase] = useState<{ enabled: boolean; markers: string; offset_hz: number; split_only: boolean }>( + { enabled: false, markers: '599', offset_hz: 0, split_only: true }); + const [rstChaseOffsetText, setRstChaseOffsetText] = useState('0'); + useEffect(() => { + GetFlexRSTChase().then((c: any) => { + if (!c) return; + setRstChase(c); setRstChaseOffsetText(String(c.offset_hz ?? 0)); + }).catch(() => {}); + }, []); const [st, setSt] = useState(ZERO); // Extra/"advanced" DSP rows (WNB + the SmartSDR v4 NRL/NRS/NRF/ANFL/AI-FFT // block) collapse behind a button so the RECEIVE card doesn't grow tall — only @@ -652,6 +664,36 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number st.split ? 'bg-info text-info-foreground border-info shadow-[0_0_12px] shadow-info/50' : 'bg-card text-info border-info hover:bg-info-muted')}> SPLIT + {/* The split chaser. On the panel and not in a settings page: + it is switched on when a DXpedition appears and off when it is + in the log, which is done mid-QSO with one hand. The offset + sits beside it because it is the one number retuned while + chasing — everything else about the feature is configured once. */} + + {rstChase.enabled && ( + + )} {st.split && !!st.tx_freq_hz && ( TX {(st.tx_freq_hz / 1e6).toFixed(3)} diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 35fff86..d852f53 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -426,7 +426,7 @@ const en: Dict = { 'tgp.online': 'online', 'tgp.offline': 'offline', 'tgp.close': 'Close', 'tgp.connecting': 'Connecting…', 'tgp.swr': 'SWR', 'tgp.power': 'Fwd power', 'tgp.tune': 'Tune', 'tgp.tuning': 'Tuning…', 'tgp.tuneHint': 'Start an automatic tuning cycle on the active channel — key the rig into a carrier so the tuner can measure SWR', 'tgp.bypass': 'Bypass', 'tgp.bypassHint': 'Toggle global bypass — route the antenna straight through, tuner out of line', 'tgp.operate': 'Operate', 'tgp.standby': 'Standby', 'tgp.operateHint': 'Toggle Operate / Standby', 'tgp.title': 'Tuner Genius', 'tgp.chActive': 'Channel {letter} — active', 'tgp.chSelect': 'Make channel {letter} active', 'tgp.chActiveTag': 'active', 'tgp.ant': 'Ant', 'tgp.bypassed': 'Bypassed', 'tgp.inLine': 'In line', 'flxp.ritHint': 'RIT — shifts your RECEIVE frequency only. Wheel, ± or arrow keys to adjust (Ctrl = 100 Hz). The offset is kept when you switch it off.', 'flxp.xitHint': 'XIT — shifts your TRANSMIT frequency only. Wheel, ± or arrow keys to adjust (Ctrl = 100 Hz). The offset is kept when you switch it off.', - 'flxp.smartsdrRemote': 'SmartSDR remote control', 'flxp.offline': 'OFFLINE', 'flxp.waiting': 'Waiting for the FlexRadio… (set CAT to FlexRadio and connect)', 'flxp.transmit': 'Transmit', 'flxp.rfPower': 'RF Power', 'flxp.tunePwr': 'Tune Pwr', 'flxp.splitHint': 'Split: RX/TX on separate slices. ON creates a TX slice +1 kHz (CW) / +5 kHz (SSB) up, like SmartSDR.', 'flxp.sliceHint': 'Click to make this the active slice — frequency, mode, DSP and spot-clicks all follow it.', 'flxp.txSlice': 'This slice transmits', 'flxp.setTxSlice': 'Move TX to this slice (transmit here)', 'flxp.voxDly': 'VOX Dly', 'flxp.speed': 'Speed', 'flxp.pitch': 'Pitch', 'flxp.delay': 'Delay', + 'flxp.smartsdrRemote': 'SmartSDR remote control', 'flxp.offline': 'OFFLINE', 'flxp.waiting': 'Waiting for the FlexRadio… (set CAT to FlexRadio and connect)', 'flxp.transmit': 'Transmit', 'flxp.rfPower': 'RF Power', 'flxp.tunePwr': 'Tune Pwr', 'flxp.rstChaseHint': 'Chase the pile-up: when the CW skimmer marks a report ({m}) on the panadapter, move the TRANSMIT slice there — that is where the DX was listening a second ago. The receive slice never moves.', 'flxp.rstChaseOffset': 'offset', 'flxp.splitHint': 'Split: RX/TX on separate slices. ON creates a TX slice +1 kHz (CW) / +5 kHz (SSB) up, like SmartSDR.', 'flxp.sliceHint': 'Click to make this the active slice — frequency, mode, DSP and spot-clicks all follow it.', 'flxp.txSlice': 'This slice transmits', 'flxp.setTxSlice': 'Move TX to this slice (transmit here)', 'flxp.voxDly': 'VOX Dly', 'flxp.speed': 'Speed', 'flxp.pitch': 'Pitch', 'flxp.delay': 'Delay', 'flxp.receiveActive': 'Receive (active slice)', 'flxp.muted': 'Muted — click to unmute', 'flxp.mute': 'Mute RX audio', 'flxp.filter': 'Filter', 'flxp.amplifier': 'Amplifier', 'flxp.atuTune': 'TUNE', 'flxp.atuTuneHint': 'Start a tuning cycle on the built-in ATU. The radio keys a carrier itself to measure the match.', 'flxp.atuBypass': 'BYPASS', 'flxp.atuBypassHint': 'Take the ATU out of line (straight through).', 'flxp.atuMem': 'MEM', 'flxp.atuMemHint': 'Reuse the stored tuning solution for this frequency instead of tuning again.', 'flxp.atuIdle': 'not tuned', 'flxp.atuTuning': 'tuning…', 'flxp.atuOk': 'tuned', 'flxp.atuFail': 'TUNE FAILED', 'flxp.atuBypassed': 'bypassed', 'flxp.atuAborted': 'aborted', 'flxp.ampInLine': 'Amplifier is in line (transmitting through PA).', 'flxp.ampBypassed': 'Amplifier bypassed (standby).', 'flxp.pgConnected': 'PowerGenius connected', 'flxp.pgOffline': 'PowerGenius offline', 'flxp.fan': 'Fan', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Broadcast', 'flxp.fault': 'FAULT', 'flxp.meters': 'Meters', 'flxp.voltage': 'VOLTAGE', 'flxp.paTemp': 'PA TEMP', 'flxp.txFilter': 'TX filter', 'flxp.micProfile': 'Mic profile', 'flxp.noMeters': "No meters yet — waiting for the radio's UDP stream…", 'flxp.amplifierHdr': 'AMPLIFIER', 'flxp.outputPower': 'OUTPUT POWER', 'flxp.speOffline': 'SPE offline', 'flxp.acomOffline': 'ACOM offline', 'flxp.ampPick': 'Choose which amplifier this card shows', 'flxp.dspV4Hint': 'SmartSDR v4 DSP (8000/Aurora series)', 'flxp.daxHint': 'DAX as the transmit audio source (SmartSDR transmit-bar DAX button) — for WSJT-X & co', 'flxp.rnnHint': 'RNN — AI noise reduction (on/off)', 'flxp.anftHint': 'ANFT — FFT-based automatic notch filter (on/off)', 'flxp.dspNoise': 'Noise', 'flxp.dspMore': 'Show/hide advanced DSP (WNB, v4 NR/notch)', 'icmp.spectrum': 'Spectrum', 'icmp.scopeFixed': 'Fixed — double-click / wheel to tune', 'icmp.scopeCenter': 'Center — follows VFO', 'icmp.scopeOff': 'Scope off', 'icmp.scopePanDown': 'Shift scope −50 kHz', 'icmp.scopePanUp': 'Shift scope +50 kHz', 'icmp.scopeCenterVfo': 'Center scope on the current frequency (±50 kHz)', 'icmp.notConnected': "Icom not connected. Enable the Icom CI-V backend in Settings → CAT and connect the radio's USB port.", 'icmp.refresh': 'Refresh', 'icmp.meters': 'Meters', 'icmp.transmit': 'Transmit', 'icmp.power': 'Power', 'icmp.mic': 'Mic', 'icmp.receive': 'Receive', 'icmp.preamp': 'Preamp', 'icmp.filter': 'Filter', 'icmp.noiseNotch': 'Noise / Notch', 'icmp.autoNotch': 'Auto notch filter', 'icmp.apf': 'Audio peak filter (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Wheel or ± to shift · Ctrl+←/→ shifts RIT when active', 'icmp.bandsAntenna': 'Bands & Antenna', 'icmp.bandCurrent': 'The rig is on {b} m', 'icmp.antenna': 'Antenna', 'icmp.passband': 'Passband / Notch', 'icmp.pbtCenter': 'Center PBT', 'icmp.manualNotch': 'Manual notch — MN on, then set position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Power the radio ON (boots ~15 s)', 'icmp.powerOffHint': 'Power the radio OFF', 'icmp.powerOffConfirm': 'Switch the radio OFF?', 'rst.clickToFill': 'Click to set RST tx from the signal', @@ -898,7 +898,7 @@ const fr: Dict = { 'tgp.online': 'en ligne', 'tgp.offline': 'hors ligne', 'tgp.close': 'Fermer', 'tgp.connecting': 'Connexion…', 'tgp.swr': 'ROS', 'tgp.power': 'Puiss. directe', 'tgp.tune': 'Accord', 'tgp.tuning': 'Accord…', 'tgp.tuneHint': "Lancer un cycle d'accord automatique sur le canal actif — passe la radio en porteuse pour que le coupleur mesure le ROS", 'tgp.bypass': 'Bypass', 'tgp.bypassHint': "Basculer le bypass global — antenne en direct, coupleur hors ligne", 'tgp.operate': 'Operate', 'tgp.standby': 'Standby', 'tgp.operateHint': 'Basculer Operate / Standby', 'tgp.title': 'Tuner Genius', 'tgp.chActive': 'Canal {letter} — actif', 'tgp.chSelect': 'Activer le canal {letter}', 'tgp.chActiveTag': 'actif', 'tgp.ant': 'Ant', 'tgp.bypassed': 'Bypass', 'tgp.inLine': 'En ligne', 'flxp.ritHint': "RIT — décale uniquement ta fréquence de RÉCEPTION. Molette, ± ou flèches pour ajuster (Ctrl = 100 Hz). Le décalage est conservé quand tu l'éteins.", 'flxp.xitHint': "XIT — décale uniquement ta fréquence d'ÉMISSION. Molette, ± ou flèches pour ajuster (Ctrl = 100 Hz). Le décalage est conservé quand tu l'éteins.", - 'flxp.smartsdrRemote': 'Contrôle à distance SmartSDR', 'flxp.offline': 'HORS LIGNE', 'flxp.waiting': 'En attente du FlexRadio… (règle le CAT sur FlexRadio et connecte)', 'flxp.transmit': 'Émission', 'flxp.rfPower': 'Puissance RF', 'flxp.tunePwr': 'Puiss. TUNE', 'flxp.splitHint': 'Split : RX/TX sur des slices séparées. ON crée une slice TX +1 kHz (CW) / +5 kHz (SSB) au-dessus, comme SmartSDR.', 'flxp.sliceHint': 'Cliquer pour rendre cette slice active — fréquence, mode, DSP et clics de spot la suivent tous.', 'flxp.txSlice': 'Cette slice émet', 'flxp.setTxSlice': 'Déplacer le TX sur cette slice (émettre ici)', 'flxp.voxDly': 'Délai VOX', 'flxp.speed': 'Vitesse', 'flxp.pitch': 'Tonalité', 'flxp.delay': 'Délai', + 'flxp.smartsdrRemote': 'Contrôle à distance SmartSDR', 'flxp.offline': 'HORS LIGNE', 'flxp.waiting': 'En attente du FlexRadio… (règle le CAT sur FlexRadio et connecte)', 'flxp.transmit': 'Émission', 'flxp.rfPower': 'Puissance RF', 'flxp.tunePwr': 'Puiss. TUNE', 'flxp.rstChaseHint': "Chasser le pile-up : quand le skimmer CW marque un report ({m}) sur le panadapter, déplacer la slice d'ÉMISSION dessus — c'est là que le DX écoutait il y a une seconde. La slice de réception ne bouge jamais.", 'flxp.rstChaseOffset': 'décalage', 'flxp.splitHint': 'Split : RX/TX sur des slices séparées. ON crée une slice TX +1 kHz (CW) / +5 kHz (SSB) au-dessus, comme SmartSDR.', 'flxp.sliceHint': 'Cliquer pour rendre cette slice active — fréquence, mode, DSP et clics de spot la suivent tous.', 'flxp.txSlice': 'Cette slice émet', 'flxp.setTxSlice': 'Déplacer le TX sur cette slice (émettre ici)', 'flxp.voxDly': 'Délai VOX', 'flxp.speed': 'Vitesse', 'flxp.pitch': 'Tonalité', 'flxp.delay': 'Délai', 'flxp.receiveActive': 'Réception (slice active)', 'flxp.muted': 'Coupé — clic pour rétablir', 'flxp.mute': "Couper l'audio RX", 'flxp.filter': 'Filtre', 'flxp.amplifier': 'Amplificateur', 'flxp.atuTune': 'ACCORD', 'flxp.atuTuneHint': "Lance un cycle d'accord sur le coupleur intégré. La radio émet elle-même une porteuse pour mesurer l'adaptation.", 'flxp.atuBypass': 'BYPASS', 'flxp.atuBypassHint': 'Sort le coupleur de la ligne (passage direct).', 'flxp.atuMem': 'MEM', 'flxp.atuMemHint': "Réutilise l'accord mémorisé pour cette fréquence au lieu de refaire un cycle.", 'flxp.atuIdle': 'non accordé', 'flxp.atuTuning': 'accord en cours…', 'flxp.atuOk': 'accordé', 'flxp.atuFail': 'ÉCHEC ACCORD', 'flxp.atuBypassed': 'contourné', 'flxp.atuAborted': 'interrompu', 'flxp.ampInLine': 'Amplificateur en ligne (émission via le PA).', 'flxp.ampBypassed': 'Amplificateur en bypass (standby).', 'flxp.pgConnected': 'PowerGenius connecté', 'flxp.pgOffline': 'PowerGenius hors ligne', 'flxp.fan': 'Ventilo', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Diffusion', 'flxp.fault': 'DÉFAUT', 'flxp.meters': 'Mesures', 'flxp.voltage': 'TENSION', 'flxp.paTemp': 'TEMP PA', 'flxp.txFilter': 'Filtre TX', 'flxp.micProfile': 'Profil micro', 'flxp.noMeters': 'Aucune mesure — en attente du flux UDP de la radio…', 'flxp.amplifierHdr': 'AMPLIFICATEUR', 'flxp.outputPower': 'PUISSANCE DE SORTIE', 'flxp.speOffline': 'SPE hors ligne', 'flxp.acomOffline': 'ACOM hors ligne', 'flxp.ampPick': 'Choisir quel amplificateur cette carte affiche', 'flxp.dspV4Hint': 'DSP SmartSDR v4 (séries 8000/Aurora)', 'flxp.daxHint': "DAX comme source audio d'émission (bouton DAX du bandeau transmit de SmartSDR) — pour WSJT-X & co", 'flxp.rnnHint': 'RNN — réduction de bruit par IA (on/off)', 'flxp.anftHint': 'ANFT — filtre notch automatique FFT (on/off)', 'flxp.dspNoise': 'Bruit', 'flxp.dspMore': 'Afficher/masquer le DSP avancé (WNB, NR/notch v4)', 'icmp.spectrum': 'Spectre', 'icmp.scopeFixed': 'Fixe — double-clic / molette pour accorder', 'icmp.scopeCenter': 'Centré — suit le VFO', 'icmp.scopeOff': 'Scope éteint', 'icmp.scopePanDown': 'Décaler le scope −50 kHz', 'icmp.scopePanUp': 'Décaler le scope +50 kHz', 'icmp.scopeCenterVfo': 'Centrer le scope sur la fréquence actuelle (±50 kHz)', 'icmp.notConnected': 'Icom non connecté. Active le backend CI-V Icom dans Réglages → CAT et connecte le port USB de la radio.', 'icmp.refresh': 'Rafraîchir', 'icmp.meters': 'Mesures', 'icmp.transmit': 'Émission', 'icmp.power': 'Puissance', 'icmp.mic': 'Micro', 'icmp.receive': 'Réception', 'icmp.preamp': 'Préampli', 'icmp.filter': 'Filtre', 'icmp.noiseNotch': 'Bruit / Notch', 'icmp.autoNotch': 'Filtre notch auto', 'icmp.apf': 'Filtre de pic audio (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Molette ou ± pour décaler · Ctrl+←/→ décale le RIT si actif', 'icmp.bandsAntenna': 'Bandes & Antenne', 'icmp.bandCurrent': 'Le poste est sur {b} m', 'icmp.antenna': 'Antenne', 'icmp.passband': 'Passe-bande / Notch', 'icmp.pbtCenter': 'Centrer PBT', 'icmp.manualNotch': 'Notch manuel — active MN, puis règle la position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Allumer la radio (démarre en ~15 s)', 'icmp.powerOffHint': 'Éteindre la radio', 'icmp.powerOffConfirm': 'Éteindre la radio ?', 'rst.clickToFill': 'Clic pour remplir le RST tx depuis le signal', diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 7df299b..9d93581 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -471,6 +471,8 @@ export function GetFlexBandAntennas():Promise>; export function GetFlexBandPower():Promise>; +export function GetFlexRSTChase():Promise; + export function GetFlexState():Promise; export function GetFlexZoom():Promise; @@ -991,6 +993,8 @@ export function SaveFlexBandAntennas(arg1:Record):Prom export function SaveFlexBandPower(arg1:Record):Promise; +export function SaveFlexRSTChase(arg1:main.FlexRSTChase):Promise; + export function SaveFlexZoom(arg1:main.FlexZoomSettings):Promise; export function SaveFolderSync(arg1:main.FolderSyncConfig):Promise; @@ -1093,6 +1097,8 @@ export function SetCompactMode(arg1:boolean):Promise; export function SetDVKLabel(arg1:number,arg2:string):Promise; +export function SetFlexRSTChaseEnabled(arg1:boolean):Promise; + export function SetKenwoodKeySpeed(arg1:number):Promise; export function SetLinkedAmps(arg1:Array):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index bd5c087..9314247 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -882,6 +882,10 @@ export function GetFlexBandPower() { return window['go']['main']['App']['GetFlexBandPower'](); } +export function GetFlexRSTChase() { + return window['go']['main']['App']['GetFlexRSTChase'](); +} + export function GetFlexState() { return window['go']['main']['App']['GetFlexState'](); } @@ -1922,6 +1926,10 @@ export function SaveFlexBandPower(arg1) { return window['go']['main']['App']['SaveFlexBandPower'](arg1); } +export function SaveFlexRSTChase(arg1) { + return window['go']['main']['App']['SaveFlexRSTChase'](arg1); +} + export function SaveFlexZoom(arg1) { return window['go']['main']['App']['SaveFlexZoom'](arg1); } @@ -2126,6 +2134,10 @@ export function SetDVKLabel(arg1, arg2) { return window['go']['main']['App']['SetDVKLabel'](arg1, arg2); } +export function SetFlexRSTChaseEnabled(arg1) { + return window['go']['main']['App']['SetFlexRSTChaseEnabled'](arg1); +} + export function SetKenwoodKeySpeed(arg1) { return window['go']['main']['App']['SetKenwoodKeySpeed'](arg1); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 72edde9..662e53a 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -2537,6 +2537,24 @@ export namespace main { this.body = source["body"]; } } + export class FlexRSTChase { + enabled: boolean; + markers: string; + offset_hz: number; + split_only: boolean; + + static createFrom(source: any = {}) { + return new FlexRSTChase(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.enabled = source["enabled"]; + this.markers = source["markers"]; + this.offset_hz = source["offset_hz"]; + this.split_only = source["split_only"]; + } + } export class FlexZoomSettings { enabled: boolean; cw_khz: number; diff --git a/internal/cat/cat.go b/internal/cat/cat.go index b1de1bc..f27d692 100644 --- a/internal/cat/cat.go +++ b/internal/cat/cat.go @@ -487,6 +487,8 @@ type FlexController interface { // keeps freqMHz inside it, re-centring when it must. See Flex.ZoomPan. ZoomPan(bandwidthMHz, freqMHz float64, centre bool) error SetTXSlice(int) error // make slice idx the transmitter (tx=1) + // SetTXSliceFrequency moves the TRANSMIT slice only — split pile-up chasing. + SetTXSliceFrequency(int64) error SetSplit(bool) error SetNB(bool) error SetNBLevel(int) error diff --git a/internal/cat/flex.go b/internal/cat/flex.go index e549757..e4a76ac 100644 --- a/internal/cat/flex.go +++ b/internal/cat/flex.go @@ -95,6 +95,16 @@ type Flex struct { // the spot, since the radio's own notification carries only an index. The host // wires this to fill the entry form and to size the panadapter. Set before Connect. OnSpotClick func(callsign string, freqHz int64, mode string) + + // OnForeignSpot is called for every spot posted to the radio by a program + // OTHER than OpsLog, with the spot's callsign field and its frequency. + // + // A CW skimmer posts what it decodes, so the marker a DX operator's report + // leaves on the panadapter arrives here — that is what the split chaser acts + // on. Deliberately raw: this package reports what the radio said and does not + // decide what a marker looks like, because the marker text is configured in + // the skimmer, by the operator, and only they know what they chose. + OnForeignSpot func(callsign string, freqHz int64) } // panView is one panadapter's visible window, in MHz. @@ -281,11 +291,18 @@ func (f *Flex) Connect() error { f.send("sub pan all") // panadapter centre/bandwidth, so a zoom knows where the display already is f.send("sub client all") // learn the GUI client (SmartSDR) so we can bind to it (below) f.startMeters(conn) // open the UDP VITA-49 stream for live meters + // Always subscribed, even when OpsLog draws no spots of its own: the feed is + // read-only and it is how the spots posted by OTHER programs arrive — a CW + // skimmer's decoded reports, which the split chaser acts on. Tying the + // subscription to our own overlay meant a station using SDC and no OpsLog + // spots heard nothing at all. + f.send("sub spot all") if f.spotsEnabled { - // Subscribe so the radio pushes existing spots (we learn their indices), - // then wipe the panadapter so stale spots from a previous session or - // another logger are cleared before we start adding our own. - f.send("sub spot all") + // Wipe the panadapter so stale spots from a previous session or another + // logger are cleared before we start adding our own. Only when the + // overlay is ours to manage: "spot clear" removes EVERY spot on the + // radio, a skimmer's included, and taking those away from an operator + // who never asked us to draw anything would be pure vandalism. go f.clearSpotsOnConnect(conn) } return nil @@ -888,6 +905,7 @@ func (f *Flex) handleStatus(payload string) { f.mu.Unlock() if !removed { f.probeForeignSpot(payload) + f.reportForeignSpot(payload) } } debugLog.Printf("Flex: status %s", payload) @@ -1417,6 +1435,71 @@ func (f *Flex) probeForeignSpot(payload string) { } } +// reportForeignSpot hands another program's spot to OnForeignSpot. +// +// Off the reader goroutine, like OnSpotClick: the handler tunes the radio, and +// a command sent from inside the reader would deadlock against the socket it is +// reading. +func (f *Flex) reportForeignSpot(payload string) { + if strings.Contains(payload, "source=OpsLog") { + return + } + handler := f.OnForeignSpot + if handler == nil { + return + } + var call string + var hz int64 + for _, kv := range strings.Fields(payload) { + eq := strings.IndexByte(kv, '=') + if eq <= 0 { + continue + } + switch kv[:eq] { + case "callsign": + call = kv[eq+1:] + case "rx_freq": + if mhz, err := strconv.ParseFloat(kv[eq+1:], 64); err == nil { + hz = int64(math.Round(mhz * 1e6)) + } + } + } + if call == "" || hz <= 0 { + return + } + go handler(call, hz) +} + +// SetTXSliceFrequency tunes the TRANSMIT slice, leaving the receive slice where +// it is. That distinction is the whole point in split: the operator listens to +// the DX on one slice and moves the other around the pile-up. +func (f *Flex) SetTXSliceFrequency(hz int64) error { + if hz <= 0 { + return fmt.Errorf("flex: invalid frequency") + } + f.mu.Lock() + idx := -1 + for i, s := range f.slices { + if s != nil && s.inUse && s.tx { + idx = i + break + } + } + if idx >= 0 && f.slices[idx] != nil { + f.slices[idx].freqHz = hz // optimistic, like SetFrequency + } + connected := f.conn != nil + f.mu.Unlock() + if idx < 0 { + return fmt.Errorf("flex: no transmit slice") + } + if !connected { + return fmt.Errorf("flex: not connected") + } + f.send(fmt.Sprintf("slice t %d %.6f", idx, float64(hz)/1e6)) + return nil +} + // SendSpot renders a cluster spot on the panadapter via "spot add". Spots carry // a lifetime so the radio expires them on its own (the API has no "spot clear"). // Per the SmartSDR API, spaces inside a field value are encoded as 0x7F.