diff --git a/app.go b/app.go index 235d3d8..099ae85 100644 --- a/app.go +++ b/app.go @@ -176,7 +176,7 @@ const ( keyWKUsePTT = "winkeyer.use_ptt" keyWKSerialEcho = "winkeyer.serial_echo" keyWKMacros = "winkeyer.macros" // JSON array of {label,text} - keyWKEngine = "winkeyer.engine" // "winkeyer" | "tci" + keyWKEngine = "winkeyer.engine" // "winkeyer" | "icom" | "tci" keyWKEscClears = "winkeyer.esc_clears_call" // ESC also clears the callsign keyWKSendOnType = "winkeyer.send_on_type" // key characters live as typed @@ -459,6 +459,12 @@ type App struct { opLat float64 opLon float64 opSet bool + opCall string // active profile callsign, cached for outbound UDP emitters + + // Dedup for the frequency/mode outbound UDP emitters (PstRotator, N1MM): only + // send when the frequency or mode actually changes. + udpLastFreqHz int64 + udpLastMode string } // gridToLatLon parses a Maidenhead locator (4 or 6 chars) and returns the @@ -534,6 +540,7 @@ func (a *App) refreshOperatorGrid() { if err != nil { return } + a.opCall = strings.ToUpper(strings.TrimSpace(p.Callsign)) lat, lon, ok := gridToLatLon(p.MyGrid) if !ok { return @@ -543,6 +550,36 @@ func (a *App) refreshOperatorGrid() { a.opSet = true } +// emitRadioUDP forwards the rig's frequency/mode to any enabled outbound UDP +// emitters (PstRotator, N1MM RadioInfo). Deduped so it only fires when the +// operating frequency or mode actually changes. The send runs off the CAT +// goroutine so a slow/failed datagram can't stall polling. +func (a *App) emitRadioUDP(s cat.RigState) { + if a.udp == nil { + return + } + rx := s.FreqHz + if s.RxFreqHz != 0 { // split: RxFreqHz is the active/listening VFO + rx = s.RxFreqHz + } + if rx <= 0 { + return + } + if rx == a.udpLastFreqHz && s.Mode == a.udpLastMode { + return + } + a.udpLastFreqHz = rx + a.udpLastMode = s.Mode + rs := udp.RadioState{ + StationName: a.opCall, + OpCall: a.opCall, + RxFreqHz: rx, + TxFreqHz: s.FreqHz, + Mode: s.Mode, + } + go a.udp.EmitRadioState(rs) +} + // dxccAdapter bridges *dxcc.Manager to the lookup.DXCCResolver interface // without making the lookup package import dxcc. type dxccAdapter struct{ m *dxcc.Manager } @@ -726,11 +763,13 @@ func (a *App) startup(ctx context.Context) { fmt.Printf("OpsLog: clublog cty.xml loaded — %d exceptions (%s)\n", n, d) } }() - // CAT manager: emit pushes state to the frontend via Wails events. + // CAT manager: emit pushes state to the frontend via Wails events, and + // forwards frequency/mode to any outbound UDP emitters (PstRotator, N1MM). a.cat = cat.NewManager(func(s cat.RigState) { if a.ctx != nil { wruntime.EventsEmit(a.ctx, "cat:state", s) } + a.emitRadioUDP(s) }) a.reloadCAT() @@ -1583,6 +1622,10 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) { a.extsvc.OnQSOLogged(id) } a.maybeAutoSendEQSL(q) + // Forward the ADIF of this QSO to any outbound "ADIF message" UDP rows. + if a.udp != nil { + go a.udp.EmitLoggedADIF(adif.SingleRecordADIF(q)) + } } return id, err } @@ -7866,6 +7909,42 @@ func (a *App) IcomSetXITOn(on bool) error { return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetXITOn(on) }) } +// IcomSendCW keys a CW message through the rig's internal keyer (CI-V 0x17). +func (a *App) IcomSendCW(text string) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + err := a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SendCW(text) }) + if err != nil { + applog.Printf("icom cw: IcomSendCW(%q) failed: %v", text, err) + } + return err +} + +// IcomStopCW aborts the CW message currently being sent. +func (a *App) IcomStopCW() error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.StopCW() }) +} + +// IcomSetKeySpeed sets the CW keyer speed in WPM. +func (a *App) IcomSetKeySpeed(wpm int) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetKeySpeed(wpm) }) +} + +// IcomSetBreakIn sets CW break-in (0=OFF, 1=SEMI, 2=FULL). +func (a *App) IcomSetBreakIn(mode int) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetBreakIn(mode) }) +} + func (a *App) IcomSetPTT(on bool) error { if a.cat == nil { return fmt.Errorf("cat not initialized") @@ -9092,7 +9171,7 @@ type WKMacro struct { type WinkeyerSettings struct { Enabled bool `json:"enabled"` winkeyer.Config - Engine string `json:"engine"` // keyer backend: "winkeyer" | "tci" + Engine string `json:"engine"` // keyer backend: "winkeyer" | "icom" (rig keyer via CI-V) | "tci" EscClearsCall bool `json:"esc_clears_call"` // ESC also resets the callsign SendOnType bool `json:"send_on_type"` // key chars live as typed Macros []WKMacro `json:"macros"` diff --git a/cmd/psttest/main.go b/cmd/psttest/main.go new file mode 100644 index 0000000..e4214a3 --- /dev/null +++ b/cmd/psttest/main.go @@ -0,0 +1,92 @@ +// Command psttest probes PstRotatorAz's UDP tracking input to discover the wire +// format it expects for the "DXLog.net" tracker. We don't have the spec, so this +// sends a series of labelled candidate datagrams to the target and pauses between +// each so you can watch PstRotatorAz and note which one it reacts to (the band +// filter "Rotate Antenna only for the Selected Bands" changes, or the frequency +// shows in the tracker box). +// +// Setup on the PstRotatorAz side: +// - Tracker = DXLog.net +// - Note the UDP port it listens on (commonly 12040) and this PC's IP +// - In the tracker setup tick a couple of bands so the band filter is visible +// +// Usage: +// +// go run ./cmd/psttest 192.168.1.50:12040 # default 14025.5 kHz +// go run ./cmd/psttest 192.168.1.50:12040 21074 # a specific kHz +// go run ./cmd/psttest 192.168.1.50:12040 21074 3 # repeat each variant 3x +// +// Watch PstRotatorAz as it runs; when one variant makes it react, that's the +// format — tell me the variant number and I'll wire it into OpsLog. +package main + +import ( + "fmt" + "net" + "os" + "strconv" + "time" +) + +type variant struct { + name string + // build returns the datagram for a given frequency in kHz. + build func(khz float64) string +} + +func main() { + if len(os.Args) < 2 { + fmt.Println("usage: psttest [freqKHz] [repeat]") + fmt.Println("example: psttest 192.168.1.50:12040 3525.5") + os.Exit(2) + } + target := os.Args[1] + khz := 3525.5 + if len(os.Args) >= 3 { + if v, err := strconv.ParseFloat(os.Args[2], 64); err == nil { + khz = v + } + } + repeat := 1 + if len(os.Args) >= 4 { + if v, err := strconv.Atoi(os.Args[3]); err == nil && v > 0 { + repeat = v + } + } + + hz := int64(khz*1000 + 0.5) + tensHz := hz / 10 // N1MM/DXLog unit + + // Tag is confirmed. PstRotator strips the decimal point and + // reads the digits, so pin down the exact unit: watch which one shows the real + // frequency (3.5255 MHz), not 0.35255 / 0.003525 / 0.035255. + variants := []variant{ + {"FREQUENCY = Hz integer (expect 3.5255 MHz) ← prediction", func(k float64) string { return fmt.Sprintf("%d", hz) }}, + {"FREQUENCY = tens-of-Hz (expect 0.35255 MHz)", func(k float64) string { return fmt.Sprintf("%d", tensHz) }}, + {"FREQUENCY = kHz integer (expect 0.003525 MHz)", func(k float64) string { return fmt.Sprintf("%d", int64(k+0.5)) }}, + {"FREQUENCY = kHz decimal (baseline: gave 0.035255 MHz)", func(k float64) string { return fmt.Sprintf("%.1f", k) }}, + } + + conn, err := net.Dial("udp", target) + if err != nil { + fmt.Printf("dial %s: %v\n", target, err) + os.Exit(1) + } + defer conn.Close() + + fmt.Printf("Sending %d variants to %s at %.1f kHz (%d Hz, %d tens-of-Hz), %dx each.\n\n", len(variants), target, khz, hz, tensHz, repeat) + for i, v := range variants { + msg := v.build(khz) + fmt.Printf("── Variant %d: %s\n", i+1, v.name) + fmt.Printf(" payload: %s\n", msg) + for r := 0; r < repeat; r++ { + if _, err := conn.Write([]byte(msg)); err != nil { + fmt.Printf(" write error: %v\n", err) + } + time.Sleep(300 * time.Millisecond) + } + fmt.Printf(" → sent. Watch PstRotatorAz for ~4s…\n\n") + time.Sleep(4 * time.Second) + } + fmt.Println("Done. Which variant number made PstRotatorAz react?") +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2ccb475..c747313 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -31,6 +31,7 @@ import { ListCountries, GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus, WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace, + IcomSendCW, IcomStopCW, IcomSetKeySpeed, IcomSetBreakIn, GetIcomState, GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop, StartCWDecoder, StopCWDecoder, SetCWDecoderPitch, ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators, @@ -663,6 +664,24 @@ export default function App() { const [wkSent, setWkSent] = useState(''); // rolling text the keyer echoes as it transmits const [wkEscClears, setWkEscClears] = useState(true); // ESC also clears the callsign const [wkSendOnType, setWkSendOnType] = useState(false); // key chars live as typed + // CW keyer output engine (persisted in the WinKeyer settings, chosen in + // Settings → CW Keyer): the WinKeyer hardware, or the Icom rig's own keyer via + // CI-V 0x17 (no extra hardware — sends over the CAT connection). Macros, + // auto-call and are shared; only the transport differs. + const [wkEngine, setWkEngine] = useState('winkeyer'); + const cwSource: 'winkeyer' | 'icom' = wkEngine === 'icom' ? 'icom' : 'winkeyer'; + const cwSourceRef = useRef(cwSource); + useEffect(() => { cwSourceRef.current = cwSource; }, [cwSource]); + // CW break-in (0=OFF, 1=SEMI, 2=FULL) — must be on for the rig's 0x17 keyer to + // actually transmit. Exposed in the CW keyer panel when the engine is Icom. + const [icomBreakIn, setIcomBreakIn] = useState(0); + const setBreakIn = useCallback((m: number) => { setIcomBreakIn(m); IcomSetBreakIn(m).catch(() => {}); }, []); + // Seed the current break-in from the rig when the CW panel becomes active in + // Icom mode (so the control reflects the radio's real state). + useEffect(() => { + if (cwSource !== 'icom' || !wkEnabled || !(catState.backend === 'icom' && catState.connected)) return; + GetIcomState().then((s: any) => { if (s && typeof s.break_in === 'number') setIcomBreakIn(s.break_in); }).catch(() => {}); + }, [cwSource, wkEnabled, catState.backend, catState.connected]); // Auto-call: repeat the clicked macro (e.g. F1 CQ) every (message + N seconds) // until a reply is entered or it's stopped. Persisted as UI prefs. const [wkAutoCall, setWkAutoCall] = useState(() => localStorage.getItem('opslog.wkAutoCall') === '1'); @@ -678,7 +697,10 @@ export default function App() { const wkEscClearsRef = useRef(true); const wkBusyRef = useRef(false); // live "keyer is sending" flag, for the wait-then-log useEffect(() => { wkBusyRef.current = wkStatus.busy; }, [wkStatus.busy]); - useEffect(() => { wkActiveRef.current = wkEnabled && wkStatus.connected; }, [wkEnabled, wkStatus.connected]); + useEffect(() => { + const connected = cwSource === 'icom' ? (catState.backend === 'icom' && catState.connected) : wkStatus.connected; + wkActiveRef.current = wkEnabled && connected; + }, [wkEnabled, wkStatus.connected, cwSource, catState.backend, catState.connected]); useEffect(() => { wkEscClearsRef.current = wkEscClears; }, [wkEscClears]); // === Digital Voice Keyer (DVK) === @@ -1650,6 +1672,7 @@ export default function App() { setWkMacros((s.macros ?? []) as WKMacro[]); setWkEscClears(s.esc_clears_call !== false); setWkSendOnType(!!s.send_on_type); + setWkEngine(s.engine === 'icom' ? 'icom' : 'winkeyer'); } catch { /* keyer not configured */ } }, []); @@ -1728,6 +1751,15 @@ export default function App() { setWkSent(''); const resolved = resolveCW(rawText); const doLog = //i.test(rawText); // resolveCW strips the token (unknown var → "") + const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms)); + if (cwSourceRef.current === 'icom') { + // The rig's keyer gives no busy echo back, so show the text we sent and, + // for , wait the estimated send duration before logging. + setWkSent(resolved); + await IcomSendCW(resolved).catch((e) => setError(String(e?.message ?? e))); + if (doLog) { await sleep(Math.round(estimateCwMs(resolved, wkWpm)) + 600); void save(); } + return; + } await WinkeyerSend(resolved).catch((e) => setError(String(e?.message ?? e))); // (e.g. "BK 73 TU ") logs the contact AFTER the keyer has // finished sending — so the QSO isn't logged (and the form cleared) while CW @@ -1737,7 +1769,6 @@ export default function App() { // send duration (text length × WPM) plus slack: log when busy clears OR the // estimate elapses, whichever comes first. if (!doLog) return; - const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms)); const capMs = Math.round(estimateCwMs(resolved, wkWpm) * 1.4) + 2500; // slack for keyer buffering/lag for (let i = 0; i < 20 && !wkBusyRef.current; i++) await sleep(50); // ≤1s for sending to start const deadline = Date.now() + capMs; @@ -1756,14 +1787,19 @@ export default function App() { const m = wkMacros[i]; if (!m) break; await wkSend(m.text); - // Wait for the message to finish before the gap+resend. Cap the wait at the - // ESTIMATED send time (not the busy flag alone): over a remote/serial-over-IP - // link the keyer's "busy" status lags badly and stays stuck true for tens of - // seconds, which made the next CQ fire ~120s late — i.e. auto-call looked dead. - const capMs = Math.round(estimateCwMs(resolveCW(m.text), wkWpm) * 1.4) + 2500; - for (let k = 0; k < 20 && !wkBusyRef.current && gen === autoCallGenRef.current; k++) await sleep(50); // ≤1s to start - const deadline = Date.now() + capMs; - while (wkBusyRef.current && gen === autoCallGenRef.current && Date.now() < deadline) await sleep(50); + // Wait for the message to finish before the gap+resend. The Icom keyer has + // no busy echo, so just wait the estimated send time. For the WinKeyer, cap + // the wait at the ESTIMATED send time (not the busy flag alone): over a + // remote/serial-over-IP link the "busy" status lags badly and stays stuck + // true for tens of seconds, which made the next CQ fire ~120s late. + if (cwSourceRef.current === 'icom') { + await sleep(Math.round(estimateCwMs(resolveCW(m.text), wkWpm)) + 300); + } else { + const capMs = Math.round(estimateCwMs(resolveCW(m.text), wkWpm) * 1.4) + 2500; + for (let k = 0; k < 20 && !wkBusyRef.current && gen === autoCallGenRef.current; k++) await sleep(50); // ≤1s to start + const deadline = Date.now() + capMs; + while (wkBusyRef.current && gen === autoCallGenRef.current && Date.now() < deadline) await sleep(50); + } if (gen !== autoCallGenRef.current) break; await sleep(Math.max(0, wkAutoCallSecsRef.current) * 1000); // the gap before the next call } @@ -3648,20 +3684,29 @@ export default function App() { {wkEnabled && (
WinkeyerConnect().catch((e) => setError(String(e?.message ?? e)))} onDisconnect={() => WinkeyerDisconnect().catch(() => {})} - onSetSpeed={(w) => { setWkWpm(w); WinkeyerSetSpeed(w).catch(() => {}); saveWk({ wpm: w }); }} + onSetSpeed={(w) => { + setWkWpm(w); saveWk({ wpm: w }); + if (cwSource === 'icom') IcomSetKeySpeed(w).catch(() => {}); + else WinkeyerSetSpeed(w).catch(() => {}); + }} onSend={wkSend} onSendMacro={wkSendMacro} - onStop={() => { stopAutoCall(); WinkeyerStop().catch(() => {}); }} + onStop={() => { stopAutoCall(); if (cwSource === 'icom') IcomStopCW().catch(() => {}); else WinkeyerStop().catch(() => {}); }} onClose={() => wkSetEnabled(false)} sendOnType={wkSendOnType} onToggleSendOnType={wkToggleSendOnType} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 5b607bd..38ea51a 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -2308,6 +2308,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan WinKeyer (serial) + Icom CI-V (rig keyer) TCI (coming soon) @@ -2318,91 +2319,115 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
-
-
- -
- - + {wk.engine === 'icom' ? ( + <> +

+ Icom CI-V keys CW through the rig's own keyer over the existing CAT connection (command 0x17) — it reuses the CAT COM port set in Settings → CAT, so there's nothing else to wire up here. Put the rig in CW mode. Weight, ratio, sidetone, paddle mode… are configured on the radio; only the speed is set from here (the rig's KEY SPEED). +

+ {(!catCfg.enabled || catCfg.backend !== 'icom') && ( +

+ + + Your CAT backend is set to {catCfg.enabled ? (catCfg.backend || 'none') : 'disabled'}. Icom CI-V CW needs the CAT backend set to Icom and connected — change it under Settings → CAT interface, otherwise sending CW will fail. + +

+ )} +
+
+ + setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" /> +
+
+ + ) : ( + <> +
+
+ +
+ + +
+
+
+ + +
+
+ + setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" /> +
-
-
- - -
-
- - setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" /> -
-
-
-
- - setWkField({ weight: num(e.target.value, 50) })} className="font-mono" /> -
-
- - setWkField({ lead_in_ms: num(e.target.value, 10) })} className="font-mono" /> -
-
- - setWkField({ tail_ms: num(e.target.value, 50) })} className="font-mono" /> -
-
- - setWkField({ ratio: num(e.target.value, 50) })} className="font-mono" /> -
-
- - setWkField({ farnsworth: num(e.target.value, 0) })} className="font-mono" /> -
-
- - setWkField({ sidetone_hz: num(e.target.value, 600) })} className="font-mono" /> -
-
- - -
-
+
+
+ + setWkField({ weight: num(e.target.value, 50) })} className="font-mono" /> +
+
+ + setWkField({ lead_in_ms: num(e.target.value, 10) })} className="font-mono" /> +
+
+ + setWkField({ tail_ms: num(e.target.value, 50) })} className="font-mono" /> +
+
+ + setWkField({ ratio: num(e.target.value, 50) })} className="font-mono" /> +
+
+ + setWkField({ farnsworth: num(e.target.value, 0) })} className="font-mono" /> +
+
+ + setWkField({ sidetone_hz: num(e.target.value, 600) })} className="font-mono" /> +
+
+ + +
+
-
- - - - -
+
+ + + + +
+ + )} {/* Macro editor */}
diff --git a/frontend/src/components/UDPIntegrationsPanel.tsx b/frontend/src/components/UDPIntegrationsPanel.tsx index 6a7403a..502b986 100644 --- a/frontend/src/components/UDPIntegrationsPanel.tsx +++ b/frontend/src/components/UDPIntegrationsPanel.tsx @@ -24,7 +24,7 @@ type UDPConfig = { direction: 'inbound' | 'outbound'; name: string; port: number; - service_type: 'wsjt' | 'adif' | 'n1mm' | 'remote_call' | 'db_updated'; + service_type: 'wsjt' | 'adif' | 'n1mm' | 'remote_call' | 'db_updated' | 'pstrotator_freq' | 'n1mm_radioinfo'; multicast: boolean; multicast_group: string; destination_ip: string; @@ -77,6 +77,20 @@ const SERVICE_TYPES: Array<{ hint: 'udpp.svcDbHint', defaults: { port: 2333, destination_ip: '127.0.0.1' }, }, + { + id: 'pstrotator_freq', + direction: 'outbound', + label: 'udpp.svcPstLabel', + hint: 'udpp.svcPstHint', + defaults: { port: 12040, destination_ip: '127.0.0.1' }, + }, + { + id: 'n1mm_radioinfo', + direction: 'outbound', + label: 'udpp.svcN1mmRadioLabel', + hint: 'udpp.svcN1mmRadioHint', + defaults: { port: 12060, destination_ip: '127.0.0.1' }, + }, ]; type Props = { onError: (msg: string) => void }; diff --git a/frontend/src/components/WinkeyerPanel.tsx b/frontend/src/components/WinkeyerPanel.tsx index ee8785b..3cdcfe9 100644 --- a/frontend/src/components/WinkeyerPanel.tsx +++ b/frontend/src/components/WinkeyerPanel.tsx @@ -26,6 +26,9 @@ interface Props { wpm: number; macros: WKMacro[]; sent: string; // text echoed back by the keyer as it transmits + source: 'winkeyer' | 'icom'; // CW output engine (chosen in Settings → CW Keyer) + breakIn?: number; // Icom CW break-in: 0=OFF, 1=SEMI, 2=FULL + onSetBreakIn?: (mode: number) => void; onSelectPort: (p: string) => void; onRefreshPorts: () => void; onConnect: () => void; @@ -49,7 +52,7 @@ interface Props { // reserved space to the right of the F1-F5 tabs. Sends Morse via the WinKeyer // hardware: free-text CW, one-click macros (F1…), live speed, and abort. export function WinkeyerPanel({ - status, ports, port, wpm, macros, sent, + status, ports, port, wpm, macros, sent, source, breakIn = 0, onSetBreakIn, onSelectPort, onRefreshPorts, onConnect, onDisconnect, onSetSpeed, onSend, onSendMacro, onStop, onClose, sendOnType, onToggleSendOnType, onSendRaw, onBackspace, @@ -96,11 +99,18 @@ export function WinkeyerPanel({ {/* Header / connection bar */}
- WinKeyer + {/* CW output engine (chosen in Settings → CW Keyer). */} + + {source === 'icom' ? 'Icom CW' : 'WinKeyer'} +
- {!connected ? ( + {source === 'icom' ? ( + + {connected ? t('wkp.civReady') : t('wkp.civOffline')} + + ) : !connected ? ( <> onToggleSendOnType(e.target.checked)} /> - {t('wkp.sendOnType')} - + {source === 'winkeyer' && ( + + )} whenever it changes — set PstRotatorAz tracker to DXLog.net (default port 12040).', 'udpp.svcN1mmRadioLabel': 'N1MM RadioInfo (freq + mode)', 'udpp.svcN1mmRadioHint': 'Broadcasts the rig frequency/mode as N1MM Logger+ RadioInfo XML on every change — consumed by PstRotator (N1MM tracker) and many other tools.', 'udpp.deleteConfirm': 'Delete this UDP connection?', 'udpp.loading': 'Loading…', 'udpp.intro': 'UDP connections let OpsLog talk to other ham radio software. Inbound connections receive QSOs or callsigns and update the logbook live; outbound connections notify other apps when you log a QSO locally. Enable multicast to share a port with another listener without conflict — required for the typical WSJT-X 2237 setup.', 'udpp.inboundTitle': 'Inbound — OpsLog listens', 'udpp.outboundTitle': 'Outbound — OpsLog sends', 'udpp.reloadAll': 'Reload all', 'udpp.reloadHint': 'Restarts every enabled listener after a manual change.', 'udpp.add': 'Add', 'udpp.noConnection': 'No connection.', 'udpp.unnamed': '(unnamed)', 'udpp.dialogTitle': '{action} {direction} connection', 'udpp.new': 'New', 'udpp.edit': 'Edit', 'udpp.directionInbound': 'inbound', 'udpp.directionOutbound': 'outbound', 'udpp.name': 'Name', 'udpp.namePhInbound': 'WSJT-X log', 'udpp.namePhOutbound': 'Cloudlog notify', 'udpp.serviceType': 'Service type', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Multicast group', 'udpp.multicastHint': 'Use the same group address as the sending app. WSJT-X default is 224.0.0.1.', 'udpp.destinationIp': 'Destination IP', 'udpp.enabled': 'Enabled', 'udpp.cancel': 'Cancel', 'udpp.save': 'Save', 'fltb.fCallsign': 'Callsign', 'fltb.fDate': 'Date / time (UTC)', 'fltb.fEndDate': 'End date / time', 'fltb.fBand': 'Band', 'fltb.fRxBand': 'RX band', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Submode', 'fltb.fFreq': 'Frequency (Hz)', 'fltb.fRxFreq': 'RX frequency (Hz)', 'fltb.fRstSent': 'RST sent', 'fltb.fRstRcvd': 'RST rcvd', 'fltb.fName': 'Name', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Address', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Grid', 'fltb.fCountry': 'Country', 'fltb.fState': 'State', 'fltb.fCounty': 'County', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'CQ zone', 'fltb.fItuz': 'ITU zone', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'SOTA ref', 'fltb.fPota': 'POTA ref', 'fltb.fWwff': 'WWFF ref', 'fltb.fRig': 'Rig', 'fltb.fAntenna': 'Antenna', 'fltb.fQslSent': 'QSL sent', 'fltb.fQslRcvd': 'QSL rcvd', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW sent', 'fltb.fLotwRcvd': 'LoTW rcvd', 'fltb.fEqslSent': 'eQSL sent', 'fltb.fEqslRcvd': 'eQSL rcvd', 'fltb.fQrzUpload': 'QRZ upload status', 'fltb.fClublogUpload': 'ClubLog upload status', 'fltb.fHrdlogUpload': 'HRDLog upload status', 'fltb.fContestId': 'Contest ID', 'fltb.fSerialRcvd': 'Serial rcvd', 'fltb.fSerialSent': 'Serial sent', 'fltb.fPropMode': 'Propagation mode', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'My callsign', 'fltb.fOperator': 'Operator', 'fltb.fOwnerCallsign': 'Owner callsign', 'fltb.fMyGrid': 'My grid', 'fltb.fMyCountry': 'My country', 'fltb.fMyState': 'My state', 'fltb.fMyCounty': 'My county', 'fltb.fMyIota': 'My IOTA', 'fltb.fMySota': 'My SOTA ref', 'fltb.fMyPota': 'My POTA ref', 'fltb.fMyWwff': 'My WWFF ref', 'fltb.fMyStreet': 'My street', 'fltb.fMyCity': 'My city', 'fltb.fMyPostal': 'My postal code', 'fltb.fMyRig': 'My rig', 'fltb.fMyAntenna': 'My antenna', 'fltb.fTxPower': 'TX power (W)', 'fltb.fComment': 'Comment', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'equals (=)', 'fltb.opNe': 'not equal (≠)', 'fltb.opContains': 'contains', 'fltb.opStartsWith': 'starts with', 'fltb.opEndsWith': 'ends with', 'fltb.opGt': 'greater than (>)', 'fltb.opLt': 'less than (<)', 'fltb.opGe': 'greater or equal (≥)', 'fltb.opLe': 'less or equal (≤)', 'fltb.opEmpty': 'is empty', 'fltb.opNotEmpty': 'is not empty', 'fltb.title': 'QSO filter', 'fltb.match': 'Match', 'fltb.all': 'ALL (AND)', 'fltb.any': 'ANY (OR)', 'fltb.loadPreset': 'Load preset…', 'fltb.noConditions': 'No conditions — the list shows all QSOs. Add one below.', 'fltb.where': 'WHERE', 'fltb.valuePh': 'value', 'fltb.remove': 'Remove', 'fltb.addCondition': 'Add condition', 'fltb.presetNamePh': 'Preset name…', 'fltb.savePreset': 'Save preset', 'fltb.clear': 'Clear', 'fltb.cancel': 'Cancel', 'fltb.applyClose': 'Apply & close', 'detp.propAS': 'Aircraft Scatter', 'detp.propAUR': 'Aurora', 'detp.propAUE': 'Aurora-E', 'detp.propBS': 'Back Scatter', 'detp.propEME': 'Earth-Moon-Earth', 'detp.propES': 'Sporadic E', 'detp.propFAI': 'Field Aligned Irregularities', 'detp.propF2': 'F2 Reflection', 'detp.propGWAVE': 'Ground Wave', 'detp.propINTERNET': 'Internet-assisted', 'detp.propION': 'Ionoscatter', 'detp.propLOS': 'Line of Sight', 'detp.propMS': 'Meteor Scatter', 'detp.propRPT': 'Terrestrial / atmospheric repeater', 'detp.propRS': 'Rain Scatter', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-Equatorial', 'detp.propTR': 'Tropospheric Ducting', 'detp.pathShort': 'Short Path', 'detp.pathLong': 'Long Path', 'detp.pathGrayline': 'Grayline', 'detp.pathOther': 'Other', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Awards', 'detp.tabMy': 'My', 'detp.tabExtended': 'Extended', 'detp.statePref': 'State / pref', 'detp.county': 'County', 'detp.prefix': 'Prefix', 'detp.cqZone': 'CQ zone', 'detp.ituZone': 'ITU zone', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimuth LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Address', 'detp.qslMessage': 'QSL message', 'detp.qslVia': 'QSL via', 'detp.detected': 'Detected — this contact will count for:', 'detp.azimuth': 'Azimuth (°)', 'detp.elevation': 'Elevation (°)', 'detp.txPower': 'TX power (W)', 'detp.satelliteMode': 'Satellite mode', 'detp.antPath': 'Ant. path', 'detp.propagation': 'Propagation', 'detp.rig': 'Rig', 'detp.antenna': 'Antenna', 'detp.satName': 'Satellite name', 'detp.contestId': 'Contest ID', 'detp.rcvdExchangePh': 'rcvd exchange', 'detp.sentExchangePh': 'sent exchange', 'detp.contactedEmail': 'Contacted email', // Awards (ref picker / ref selector / awards panel / award editor) @@ -355,7 +357,9 @@ const fr: Dict = { 'ctp.stopContest': 'Arrêter le contest', 'ctp.startContest': 'Démarrer le contest', 'ctp.activeHint': 'Le formulaire de saisie affiche Env/Reç et un badge DUPE ; les QSO sont marqués avec ce contest.', 'ctp.inactiveHint': 'Choisis un contest, règle la fenêtre, puis Démarrer.', 'ctp.scoreboard': 'Tableau des scores', 'ctp.estimate': 'estimation', 'ctp.qsos': 'QSO', 'ctp.mult': 'Mult', 'ctp.scoreEst': 'Score (est.)', 'ctp.last60': 'Dernières 60 min', 'ctp.band': 'Bande', 'adx.introPart1': 'Tous les champs ADIF 3.1.7 non affichés dans les autres onglets. Choisis un champ à ajouter, ou saisis un tag personnalisé/constructeur (ex. ', 'adx.introPart2': '). Stocké sans perte et exporté en mode ADIF ', 'adx.fullMode': 'complet', 'adx.introPart3': '.', 'adx.addFieldPh': 'Ajouter un champ ADIF…', 'adx.showDeprecated': 'Afficher les obsolètes', 'adx.noExtra': 'Aucun champ ADIF supplémentaire. Utilise le sélecteur ci-dessus pour en ajouter un.', 'adx.deprecated': 'obsolète', 'adx.intl': 'intl', 'adx.nonStandard': 'non standard', 'adx.removeField': 'Supprimer le champ', 'wkp.sending': 'Émission…', 'wkp.connectedV': 'Connecté (v{version})', 'wkp.disconnected': 'Déconnecté', 'wkp.comPort': 'Port COM', 'wkp.noPorts': 'Aucun port', 'wkp.refreshPorts': 'Rafraîchir les ports', 'wkp.connect': 'Connecter', 'wkp.disconnect': 'Déconnecter', 'wkp.hide': 'Masquer / désactiver le WinKeyer', + 'wkp.sourceHint': 'Sortie CW : WK = WinKeyer matériel · CI-V = le keyer interne de l’Icom (via CAT, sans matériel en plus)', 'wkp.civReady': 'Icom CI-V prêt', 'wkp.civOffline': 'Icom non connecté (Réglages → CAT)', 'wkp.cwSpeed': 'Vitesse CW (WPM)', 'wkp.faster': 'Plus rapide', 'wkp.slower': 'Plus lent', 'wkp.cwText': 'Texte CW', 'wkp.sendOnTypeHint': 'Manipule chaque caractère en direct à la frappe (retour arrière supprime les caractères non émis)', 'wkp.sendOnType': 'émission à la frappe', 'wkp.phLive': 'Tape — émis en direct…', 'wkp.phEnter': 'Tape et appuie sur Entrée pour émettre…', 'wkp.clear': 'Effacer', 'wkp.send': 'Émettre', 'wkp.abort': 'Interrompre (vider le tampon du manipulateur)', 'wkp.stop': 'Stop', + 'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "Le manipulateur interne de la radio n'émet que si le break-in est SEMI ou FULL. OFF génère la tonalité mais reste en réception.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "n'émettra pas — mettre SEMI ou FULL", 'wkp.autoCallHint': "Clique une macro CQ (dont le texte contient CQ) pour la réémettre en boucle — message, pause, répétition — jusqu'à envoyer une autre macro (ex. un report), appuyer sur Stop ou ESC. Les macros non-CQ ne sont émises qu'une fois.", 'wkp.autoCall': 'Appel auto', 'wkp.gap': 'pause', 'wkp.gapHint': 'Secondes à attendre après le message avant de réémettre', 'wkp.loopHint': 'clique une macro CQ pour la boucler', 'wkp.macroN': 'Macro {n}', 'dvkp.voiceKeyer': 'Manipulateur vocal', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Désactiver le manipulateur vocal', 'dvkp.noMsgPre': 'Aucun message enregistré. Ouvre', 'dvkp.settingsPath': 'Réglages → Périphériques audio & manipulateur vocal', 'dvkp.noMsgPost': 'pour enregistrer F1–F6.', 'dvkp.transmit': 'Émettre F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — vide', 'dvkp.message': 'message', 'agp.portDeselect': 'Port {letter} — clic pour désélectionner', 'agp.portSelect': 'Sélectionner sur le port {letter}', 'agp.online': 'en ligne', 'agp.offline': 'hors ligne', 'agp.close': 'Fermer', 'agp.connecting': 'Connexion…', 'agp.noAntennas': 'Aucune antenne configurée.', @@ -367,7 +371,7 @@ const fr: Dict = { 'altm.filterPh': 'Filtrer…', 'altm.noMatch': 'aucun résultat', 'altm.noneAll': 'aucune sélection = TOUT', 'altm.nSelected': '{n} sélectionné(s)', 'altm.giveName': 'Donne un nom à la règle', 'altm.deleteConfirm': "Supprimer l'alerte « {name} » ?", 'altm.title': 'Gestion des alertes', 'altm.desc': 'Alerte quand un spot correspond à une règle. Filtres vides = TOUT ; les filtres définis sont combinés par ET (ex. France + 20m = stations françaises sur 20m).', 'altm.rules': 'Règles', 'altm.noRules': 'Aucune règle — clique sur +', 'altm.emailTo': "E-mail d'alerte à", 'altm.selectOrCreate': 'Sélectionne ou crée une règle.', 'altm.tabDef': 'Définition', 'altm.tabCall': 'Indicatif / DXCC', 'altm.tabBandMode': 'Bande / Mode', 'altm.tabOrigin': 'Origine', 'altm.ruleName': 'Nom de la règle', 'altm.alertEnabled': 'Alerte activée', 'altm.againAfter': 'Réalerter après (min)', 'altm.againHint': '0 = une fois/session · -1 = toujours', 'altm.actions': 'Actions', 'altm.visual': 'Visuel', 'altm.sound': 'Son', 'altm.email': 'E-mail', 'altm.skipWorked': 'Ignorer les indicatifs déjà contactés (même bande + mode)', 'altm.callsigns': 'Indicatifs (un par ligne, jokers : IW3*, */P)', 'altm.countries': 'Pays (DXCC)', 'altm.continents': 'Continents', 'altm.bands': 'Bandes', 'altm.modes': 'Modes', 'altm.spotterCall': 'Indicatif du spotteur (joker)', 'altm.spotterCallPh': 'ex. F* ou DL1ABC', 'altm.spotterContinents': 'Continents du spotteur', 'altm.spotterCountries': 'Pays du spotteur', 'altm.delete': 'Supprimer', 'altm.saveRule': 'Enregistrer la règle', 'altm.close': 'Fermer', 'spm.callRequired': 'Indicatif requis', 'spm.freqRequired': 'Fréquence (kHz) requise', 'spm.title': 'Envoyer un spot DX', 'spm.callsign': 'Indicatif', 'spm.callPh': 'Indicatif DX', 'spm.frequency': 'Fréquence (kHz)', 'spm.message': 'Message', 'spm.messagePh': 'ex. CW · TNX QSO', 'spm.latestQsos': 'Derniers QSO', 'spm.spotSent': 'Spot envoyé ✓', 'spm.masterCluster': 'Cluster maître', 'spm.cancel': 'Annuler', 'spm.sending': 'Envoi…', 'spm.sendSpot': 'Envoyer le spot', 'ncp.newNetPrompt': 'Nom du nouveau NET :', 'ncp.renamePrompt': 'Renommer le NET :', 'ncp.deleteConfirm': 'Supprimer le NET « {name} » et son répertoire ? Cette action est irréversible.', 'ncp.closeConfirm': "{n} station(s) encore en l'air seront retirées SANS être enregistrées. Fermer quand même ?", 'ncp.removeConfirm': 'Retirer {n} station(s) du répertoire de ce NET ?', 'ncp.colCallsign': 'Indicatif', 'ncp.colName': 'Nom', 'ncp.colTimeOn': 'Heure début', 'ncp.colBand': 'Bande', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Commentaire', 'ncp.colCountry': 'Pays', 'ncp.newNet': 'Nouveau NET', 'ncp.closeToSwitch': 'Ferme le NET pour changer', 'ncp.selectNetTitle': 'Sélectionne un NET', 'ncp.selectNetOption': '— sélectionner un NET —', 'ncp.closeNet': 'Fermer le NET', 'ncp.openNet': 'Ouvrir le NET', 'ncp.rename': 'Renommer', 'ncp.delete': 'Supprimer', 'ncp.netOpenBadge': 'NET OUVERT', 'ncp.onAir': "En l'air :", 'ncp.roster': 'Répertoire :', 'ncp.onAirActive': "En l'air — QSO actifs", 'ncp.activeHint': 'double-clic → éditer tous les champs · « Logger & terminer » pour enregistrer', 'ncp.logEndSelected': 'Logger & terminer la sélection', 'ncp.netUsersRoster': 'Membres du NET — répertoire', 'ncp.rosterHint': "double-clic → mettre en l'air", 'ncp.addContact': 'Ajouter un contact', 'ncp.remove': 'Retirer', 'ncp.putOnAir': "Mettre la sélection en l'air", 'ncp.addContactTitle': 'Ajouter un contact au NET', 'ncp.addContactDesc': 'Enregistré dans le répertoire de ce NET (réutilisé à la prochaine ouverture).', 'ncp.callsign': 'Indicatif', 'ncp.search': 'Rechercher', 'ncp.name': 'Nom', 'ncp.country': 'Pays', 'ncp.cancel': 'Annuler', 'ncp.saveInNet': 'Enregistrer dans le NET', - 'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': "Logue automatiquement les QSO FT8/FT4/etc. et remplit l'indicatif de saisie en direct.", 'udpp.svcAdifLabel': 'Message ADIF (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Reçoit un seul enregistrement ADIF par paquet et le logue.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (XML contest)', 'udpp.svcN1mmHint': 'Reçoit les QSO de contest sous forme de messages XML.', 'udpp.svcRemoteLabel': 'Indicatif distant (DXHunter, personnalisé)', 'udpp.svcRemoteHint': 'Un court paquet texte contenant juste un indicatif — remplit le champ de saisie.', 'udpp.svcDbLabel': "Base mise à jour → notifier d'autres apps", 'udpp.svcDbHint': "Envoie l'ADIF de chaque QSO enregistré vers un écouteur distant (Cloudlog UDP, N1MM…).", 'udpp.deleteConfirm': 'Supprimer cette connexion UDP ?', 'udpp.loading': 'Chargement…', 'udpp.intro': "Les connexions UDP permettent à OpsLog de dialoguer avec d'autres logiciels radioamateurs. Les connexions entrantes reçoivent des QSO ou des indicatifs et mettent le journal à jour en direct ; les connexions sortantes notifient d'autres apps quand tu enregistres un QSO localement. Active le multicast pour partager un port avec un autre écouteur sans conflit — nécessaire pour la config WSJT-X 2237 classique.", 'udpp.inboundTitle': 'Entrant — OpsLog écoute', 'udpp.outboundTitle': 'Sortant — OpsLog envoie', 'udpp.reloadAll': 'Tout recharger', 'udpp.reloadHint': 'Redémarre chaque écouteur activé après une modification manuelle.', 'udpp.add': 'Ajouter', 'udpp.noConnection': 'Aucune connexion.', 'udpp.unnamed': '(sans nom)', 'udpp.dialogTitle': '{action} connexion {direction}', 'udpp.new': 'Nouvelle', 'udpp.edit': 'Modifier', 'udpp.directionInbound': 'entrante', 'udpp.directionOutbound': 'sortante', 'udpp.name': 'Nom', 'udpp.namePhInbound': 'Log WSJT-X', 'udpp.namePhOutbound': 'Notification Cloudlog', 'udpp.serviceType': 'Type de service', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Groupe multicast', 'udpp.multicastHint': "Utilise la même adresse de groupe que l'app émettrice. Le défaut WSJT-X est 224.0.0.1.", 'udpp.destinationIp': 'IP de destination', 'udpp.enabled': 'Activé', 'udpp.cancel': 'Annuler', 'udpp.save': 'Enregistrer', + 'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': "Logue automatiquement les QSO FT8/FT4/etc. et remplit l'indicatif de saisie en direct.", 'udpp.svcAdifLabel': 'Message ADIF (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Reçoit un seul enregistrement ADIF par paquet et le logue.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (XML contest)', 'udpp.svcN1mmHint': 'Reçoit les QSO de contest sous forme de messages XML.', 'udpp.svcRemoteLabel': 'Indicatif distant (DXHunter, personnalisé)', 'udpp.svcRemoteHint': 'Un court paquet texte contenant juste un indicatif — remplit le champ de saisie.', 'udpp.svcDbLabel': "ADIF Message", 'udpp.svcDbHint': "Envoie l'ADIF de chaque QSO enregistré vers un écouteur distant (Cloudlog UDP, N1MM…).", 'udpp.svcPstLabel': 'Fréquence PstRotator', 'udpp.svcPstHint': "Envoie la fréquence du poste en à chaque changement — règle le tracker de PstRotatorAz sur DXLog.net (port 12040 par défaut).", 'udpp.svcN1mmRadioLabel': 'N1MM RadioInfo (fréq + mode)', 'udpp.svcN1mmRadioHint': "Diffuse la fréquence/mode du poste en XML RadioInfo N1MM Logger+ à chaque changement — lu par PstRotator (tracker N1MM) et beaucoup d'autres outils.", 'udpp.deleteConfirm': 'Supprimer cette connexion UDP ?', 'udpp.loading': 'Chargement…', 'udpp.intro': "Les connexions UDP permettent à OpsLog de dialoguer avec d'autres logiciels radioamateurs. Les connexions entrantes reçoivent des QSO ou des indicatifs et mettent le journal à jour en direct ; les connexions sortantes notifient d'autres apps quand tu enregistres un QSO localement. Active le multicast pour partager un port avec un autre écouteur sans conflit — nécessaire pour la config WSJT-X 2237 classique.", 'udpp.inboundTitle': 'Entrant — OpsLog écoute', 'udpp.outboundTitle': 'Sortant — OpsLog envoie', 'udpp.reloadAll': 'Tout recharger', 'udpp.reloadHint': 'Redémarre chaque écouteur activé après une modification manuelle.', 'udpp.add': 'Ajouter', 'udpp.noConnection': 'Aucune connexion.', 'udpp.unnamed': '(sans nom)', 'udpp.dialogTitle': '{action} connexion {direction}', 'udpp.new': 'Nouvelle', 'udpp.edit': 'Modifier', 'udpp.directionInbound': 'entrante', 'udpp.directionOutbound': 'sortante', 'udpp.name': 'Nom', 'udpp.namePhInbound': 'Log WSJT-X', 'udpp.namePhOutbound': 'Notification Cloudlog', 'udpp.serviceType': 'Type de service', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Groupe multicast', 'udpp.multicastHint': "Utilise la même adresse de groupe que l'app émettrice. Le défaut WSJT-X est 224.0.0.1.", 'udpp.destinationIp': 'IP de destination', 'udpp.enabled': 'Activé', 'udpp.cancel': 'Annuler', 'udpp.save': 'Enregistrer', 'fltb.fCallsign': 'Indicatif', 'fltb.fDate': 'Date / heure (UTC)', 'fltb.fEndDate': 'Date / heure de fin', 'fltb.fBand': 'Bande', 'fltb.fRxBand': 'Bande RX', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Sous-mode', 'fltb.fFreq': 'Fréquence (Hz)', 'fltb.fRxFreq': 'Fréquence RX (Hz)', 'fltb.fRstSent': 'RST envoyé', 'fltb.fRstRcvd': 'RST reçu', 'fltb.fName': 'Nom', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Adresse', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Locator', 'fltb.fCountry': 'Pays', 'fltb.fState': 'État', 'fltb.fCounty': 'Comté', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'Zone CQ', 'fltb.fItuz': 'Zone ITU', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'Réf SOTA', 'fltb.fPota': 'Réf POTA', 'fltb.fWwff': 'Réf WWFF', 'fltb.fRig': 'Station', 'fltb.fAntenna': 'Antenne', 'fltb.fQslSent': 'QSL envoyée', 'fltb.fQslRcvd': 'QSL reçue', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW envoyé', 'fltb.fLotwRcvd': 'LoTW reçu', 'fltb.fEqslSent': 'eQSL envoyé', 'fltb.fEqslRcvd': 'eQSL reçu', 'fltb.fQrzUpload': 'Statut upload QRZ', 'fltb.fClublogUpload': 'Statut upload ClubLog', 'fltb.fHrdlogUpload': 'Statut upload HRDLog', 'fltb.fContestId': 'ID contest', 'fltb.fSerialRcvd': 'Numéro reçu', 'fltb.fSerialSent': 'Numéro envoyé', 'fltb.fPropMode': 'Mode de propagation', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'Mon indicatif', 'fltb.fOperator': 'Opérateur', 'fltb.fOwnerCallsign': 'Indicatif propriétaire', 'fltb.fMyGrid': 'Mon locator', 'fltb.fMyCountry': 'Mon pays', 'fltb.fMyState': 'Mon état', 'fltb.fMyCounty': 'Mon comté', 'fltb.fMyIota': 'Mon IOTA', 'fltb.fMySota': 'Ma réf SOTA', 'fltb.fMyPota': 'Ma réf POTA', 'fltb.fMyWwff': 'Ma réf WWFF', 'fltb.fMyStreet': 'Ma rue', 'fltb.fMyCity': 'Ma ville', 'fltb.fMyPostal': 'Mon code postal', 'fltb.fMyRig': 'Ma station', 'fltb.fMyAntenna': 'Mon antenne', 'fltb.fTxPower': 'Puissance TX (W)', 'fltb.fComment': 'Commentaire', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'égal (=)', 'fltb.opNe': 'différent (≠)', 'fltb.opContains': 'contient', 'fltb.opStartsWith': 'commence par', 'fltb.opEndsWith': 'finit par', 'fltb.opGt': 'supérieur à (>)', 'fltb.opLt': 'inférieur à (<)', 'fltb.opGe': 'supérieur ou égal (≥)', 'fltb.opLe': 'inférieur ou égal (≤)', 'fltb.opEmpty': 'est vide', 'fltb.opNotEmpty': "n'est pas vide", 'fltb.title': 'Filtre QSO', 'fltb.match': 'Correspondance', 'fltb.all': 'TOUS (ET)', 'fltb.any': 'AU MOINS UN (OU)', 'fltb.loadPreset': 'Charger un préréglage…', 'fltb.noConditions': 'Aucune condition — la liste affiche tous les QSO. Ajoutes-en une ci-dessous.', 'fltb.where': 'OÙ', 'fltb.valuePh': 'valeur', 'fltb.remove': 'Retirer', 'fltb.addCondition': 'Ajouter une condition', 'fltb.presetNamePh': 'Nom du préréglage…', 'fltb.savePreset': 'Enregistrer le préréglage', 'fltb.clear': 'Effacer', 'fltb.cancel': 'Annuler', 'fltb.applyClose': 'Appliquer & fermer', 'detp.propAS': 'Diffusion par avion', 'detp.propAUR': 'Aurore', 'detp.propAUE': 'Aurore-E', 'detp.propBS': 'Rétrodiffusion', 'detp.propEME': 'Terre-Lune-Terre', 'detp.propES': 'Sporadique E', 'detp.propFAI': 'Irrégularités alignées au champ', 'detp.propF2': 'Réflexion F2', 'detp.propGWAVE': 'Onde de sol', 'detp.propINTERNET': 'Assisté par Internet', 'detp.propION': 'Diffusion ionosphérique', 'detp.propLOS': 'Vue directe', 'detp.propMS': 'Diffusion météoritique', 'detp.propRPT': 'Répéteur terrestre / atmosphérique', 'detp.propRS': 'Diffusion par la pluie', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-équatorial', 'detp.propTR': 'Conduit troposphérique', 'detp.pathShort': 'Chemin court', 'detp.pathLong': 'Chemin long', 'detp.pathGrayline': 'Ligne grise', 'detp.pathOther': 'Autre', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Diplômes', 'detp.tabMy': 'Moi', 'detp.tabExtended': 'Étendu', 'detp.statePref': 'État / préf', 'detp.county': 'Comté', 'detp.prefix': 'Préfixe', 'detp.cqZone': 'Zone CQ', 'detp.ituZone': 'Zone ITU', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimut LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Adresse', 'detp.qslMessage': 'Message QSL', 'detp.qslVia': 'QSL via', 'detp.detected': 'Détecté — ce contact comptera pour :', 'detp.azimuth': 'Azimut (°)', 'detp.elevation': 'Élévation (°)', 'detp.txPower': 'Puissance TX (W)', 'detp.satelliteMode': 'Mode satellite', 'detp.antPath': 'Chemin ant.', 'detp.propagation': 'Propagation', 'detp.rig': 'Station', 'detp.antenna': 'Antenne', 'detp.satName': 'Nom du satellite', 'detp.contestId': 'ID contest', 'detp.rcvdExchangePh': 'échange reçu', 'detp.sentExchangePh': 'échange envoyé', 'detp.contactedEmail': 'E-mail du contact', 'awrp.remove': 'Retirer', 'awrp.searchLabel': 'Rechercher {label}…', 'awrp.searching': 'Recherche…', 'awrp.noMatch': 'Aucune correspondance.', 'awrp.noMatchDxcc': 'Aucune correspondance pour ce DXCC.', diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 2191703..65a6e7a 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -342,6 +342,8 @@ export function IcomRefresh():Promise; export function IcomScopeData():Promise; +export function IcomSendCW(arg1:string):Promise; + export function IcomSetAFGain(arg1:number):Promise; export function IcomSetAGC(arg1:string):Promise; @@ -350,8 +352,12 @@ export function IcomSetANF(arg1:boolean):Promise; export function IcomSetAtt(arg1:number):Promise; +export function IcomSetBreakIn(arg1:number):Promise; + export function IcomSetFilter(arg1:number):Promise; +export function IcomSetKeySpeed(arg1:number):Promise; + export function IcomSetMicGain(arg1:number):Promise; export function IcomSetNB(arg1:boolean):Promise; @@ -382,6 +388,8 @@ export function IcomSetSplit(arg1:boolean):Promise; export function IcomSetXITOn(arg1:boolean):Promise; +export function IcomStopCW():Promise; + export function IcomTune():Promise; export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 8612fe4..41c22a1 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -646,6 +646,10 @@ export function IcomScopeData() { return window['go']['main']['App']['IcomScopeData'](); } +export function IcomSendCW(arg1) { + return window['go']['main']['App']['IcomSendCW'](arg1); +} + export function IcomSetAFGain(arg1) { return window['go']['main']['App']['IcomSetAFGain'](arg1); } @@ -662,10 +666,18 @@ export function IcomSetAtt(arg1) { return window['go']['main']['App']['IcomSetAtt'](arg1); } +export function IcomSetBreakIn(arg1) { + return window['go']['main']['App']['IcomSetBreakIn'](arg1); +} + export function IcomSetFilter(arg1) { return window['go']['main']['App']['IcomSetFilter'](arg1); } +export function IcomSetKeySpeed(arg1) { + return window['go']['main']['App']['IcomSetKeySpeed'](arg1); +} + export function IcomSetMicGain(arg1) { return window['go']['main']['App']['IcomSetMicGain'](arg1); } @@ -726,6 +738,10 @@ export function IcomSetXITOn(arg1) { return window['go']['main']['App']['IcomSetXITOn'](arg1); } +export function IcomStopCW() { + return window['go']['main']['App']['IcomStopCW'](); +} + export function IcomTune() { return window['go']['main']['App']['IcomTune'](); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index cfcc435..1c1df78 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -679,6 +679,8 @@ export namespace cat { rit_hz: number; rit_on: boolean; xit_on: boolean; + key_speed_wpm: number; + break_in: number; rf_power: number; mic_gain: number; af_gain: number; @@ -710,6 +712,8 @@ export namespace cat { this.rit_hz = source["rit_hz"]; this.rit_on = source["rit_on"]; this.xit_on = source["xit_on"]; + this.key_speed_wpm = source["key_speed_wpm"]; + this.break_in = source["break_in"]; this.rf_power = source["rf_power"]; this.mic_gain = source["mic_gain"]; this.af_gain = source["af_gain"]; diff --git a/internal/cat/cat.go b/internal/cat/cat.go index 92ad8e5..b86256c 100644 --- a/internal/cat/cat.go +++ b/internal/cat/cat.go @@ -386,6 +386,9 @@ type IcomTXState struct { RITHz int `json:"rit_hz"` // RIT/XIT offset, signed Hz RITOn bool `json:"rit_on"` XITOn bool `json:"xit_on"` + // CW keyer (send messages via the rig's internal keyer, CI-V 0x17). + KeySpeedWPM int `json:"key_speed_wpm"` // current KEY SPEED in WPM + BreakIn int `json:"break_in"` // CW break-in: 0=OFF, 1=SEMI, 2=FULL // Set controls. RFPower int `json:"rf_power"` // 0-100 (TX output) MicGain int `json:"mic_gain"` // 0-100 @@ -429,6 +432,10 @@ type IcomController interface { SetRIT(int) error // RIT/ΔTX offset in signed Hz SetRITOn(bool) error // RIT on/off SetXITOn(bool) error // ΔTX (XIT) on/off + SendCW(string) error // key a CW message via the rig's keyer (CI-V 0x17) + StopCW() error // abort the CW message being sent + SetKeySpeed(int) error // CW keyer speed in WPM + SetBreakIn(int) error // CW break-in: 0=OFF, 1=SEMI, 2=FULL } // ScopeSweep is one complete spectrum-scope sweep reassembled from the Icom's diff --git a/internal/cat/civ/civ.go b/internal/cat/civ/civ.go index 64d5b27..dd4d3bc 100644 --- a/internal/cat/civ/civ.go +++ b/internal/cat/civ/civ.go @@ -45,6 +45,15 @@ const ( CmdATU = 0x1C // sub 0x01 = antenna tuner (0x00 off, 0x01 through, 0x02 tune) CmdScope = 0x27 // spectrum-scope waveform stream (sub 0x00 = data, 0x11 = on/off) CmdRIT = 0x21 // RIT/ΔTX: sub 0x00 offset freq, 0x01 RIT on/off, 0x02 ΔTX(XIT) on/off + CmdSendCW = 0x17 // send a CW message (ASCII, ≤30 chars) via the rig's keyer; data 0xFF = stop + + SubLevelKeySpeed = 0x0C // CmdLevel: CW keying speed (0-255 → KeyMinWPM..KeyMaxWPM) + + // CW keyer speed range for the KEY SPEED level (IC-7610: 6-48 WPM). + KeyMinWPM = 6 + KeyMaxWPM = 48 + + StopCWByte = 0xFF // 0x17 data byte that stops an in-progress CW message SubRITFreq = 0x00 // RIT/ΔTX offset: 2 BCD bytes (LE, 0-9999) + sign byte (00 +, 01 -) SubRITOn = 0x01 // RIT on/off (00/01) @@ -85,6 +94,14 @@ const ( SubSwNB = 0x22 // noise blanker on/off SubSwNR = 0x40 // noise reduction on/off SubSwANF = 0x41 // auto-notch on/off + SubSwBreakIn = 0x47 // CW break-in: 0=OFF, 1=SEMI, 2=FULL (needed so 0x17 CW keys TX) +) + +// CW break-in modes (CmdSwitch 0x47). +const ( + BreakInOff = 0 + BreakInSemi = 1 + BreakInFull = 2 ) // Icom mode codes (used by CmdReadMode / CmdSetMode). @@ -191,6 +208,51 @@ func BCDToRIT(b []byte) int { return v } +// WPMToKeyLevel maps a CW speed in words-per-minute to the 0-255 value the KEY +// SPEED level (CmdLevel 0x0C) expects, linear across KeyMinWPM..KeyMaxWPM. +func WPMToKeyLevel(wpm int) int { + if wpm < KeyMinWPM { + wpm = KeyMinWPM + } + if wpm > KeyMaxWPM { + wpm = KeyMaxWPM + } + return (wpm - KeyMinWPM) * 255 / (KeyMaxWPM - KeyMinWPM) +} + +// KeyLevelToWPM is the inverse of WPMToKeyLevel (0-255 → WPM). +func KeyLevelToWPM(v int) int { + if v < 0 { + v = 0 + } + if v > 255 { + v = 255 + } + return KeyMinWPM + (v*(KeyMaxWPM-KeyMinWPM)+127)/255 +} + +// CWText is the set of characters the rig's keyer accepts (command 0x17). +// Everything else is dropped. Space keys a word gap. +const CWText = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 /?.,-=+@:" + +// FilterCW upper-cases text and keeps only keyer-legal characters. +func FilterCW(text string) string { + out := make([]byte, 0, len(text)) + for i := 0; i < len(text); i++ { + c := text[i] + if c >= 'a' && c <= 'z' { + c -= 32 + } + for j := 0; j < len(CWText); j++ { + if CWText[j] == c { + out = append(out, c) + break + } + } + } + return string(out) +} + // ByteToBCD / BCDToByte handle a single packed-BCD byte (used by the // attenuator, where the value is dB: 0x00, 0x06, 0x12, 0x18…). func ByteToBCD(v int) byte { diff --git a/internal/cat/icomserial.go b/internal/cat/icomserial.go index d0a2ce2..0a29fd9 100644 --- a/internal/cat/icomserial.go +++ b/internal/cat/icomserial.go @@ -551,6 +551,91 @@ func (b *IcomSerial) SetXITOn(on bool) error { return nil } +// SendCW keys a CW message through the rig's internal keyer (CI-V 0x17). The +// text is upper-cased and filtered to keyer-legal characters; the radio must be +// in CW mode. Messages longer than 30 characters are split across several 0x17 +// commands (the rig queues them). +func (b *IcomSerial) SendCW(text string) error { + msg := civ.FilterCW(text) + if msg == "" { + applog.Printf("icom cw: nothing to send (filtered %q → empty)", text) + return nil + } + applog.Printf("icom cw: send %q (%d chars) to rig 0x%02X", msg, len(msg), b.rigAddr) + for len(msg) > 0 { + n := len(msg) + if n > 30 { + n = 30 + } + chunk := msg[:n] + msg = msg[n:] + payload := append([]byte{civ.CmdSendCW}, []byte(chunk)...) + if err := b.write(payload...); err != nil { + applog.Printf("icom cw: write failed: %v", err) + return err + } + // A missing ack is NOT fatal: some firmwares don't acknowledge 0x17, and + // the message bytes were already written. Only an explicit NG (0xFA) means + // the rig refused it (typically: not in CW mode / break-in off). + f, err := b.recv(icomCmdTimeout, func(d civ.Decoded) bool { return d.Cmd == civ.OK || d.Cmd == civ.NG }) + if err != nil { + applog.Printf("icom cw: chunk %q written, no ack (sent anyway): %v", chunk, err) + } else if f.Cmd == civ.NG { + applog.Printf("icom cw: rig REJECTED CW (0xFA) — put the rig in CW mode / enable break-in") + return fmt.Errorf("icom: rig rejected CW — check CW mode / break-in") + } else { + applog.Printf("icom cw: chunk %q acked OK", chunk) + } + } + return nil +} + +// SetBreakIn sets CW break-in (0=OFF, 1=SEMI, 2=FULL). Break-in must be on for +// the 0x17 CW keyer to actually switch the rig to transmit. +func (b *IcomSerial) SetBreakIn(mode int) error { + if mode < 0 { + mode = 0 + } + if mode > 2 { + mode = 2 + } + applog.Printf("icom cw: set break-in %d", mode) + if err := b.exec(civ.CmdSwitch, civ.SubSwBreakIn, byte(mode)); err != nil { + applog.Printf("icom cw: set break-in failed: %v", err) + return err + } + b.setCache(func(s *IcomTXState) { s.BreakIn = mode }) + return nil +} + +// StopCW aborts a CW message currently being sent (0x17 with the 0xFF stop code). +func (b *IcomSerial) StopCW() error { + applog.Printf("icom cw: stop") + if err := b.write(civ.CmdSendCW, civ.StopCWByte); err != nil { + return err + } + _, _ = b.recv(icomCmdTimeout, func(d civ.Decoded) bool { return d.Cmd == civ.OK || d.Cmd == civ.NG }) + return nil +} + +// SetKeySpeed sets the CW keyer speed in WPM (CmdLevel 0x0C). +func (b *IcomSerial) SetKeySpeed(wpm int) error { + lvl := civ.WPMToKeyLevel(wpm) + applog.Printf("icom cw: set key speed %d WPM (level %d)", wpm, lvl) + if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelKeySpeed}, civ.LevelToBCD(lvl)...)...); err != nil { + applog.Printf("icom cw: set key speed failed: %v", err) + return err + } + if wpm < civ.KeyMinWPM { + wpm = civ.KeyMinWPM + } + if wpm > civ.KeyMaxWPM { + wpm = civ.KeyMaxWPM + } + b.setCache(func(s *IcomTXState) { s.KeySpeedWPM = wpm }) + return nil +} + // readRIT reads the offset + RIT/ΔTX on-off flags into st (best-effort). func (b *IcomSerial) readRIT(st *IcomTXState) { if err := b.write(civ.CmdRIT, civ.SubRITFreq); err == nil { @@ -812,6 +897,12 @@ func (b *IcomSerial) readDSP() { st.Filter = int(f) } b.readRIT(&st) + if v, ok := b.readLevel(civ.SubLevelKeySpeed); ok { + st.KeySpeedWPM = civ.KeyLevelToWPM(v) + } + if v, ok := b.readSwitch(civ.SubSwBreakIn); ok { + st.BreakIn = int(v) + } b.dspMu.Lock() b.dsp = st diff --git a/internal/cwdecode/cwdecode.go b/internal/cwdecode/cwdecode.go index 894a322..28e7e8b 100644 --- a/internal/cwdecode/cwdecode.go +++ b/internal/cwdecode/cwdecode.go @@ -325,15 +325,12 @@ func (d *Decoder) endMark(hops int) { } // adaptDot nudges the dot-length estimate toward an observation (EMA, clamped -// to ~5–100 WPM). The first marks of a new signal adapt FAST so the WPM (and -// therefore the character/word gap thresholds) converge within the first -// character or two — otherwise a wrong seed mis-times early gaps and runs -// characters/words together. +// to ~5–60 WPM). The EMA is deliberately GENTLE (0.2) and NOT accelerated on the +// opening marks: a fast alpha let short noise blips (misclassified as dots) drag +// the dot-length down to the clamp within a few marks — the "60 WPM, all dits" +// garbage. The slow EMA is self-correcting because genuine marks pull it back up. func (d *Decoder) adaptDot(obs float64) { - alpha := 0.2 - if d.markCount < 6 { - alpha = 0.5 // fast convergence on the opening marks - } + const alpha = 0.2 d.markCount++ d.dotHops = d.dotHops*(1-alpha) + obs*alpha if d.dotHops < 5 { // 5 hops ≈ 60 WPM ceiling — never 100 diff --git a/internal/integrations/udp/config.go b/internal/integrations/udp/config.go index 66ed70a..d856945 100644 --- a/internal/integrations/udp/config.go +++ b/internal/integrations/udp/config.go @@ -29,11 +29,14 @@ const ( type ServiceType string const ( - ServiceWSJT ServiceType = "wsjt" // WSJT-X / JTDX / MSHV binary - ServiceADIF ServiceType = "adif" // text ADIF over UDP - ServiceN1MM ServiceType = "n1mm" // N1MM Logger+ XML - ServiceRemoteCall ServiceType = "remote_call" // plain text callsign - ServiceDBUpdated ServiceType = "db_updated" // outbound ADIF of local QSO + ServiceWSJT ServiceType = "wsjt" // WSJT-X / JTDX / MSHV binary (inbound) + ServiceADIF ServiceType = "adif" // text ADIF over UDP (inbound) + ServiceN1MM ServiceType = "n1mm" // N1MM Logger+ XML (inbound) + ServiceRemoteCall ServiceType = "remote_call" // plain text callsign (inbound) + // Outbound emitters. + ServiceDBUpdated ServiceType = "db_updated" // ADIF of each locally-logged QSO (on save) + ServicePstFreq ServiceType = "pstrotator_freq" // radio freq (on freq change) + ServiceN1MMRadio ServiceType = "n1mm_radioinfo" // N1MM RadioInfo XML: freq+mode (on freq/mode change) ) // Config is one user-defined UDP connection. diff --git a/internal/integrations/udp/outbound.go b/internal/integrations/udp/outbound.go new file mode 100644 index 0000000..fcf6a7a --- /dev/null +++ b/internal/integrations/udp/outbound.go @@ -0,0 +1,104 @@ +package udp + +import ( + "fmt" + "strings" + + "hamlog/internal/applog" +) + +// This file holds the outbound emitters: OpsLog → other programs over UDP. +// Formats are chosen per connection row (like the inbound parsers), so the user +// can point PstRotator, a second logger, an SDR, etc. at OpsLog. + +// tensOfHz converts a frequency in Hz to the "tens of Hz" unit N1MM and +// PstRotator use for their frequency fields (e.g. 14 025 500 Hz → 1 402 550). +func tensOfHz(freqHz int64) int64 { return freqHz / 10 } + +// BuildPstFreq builds the datagram PstRotatorAz expects for its "DXLog.net" +// tracker: {tens of Hz}. Verified by probing a +// live PstRotatorAz — it reads the value as tens of Hz (14025.5 kHz → 1402550 → +// displayed 3.5255 MHz for 3525.5 kHz, etc.). +func BuildPstFreq(freqHz int64) []byte { + return []byte(fmt.Sprintf("%d", tensOfHz(freqHz))) +} + +// BuildN1MMRadioInfo builds an N1MM Logger+ RadioInfo XML datagram. and +// are in tens of Hz. Consumed by PstRotator (as the "N1MM Logger" +// tracker) and many other programs. mode is passed through (CW/USB/LSB/…). +func BuildN1MMRadioInfo(station string, rxFreqHz, txFreqHz int64, mode, opCall string) []byte { + if station == "" { + station = "OPSLOG" + } + if txFreqHz == 0 { + txFreqHz = rxFreqHz + } + var b strings.Builder + b.WriteString(``) + b.WriteString(``) + b.WriteString(`` + xmlEsc(station) + ``) + b.WriteString(`1`) + b.WriteString(fmt.Sprintf(`%d`, tensOfHz(rxFreqHz))) + b.WriteString(fmt.Sprintf(`%d`, tensOfHz(txFreqHz))) + b.WriteString(`` + xmlEsc(mode) + ``) + b.WriteString(`` + xmlEsc(opCall) + ``) + b.WriteString(`True`) + b.WriteString(`0`) + b.WriteString(`0`) + b.WriteString(``) + b.WriteString(`1`) + b.WriteString(`False`) + b.WriteString(`1`) + b.WriteString(``) + return []byte(b.String()) +} + +func xmlEsc(s string) string { + r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """, "'", "'") + return r.Replace(s) +} + +// RadioState is a snapshot the app pushes to EmitRadioState on freq/mode change. +type RadioState struct { + StationName string + OpCall string + RxFreqHz int64 // operating/RX frequency + TxFreqHz int64 // TX frequency (may equal RX when not split) + Mode string +} + +// EmitRadioState sends the current radio frequency/mode to every enabled +// outbound row whose format is frequency-based (PstRotator, N1MM RadioInfo). +// Best-effort: send errors are logged, never returned to the caller. +func (m *Manager) EmitRadioState(st RadioState) { + for _, c := range m.Outbound(ServicePstFreq) { + m.sendTo(c, BuildPstFreq(st.RxFreqHz)) + } + for _, c := range m.Outbound(ServiceN1MMRadio) { + m.sendTo(c, BuildN1MMRadioInfo(st.StationName, st.RxFreqHz, st.TxFreqHz, st.Mode, st.OpCall)) + } +} + +// EmitLoggedADIF sends the ADIF of a just-logged QSO to every enabled outbound +// "ADIF message" row (db_updated) — lets a second logger or Cloudlog gateway +// pick up contacts as they're logged. +func (m *Manager) EmitLoggedADIF(adif string) { + if strings.TrimSpace(adif) == "" { + return + } + for _, c := range m.Outbound(ServiceDBUpdated) { + m.sendTo(c, []byte(adif)) + } +} + +// sendTo resolves the row's destination (host:port) and fires one datagram. +func (m *Manager) sendTo(c Config, payload []byte) { + host := strings.TrimSpace(c.DestinationIP) + if host == "" { + host = "127.0.0.1" + } + dst := fmt.Sprintf("%s:%d", host, c.Port) + if err := SendUDP(dst, payload); err != nil { + applog.Printf("udp: [%s] outbound send to %s failed: %v", c.Name, dst, err) + } +}