feat: While importing ADIF, update MY fields
This commit is contained in:
@@ -25,6 +25,7 @@ import (
|
||||
"hamlog/internal/backup"
|
||||
"hamlog/internal/cat"
|
||||
"hamlog/internal/clublog"
|
||||
"hamlog/internal/cwdecode"
|
||||
"hamlog/internal/cluster"
|
||||
"hamlog/internal/db"
|
||||
"hamlog/internal/dxcc"
|
||||
@@ -380,6 +381,8 @@ type App struct {
|
||||
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
|
||||
cwMu sync.Mutex // guards the CW decoder lifecycle
|
||||
cwStop chan struct{} // stops the CW decoder capture loop; nil when off
|
||||
cwDecoder *cwdecode.Decoder // live decoder (for retargeting the pitch)
|
||||
cwPitchHz int // manual pitch override (0 = auto / follow Flex)
|
||||
dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord)
|
||||
dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends
|
||||
pttMu sync.Mutex
|
||||
@@ -1481,7 +1484,7 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
|
||||
}
|
||||
}
|
||||
}()
|
||||
a.applyStationDefaults(&q)
|
||||
a.applyStationDefaults(&q, true)
|
||||
a.applyDXCCNumber(&q)
|
||||
a.applyClublogException(&q, false) // override entity for date-ranged DXpeditions
|
||||
a.refineDistrictZones(&q) // W6 → CQ3/ITU6 for zone-split countries
|
||||
@@ -1604,7 +1607,7 @@ func (a *App) refineDistrictZones(q *qso.QSO) {
|
||||
// currently-active profile's values. Multi-profile support means a user
|
||||
// can be /P with a different callsign + grid + SOTA ref than home — the
|
||||
// QSO carries whichever profile was selected at log time.
|
||||
func (a *App) applyStationDefaults(q *qso.QSO) {
|
||||
func (a *App) applyStationDefaults(q *qso.QSO, includeIdentity bool) {
|
||||
if a.profiles == nil {
|
||||
return
|
||||
}
|
||||
@@ -1612,15 +1615,17 @@ func (a *App) applyStationDefaults(q *qso.QSO) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if q.StationCallsign == "" {
|
||||
// STATION_CALLSIGN drives upload routing, so only stamp it on NEW QSOs — on
|
||||
// import backfill, stamping the active call onto a QSO that lacked one could
|
||||
// misroute it in a mixed-call log.
|
||||
if includeIdentity && q.StationCallsign == "" {
|
||||
q.StationCallsign = p.Callsign
|
||||
}
|
||||
// OPERATOR and OWNER_CALLSIGN are descriptive (not used for routing), so fill
|
||||
// them whenever empty — including on import.
|
||||
if q.Operator == "" {
|
||||
q.Operator = p.Operator
|
||||
}
|
||||
// OWNER_CALLSIGN is a valid ADIF field but not a promoted column, so it
|
||||
// lives in Extras (exported verbatim, round-trips, and is filterable via
|
||||
// json_extract). Stamp it from the active profile when set.
|
||||
if strings.TrimSpace(p.OwnerCallsign) != "" {
|
||||
if q.Extras == nil {
|
||||
q.Extras = map[string]string{}
|
||||
@@ -3477,17 +3482,19 @@ func (a *App) OpenADIFFile() (string, error) {
|
||||
// cty.dat for every record, overriding what the file carries — corrects the
|
||||
// wrong COUNTRY that contest software often exports (e.g. RG2Y as Asiatic
|
||||
// Russia). Everything else in the ADIF is still preserved verbatim.
|
||||
func (a *App) ImportADIF(path string, dupMode string, applyCty bool) (adif.ImportResult, error) {
|
||||
func (a *App) ImportADIF(path string, dupMode string, applyCty bool, applyStation bool) (adif.ImportResult, error) {
|
||||
if a.qso == nil {
|
||||
return adif.ImportResult{}, fmt.Errorf("db not initialized")
|
||||
}
|
||||
if path == "" {
|
||||
return adif.ImportResult{}, fmt.Errorf("empty path")
|
||||
}
|
||||
// Import preserves the ADIF verbatim — NO station / confirmation defaults
|
||||
// are applied. Defaults are for NEW QSOs only (manual entry + UDP auto-log);
|
||||
// stamping them on a historical import would, e.g., flag old QSOs as
|
||||
// "LoTW requested" and try to re-upload them.
|
||||
// Import preserves the ADIF verbatim by default — confirmation/sent-status
|
||||
// defaults are NEVER applied (they'd flag old QSOs "LoTW requested" and try to
|
||||
// re-upload). When applyStation is on, we DO backfill empty MY_* station
|
||||
// fields (grid/rig/antenna/QTH/address…) from the active profile — those are
|
||||
// descriptive metadata and safe to fill (identity fields are still left
|
||||
// alone, see applyStationDefaults).
|
||||
im := &adif.Importer{Repo: a.qso}
|
||||
switch dupMode {
|
||||
case "update":
|
||||
@@ -3508,13 +3515,20 @@ func (a *App) ImportADIF(path string, dupMode string, applyCty bool) (adif.Impor
|
||||
_ = a.clublog.EnsureLoaded()
|
||||
}
|
||||
clLoaded := a.clublog != nil && a.clublog.Loaded()
|
||||
if applyCty {
|
||||
if applyCty || applyStation {
|
||||
im.Enrich = func(q *qso.QSO) {
|
||||
if applyCty {
|
||||
a.enrichContactedFromCtyForce(q)
|
||||
if clLoaded {
|
||||
a.applyClublogException(q, true) // force: explicit import-time correction
|
||||
}
|
||||
}
|
||||
if applyStation {
|
||||
// Backfill empty MY_* descriptive fields from the active profile
|
||||
// (identity fields left alone to keep mixed-call routing intact).
|
||||
a.applyStationDefaults(q, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
im.OnProgress = func(processed, total int) {
|
||||
wruntime.EventsEmit(a.ctx, "import:progress", map[string]int{"processed": processed, "total": total})
|
||||
@@ -6328,7 +6342,7 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
||||
// (station callsign, grid, country, zones, and the profile's default
|
||||
// MY_RIG / MY_ANTENNA) from the active profile. Without this a UDP /
|
||||
// WSJT-X auto-logged QSO carried none of the operator's own data.
|
||||
a.applyStationDefaults(&q)
|
||||
a.applyStationDefaults(&q, true)
|
||||
|
||||
// ── DXCC# + QSL defaults ──
|
||||
// applyDXCCNumber stamps the contacted-station DXCC# from the
|
||||
@@ -6910,6 +6924,55 @@ func (a *App) FlexSetANFLevel(l int) error {
|
||||
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetANFLevel(l) })
|
||||
}
|
||||
|
||||
func (a *App) FlexSetAPF(on bool) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetAPF(on) })
|
||||
}
|
||||
func (a *App) FlexSetAPFLevel(l int) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetAPFLevel(l) })
|
||||
}
|
||||
func (a *App) FlexSetCWSpeed(wpm int) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetCWSpeed(wpm) })
|
||||
}
|
||||
func (a *App) FlexSetCWPitch(hz int) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetCWPitch(hz) })
|
||||
}
|
||||
func (a *App) FlexSetCWBreakInDelay(ms int) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetCWBreakInDelay(ms) })
|
||||
}
|
||||
func (a *App) FlexSetCWSidetone(on bool) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetCWSidetone(on) })
|
||||
}
|
||||
func (a *App) FlexSetSidetoneLevel(l int) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetSidetoneLevel(l) })
|
||||
}
|
||||
func (a *App) FlexSetCWFilter(bw int) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetCWFilter(bw) })
|
||||
}
|
||||
|
||||
// SwitchCATRig hot-swaps the active OmniRig slot (Rig1 ↔ Rig2) without
|
||||
// requiring a trip through the full Settings panel. Persists the choice
|
||||
// so it survives restart.
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
"hamlog/internal/audio"
|
||||
@@ -13,6 +14,31 @@ import (
|
||||
// CW decoder: taps the RX audio device (the same "From radio" capture the DVK
|
||||
// and QSO recorder use) and streams decoded Morse text to the UI. It is started
|
||||
// only by the frontend, and only while the entry mode is CW.
|
||||
//
|
||||
// Pitch targeting: the single-channel decoder is far more reliable when it locks
|
||||
// to a KNOWN pitch (a narrow filter at the signal frequency, like a skimmer)
|
||||
// instead of auto-searching for the loudest tone. So we follow the radio's CW
|
||||
// pitch (FlexRadio cw_pitch) when available — or a manual override — and fall
|
||||
// back to auto-search otherwise.
|
||||
|
||||
// cwTargetPitch returns the pitch (Hz) the decoder should lock to: the manual
|
||||
// override if set, else the FlexRadio's CW pitch when it's in CW, else 0 (auto).
|
||||
func (a *App) cwTargetPitch() int {
|
||||
if a.cwPitchHz > 0 {
|
||||
return a.cwPitchHz
|
||||
}
|
||||
if a.cat != nil {
|
||||
if st, ok := a.cat.FlexState(); ok && st.Available {
|
||||
// Only trust the radio's pitch when it's actually in CW.
|
||||
if st.Mode == "CW" || st.Mode == "CWL" || st.Mode == "CWU" {
|
||||
if st.CWPitch > 0 {
|
||||
return st.CWPitch
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// StartCWDecoder begins decoding CW from the configured RX audio device. The
|
||||
// frontend calls this when the decoder toggle is on AND the mode is CW. Safe to
|
||||
@@ -43,6 +69,8 @@ func (a *App) StartCWDecoder() error {
|
||||
}
|
||||
},
|
||||
)
|
||||
dec.SetTarget(a.cwTargetPitch())
|
||||
a.cwDecoder = dec
|
||||
|
||||
stop := make(chan struct{})
|
||||
a.cwStop = stop
|
||||
@@ -53,21 +81,38 @@ func (a *App) StartCWDecoder() error {
|
||||
wruntime.EventsEmit(a.ctx, "cw:error", err.Error())
|
||||
}
|
||||
}
|
||||
// Capture ended (stopped or errored) — clear state so a restart works.
|
||||
a.cwMu.Lock()
|
||||
if a.cwStop == stop {
|
||||
a.cwStop = nil
|
||||
a.cwDecoder = nil
|
||||
}
|
||||
a.cwMu.Unlock()
|
||||
}()
|
||||
// Follow the radio's CW pitch live (every second) while this run is active.
|
||||
go a.cwFollowPitch(stop, dec)
|
||||
return nil
|
||||
}
|
||||
|
||||
// cwFollowPitch keeps the decoder locked to the current target pitch until stop.
|
||||
func (a *App) cwFollowPitch(stop <-chan struct{}, dec *cwdecode.Decoder) {
|
||||
t := time.NewTicker(time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-t.C:
|
||||
dec.SetTarget(a.cwTargetPitch())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// StopCWDecoder halts the CW decoder if running.
|
||||
func (a *App) StopCWDecoder() {
|
||||
a.cwMu.Lock()
|
||||
stop := a.cwStop
|
||||
a.cwStop = nil
|
||||
a.cwDecoder = nil
|
||||
a.cwMu.Unlock()
|
||||
if stop != nil {
|
||||
close(stop)
|
||||
@@ -80,3 +125,25 @@ func (a *App) CWDecoderRunning() bool {
|
||||
defer a.cwMu.Unlock()
|
||||
return a.cwStop != nil
|
||||
}
|
||||
|
||||
// SetCWDecoderPitch sets a manual decode pitch (Hz); 0 returns to auto (follow
|
||||
// the Flex CW pitch, or search). Applies live to a running decoder.
|
||||
func (a *App) SetCWDecoderPitch(hz int) {
|
||||
if hz < 0 {
|
||||
hz = 0
|
||||
}
|
||||
a.cwMu.Lock()
|
||||
a.cwPitchHz = hz
|
||||
dec := a.cwDecoder
|
||||
a.cwMu.Unlock()
|
||||
if dec != nil {
|
||||
dec.SetTarget(a.cwTargetPitch())
|
||||
}
|
||||
}
|
||||
|
||||
// GetCWDecoderPitch returns the manual override (0 = auto / follow Flex).
|
||||
func (a *App) GetCWDecoderPitch() int {
|
||||
a.cwMu.Lock()
|
||||
defer a.cwMu.Unlock()
|
||||
return a.cwPitchHz
|
||||
}
|
||||
|
||||
+32
-2
@@ -29,7 +29,7 @@ import {
|
||||
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus,
|
||||
WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace,
|
||||
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
|
||||
StartCWDecoder, StopCWDecoder,
|
||||
StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
|
||||
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart,
|
||||
GetAwardDefs,
|
||||
GetUIPref,
|
||||
@@ -600,6 +600,13 @@ export default function App() {
|
||||
// Keep the decoded line scrolled to the newest text (left-aligned, no scrollbar).
|
||||
const cwScrollRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => { const el = cwScrollRef.current; if (el) el.scrollLeft = el.scrollWidth; }, [cwText]);
|
||||
// Manual pitch override ('' = Auto: follow the radio's CW pitch / search).
|
||||
const [cwPitch, setCwPitch] = useState(() => localStorage.getItem('opslog.cwPitch') || '');
|
||||
useEffect(() => {
|
||||
const hz = parseInt(cwPitch, 10);
|
||||
SetCWDecoderPitch(Number.isFinite(hz) ? hz : 0).catch(() => {});
|
||||
localStorage.setItem('opslog.cwPitch', cwPitch);
|
||||
}, [cwPitch, cwOn]);
|
||||
useEffect(() => {
|
||||
const offT = EventsOn('cw:text', (t: string) => setCwText((s) => (s + t).slice(-200)));
|
||||
const offS = EventsOn('cw:status', (st: any) => setCwStatus(st));
|
||||
@@ -769,6 +776,7 @@ export default function App() {
|
||||
const [pendingImportPath, setPendingImportPath] = useState<string | null>(null);
|
||||
const [importDupMode, setImportDupMode] = useState<'skip' | 'update' | 'all'>('skip');
|
||||
const [importApplyCty, setImportApplyCty] = useState(true);
|
||||
const [importApplyStation, setImportApplyStation] = useState(false);
|
||||
// QRZ profile photo lightbox (full-size, in-app — not the browser).
|
||||
const [photoModal, setPhotoModal] = useState<string | null>(null);
|
||||
// Esc closes the lightbox. Capture-phase + stopImmediatePropagation so the
|
||||
@@ -1939,7 +1947,7 @@ export default function App() {
|
||||
setImportErrorsOpen(false);
|
||||
setImportDupsOpen(false);
|
||||
try {
|
||||
const res = await ImportADIF(path, importDupMode, importApplyCty);
|
||||
const res = await ImportADIF(path, importDupMode, importApplyCty, importApplyStation);
|
||||
setImportResult(res);
|
||||
await refresh();
|
||||
} catch (e: any) {
|
||||
@@ -3215,6 +3223,15 @@ export default function App() {
|
||||
<span className="shrink-0 font-mono text-[10px] text-muted-foreground tabular-nums">
|
||||
{cwStatus.wpm > 0 ? `${cwStatus.wpm} WPM` : '— WPM'} · {cwStatus.pitch > 0 ? `${cwStatus.pitch} Hz` : '— Hz'}
|
||||
</span>
|
||||
{/* Lock pitch: blank = Auto (follow the Flex CW pitch / search). */}
|
||||
<input
|
||||
type="number"
|
||||
value={cwPitch}
|
||||
onChange={(e) => setCwPitch(e.target.value)}
|
||||
placeholder="auto"
|
||||
title="Lock the decoder to this pitch (Hz). Blank = follow the radio's CW pitch / auto-search."
|
||||
className="shrink-0 w-14 h-5 rounded border border-emerald-300/70 bg-white/60 px-1 text-[10px] font-mono text-center outline-none"
|
||||
/>
|
||||
{/* Left-aligned single line, no scrollbar; auto-scrolled to the newest
|
||||
text (see cwScrollRef effect) so the latest stays in view. */}
|
||||
<div ref={cwScrollRef} className="flex-1 min-w-0 overflow-hidden font-mono leading-5">
|
||||
@@ -3874,6 +3891,19 @@ export default function App() {
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer pt-1">
|
||||
<Checkbox
|
||||
checked={importApplyStation}
|
||||
onCheckedChange={(c) => setImportApplyStation(!!c)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
Fill my station fields from my profile
|
||||
<span className="block text-xs text-muted-foreground mt-0.5">
|
||||
Backfill <strong>empty</strong> MY_* fields (my grid, rig, antenna, address, city, state, county, SOTA/POTA ref, TX power…) plus <strong>Operator</strong> and <strong>Owner callsign</strong> from your active profile. Existing values are kept. Only <strong>STATION_CALLSIGN</strong> is left untouched so a mixed-call log isn't re-routed. Enable when importing <em>your own</em> log.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter className="px-2 bg-transparent border-t-0">
|
||||
<Button variant="outline" onClick={() => setPendingImportPath(null)}>Cancel</Button>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Radio, Zap, Mic2, Settings2, Power, AudioLines, Antenna, Flame, Gauge } from 'lucide-react';
|
||||
import { Radio, Zap, Power, AudioLines, Flame, Gauge } from 'lucide-react';
|
||||
import {
|
||||
GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
|
||||
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
|
||||
FlexMox, FlexATUStart, FlexATUBypass, FlexSetATUMemories, FlexAmpOperate,
|
||||
FlexMox, FlexAmpOperate,
|
||||
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel,
|
||||
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
|
||||
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
|
||||
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -18,6 +20,9 @@ type FlexState = {
|
||||
atu_status?: string; atu_memories: boolean;
|
||||
rx_avail: boolean; agc_mode?: string; agc_threshold: number; audio_level: number;
|
||||
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number;
|
||||
mode?: string;
|
||||
cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number;
|
||||
apf: boolean; apf_level: number; filter_lo: number; filter_hi: number;
|
||||
amp_available: boolean; amp_model?: string; amp_operate: boolean; amp_fault?: string;
|
||||
meters?: Meter[];
|
||||
};
|
||||
@@ -30,6 +35,8 @@ const ZERO: FlexState = {
|
||||
mon: false, mon_level: 0, mic_level: 0, atu_memories: false,
|
||||
rx_avail: false, agc_threshold: 0, audio_level: 0,
|
||||
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, anf_level: 0,
|
||||
cw_speed: 25, cw_pitch: 600, cw_break_in_delay: 30, cw_sidetone: true, cw_mon_level: 0,
|
||||
apf: false, apf_level: 0, filter_lo: 0, filter_hi: 0,
|
||||
amp_available: false, amp_operate: false,
|
||||
};
|
||||
|
||||
@@ -174,6 +181,15 @@ function Card({ icon: Icon, title, accent, children }: { icon: any; title: strin
|
||||
export function FlexPanel() {
|
||||
const [st, setSt] = useState<FlexState>(ZERO);
|
||||
const hold = useRef<Record<string, number>>({});
|
||||
// Peak-hold: keep the highest reading for ~2 s so the jittery VITA-49 meters
|
||||
// read steadily instead of jumping every poll.
|
||||
const peak = useRef<Record<string, { v: number; t: number }>>({});
|
||||
const peakHold = (key: string, val: number) => {
|
||||
const now = Date.now();
|
||||
const p = peak.current[key];
|
||||
if (!p || val >= p.v || now - p.t > 2000) { peak.current[key] = { v: val, t: now }; return val; }
|
||||
return p.v;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
@@ -204,8 +220,11 @@ export function FlexPanel() {
|
||||
|
||||
const off = !st.available;
|
||||
const rxOff = off || !st.rx_avail;
|
||||
const isCW = (st.mode || '').toUpperCase().includes('CW');
|
||||
const PROC = [{ v: '0', l: 'NOR' }, { v: '1', l: 'DX' }, { v: '2', l: 'DX+' }];
|
||||
const AGC = [{ v: 'off', l: 'OFF' }, { v: 'slow', l: 'SLOW' }, { v: 'med', l: 'MED' }, { v: 'fast', l: 'FAST' }];
|
||||
const CW_BW = [100, 200, 300, 400, 500];
|
||||
const curBW = Math.max(0, (st.filter_hi || 0) - (st.filter_lo || 0));
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0 overflow-auto bg-background">
|
||||
@@ -262,6 +281,7 @@ export function FlexPanel() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!isCW ? (
|
||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Chip on={st.proc_enable} disabled={off} label="PROC" accent="violet"
|
||||
@@ -288,6 +308,29 @@ export function FlexPanel() {
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mic_level}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* CW keyer controls (replace VOX/PROC/MIC when the slice is in CW). */
|
||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-16 shrink-0 text-[11px] font-bold text-muted-foreground">Speed</span>
|
||||
<Slider value={st.cw_speed} disabled={off} max={60} accent="#0d9488" onChange={(v) => change('cw_speed', v, () => FlexSetCWSpeed(v))} />
|
||||
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.cw_speed} wpm</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-16 shrink-0 text-[11px] font-bold text-muted-foreground">Pitch</span>
|
||||
<Slider value={st.cw_pitch} disabled={off} max={1000} step={10} accent="#7c3aed" onChange={(v) => change('cw_pitch', v, () => FlexSetCWPitch(v))} />
|
||||
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.cw_pitch} Hz</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-16 shrink-0 text-[11px] font-bold text-muted-foreground">Delay</span>
|
||||
<Slider value={st.cw_break_in_delay} disabled={off} max={1000} step={1} accent="#d97706" onChange={(v) => change('cw_break_in_delay', v, () => FlexSetCWBreakInDelay(v))} />
|
||||
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.cw_break_in_delay} ms</span>
|
||||
</div>
|
||||
<LevelRow label="STONE" on={st.cw_sidetone} disabled={off} value={st.cw_mon_level} accent="cyan" sliderAccent="#0891b2"
|
||||
onToggle={() => change('cw_sidetone', !st.cw_sidetone, () => FlexSetCWSidetone(!st.cw_sidetone))}
|
||||
onLevel={(v) => change('cw_mon_level', v, () => FlexSetSidetoneLevel(v))} />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* RECEIVE */}
|
||||
@@ -318,28 +361,29 @@ export function FlexPanel() {
|
||||
onToggle={() => change('anf', !st.anf, () => FlexSetANF(!st.anf))}
|
||||
onLevel={(v) => change('anf_level', v, () => FlexSetANFLevel(v))} />
|
||||
</div>
|
||||
</Card>
|
||||
{isCW && (
|
||||
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||
<LevelRow label="APF" on={st.apf} disabled={rxOff} value={st.apf_level} accent="emerald" sliderAccent="#16a34a"
|
||||
onToggle={() => change('apf', !st.apf, () => FlexSetAPF(!st.apf))}
|
||||
onLevel={(v) => change('apf_level', v, () => FlexSetAPFLevel(v))} />
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Filter</span>
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||
{CW_BW.map((bw) => (
|
||||
<button key={bw} type="button" disabled={rxOff}
|
||||
onClick={() => { setSt((p) => { const c = ((p.filter_lo || 0) + (p.filter_hi || 0)) ? Math.round(((p.filter_lo || 0) + (p.filter_hi || 0)) / 2) : (p.cw_pitch || 600); return { ...p, filter_lo: c - bw / 2, filter_hi: c + bw / 2 }; }); FlexSetCWFilter(bw).catch(() => {}); }}
|
||||
className={cn('px-2 py-1 text-[11px] font-bold tracking-wide transition-colors disabled:opacity-30 border-l border-border first:border-l-0',
|
||||
Math.abs(curBW - bw) <= 1 ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||
{bw}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground/70 font-mono">Hz</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ATU */}
|
||||
<Card icon={Settings2} title="Antenna Tuner">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button type="button" disabled={off} onClick={() => FlexATUStart().catch(() => {})}
|
||||
className="px-3 py-1.5 rounded-md text-xs font-bold border border-emerald-400 text-emerald-700 hover:bg-emerald-50 disabled:opacity-30">
|
||||
<Antenna className="size-3.5 inline mr-1 -mt-0.5" /> Tune ATU
|
||||
</button>
|
||||
<button type="button" disabled={off} onClick={() => FlexATUBypass().catch(() => {})}
|
||||
className="px-3 py-1.5 rounded-md text-xs font-bold border border-border text-muted-foreground hover:bg-muted disabled:opacity-30">
|
||||
Bypass
|
||||
</button>
|
||||
<Chip on={st.atu_memories} disabled={off} label="MEM"
|
||||
onClick={() => change('atu_memories', !st.atu_memories, () => FlexSetATUMemories(!st.atu_memories))} />
|
||||
<div className="flex-1" />
|
||||
{st.atu_status && (
|
||||
<span className="text-xs font-mono text-muted-foreground">{st.atu_status.replace(/_/g, ' ')}</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* External amplifier (PowerGenius XL) — only when detected. */}
|
||||
{st.amp_available && (
|
||||
@@ -378,9 +422,6 @@ export function FlexPanel() {
|
||||
const sig = radio('LEVEL') || radio('SIGNAL');
|
||||
const fwd = radio('FWDPWR');
|
||||
const swr = radio('SWR');
|
||||
const alc = radio('ALC');
|
||||
const temp = radio('PATEMP');
|
||||
const volts = radio('13.8B') || meters.find((m) => /volts/i.test(m.unit || '') && !(m.src || '').toUpperCase().includes('AMP'));
|
||||
const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
||||
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
||||
const accentFor = (m: Meter) => /swr/i.test(`${m.unit}${m.name}`) ? '#dc2626'
|
||||
@@ -396,16 +437,15 @@ export function FlexPanel() {
|
||||
return { display: `S${Math.max(0, Math.round(s))}`, bar: Math.max(0, s) };
|
||||
};
|
||||
const cur = [
|
||||
sig && (() => { const s = sUnit(sig.value); return (
|
||||
<MeterBar key="s" label="S-METER" value={s.bar} lo={0} hi={19} accent="#16a34a" display={s.display} extra={`${sig.value.toFixed(1)} dBm`}
|
||||
sig && (() => { const dbm = peakHold('s', sig.value); const s = sUnit(dbm); return (
|
||||
<MeterBar key="s" label="S-METER" value={s.bar} lo={0} hi={19} accent="#16a34a" display={s.display} extra={`${dbm.toFixed(1)} dBm`}
|
||||
segColor={(fr) => { const sv = fr * 19; return sv < 9 ? '#16a34a' : sv < 14 ? '#f59e0b' : '#dc2626'; }} />
|
||||
); })(),
|
||||
fwd && <MeterBar key="p" label="PWR" unit="W" lo={0} hi={120} accent="#dc2626"
|
||||
value={isDbm(fwd) ? dbmToW(fwd.value) : fwd.value} extra={isDbm(fwd) ? `${fwd.value.toFixed(1)} dBm` : undefined} />,
|
||||
swr && <MeterBar key="w" label="SWR" value={swr.value} unit="" lo={1} hi={3} accent="#d97706" />,
|
||||
alc && <MeterBar key="a" label="ALC" value={alc.value} unit={alc.unit} lo={alc.lo} hi={alc.hi || 100} accent="#7c3aed" />,
|
||||
temp && <MeterBar key="t" label="PA TEMP" value={temp.value} unit={temp.unit} lo={temp.lo || 0} hi={temp.hi || 80} accent="#ea580c" />,
|
||||
volts && <MeterBar key="v" label="VOLTS" value={volts.value} unit={volts.unit} lo={volts.lo || 0} hi={volts.hi || 15} accent="#2563eb" />,
|
||||
fwd && (() => { const w = peakHold('p', isDbm(fwd) ? dbmToW(fwd.value) : fwd.value); return (
|
||||
<MeterBar key="p" label="PWR" unit="W" lo={0} hi={120} accent="#dc2626"
|
||||
value={w} extra={isDbm(fwd) ? `${fwd.value.toFixed(1)} dBm` : undefined} />
|
||||
); })(),
|
||||
swr && <MeterBar key="w" label="SWR" value={peakHold('w', swr.value)} unit="" lo={1} hi={3} accent="#d97706" />,
|
||||
].filter(Boolean);
|
||||
return (
|
||||
<>
|
||||
@@ -416,9 +456,9 @@ export function FlexPanel() {
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{amp.map((m) => {
|
||||
if (/fwd|pwr/i.test(m.name || '') && isDbm(m)) {
|
||||
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={dbmToW(m.value)} unit="W" lo={0} hi={1500} accent="#dc2626" extra={`${m.value.toFixed(1)} dBm`} />;
|
||||
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" extra={`${m.value.toFixed(1)} dBm`} />;
|
||||
}
|
||||
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={m.value} unit={m.unit} lo={m.lo} hi={m.hi} accent={accentFor(m)} />;
|
||||
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={m.lo} hi={m.hi} accent={accentFor(m)} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Vendored
+21
-1
@@ -139,10 +139,24 @@ export function FlexSetANF(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetANFLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetAPF(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetAPFLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetATUMemories(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetAudioLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetCWBreakInDelay(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetCWFilter(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetCWPitch(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetCWSidetone(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetCWSpeed(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetMic(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetMon(arg1:boolean):Promise<void>;
|
||||
@@ -163,6 +177,8 @@ export function FlexSetProcessor(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetProcessorLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetSidetoneLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetTunePower(arg1:number):Promise<void>;
|
||||
|
||||
export function FlexSetVox(arg1:boolean):Promise<void>;
|
||||
@@ -197,6 +213,8 @@ export function GetCATSettings():Promise<main.CATSettings>;
|
||||
|
||||
export function GetCATState():Promise<cat.RigState>;
|
||||
|
||||
export function GetCWDecoderPitch():Promise<number>;
|
||||
|
||||
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
|
||||
|
||||
export function GetClusterAutoConnect():Promise<boolean>;
|
||||
@@ -265,7 +283,7 @@ export function GetWinkeyerStatus():Promise<winkeyer.Status>;
|
||||
|
||||
export function HasBuiltinReferences(arg1:string):Promise<boolean>;
|
||||
|
||||
export function ImportADIF(arg1:string,arg2:string,arg3:boolean):Promise<adif.ImportResult>;
|
||||
export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean):Promise<adif.ImportResult>;
|
||||
|
||||
export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<number>;
|
||||
|
||||
@@ -453,6 +471,8 @@ export function SetCATFrequency(arg1:number):Promise<void>;
|
||||
|
||||
export function SetCATMode(arg1:string):Promise<void>;
|
||||
|
||||
export function SetCWDecoderPitch(arg1:number):Promise<void>;
|
||||
|
||||
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetClusterAutoConnect(arg1:boolean):Promise<void>;
|
||||
|
||||
@@ -250,6 +250,14 @@ export function FlexSetANFLevel(arg1) {
|
||||
return window['go']['main']['App']['FlexSetANFLevel'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetAPF(arg1) {
|
||||
return window['go']['main']['App']['FlexSetAPF'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetAPFLevel(arg1) {
|
||||
return window['go']['main']['App']['FlexSetAPFLevel'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetATUMemories(arg1) {
|
||||
return window['go']['main']['App']['FlexSetATUMemories'](arg1);
|
||||
}
|
||||
@@ -258,6 +266,26 @@ export function FlexSetAudioLevel(arg1) {
|
||||
return window['go']['main']['App']['FlexSetAudioLevel'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetCWBreakInDelay(arg1) {
|
||||
return window['go']['main']['App']['FlexSetCWBreakInDelay'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetCWFilter(arg1) {
|
||||
return window['go']['main']['App']['FlexSetCWFilter'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetCWPitch(arg1) {
|
||||
return window['go']['main']['App']['FlexSetCWPitch'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetCWSidetone(arg1) {
|
||||
return window['go']['main']['App']['FlexSetCWSidetone'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetCWSpeed(arg1) {
|
||||
return window['go']['main']['App']['FlexSetCWSpeed'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetMic(arg1) {
|
||||
return window['go']['main']['App']['FlexSetMic'](arg1);
|
||||
}
|
||||
@@ -298,6 +326,10 @@ export function FlexSetProcessorLevel(arg1) {
|
||||
return window['go']['main']['App']['FlexSetProcessorLevel'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetSidetoneLevel(arg1) {
|
||||
return window['go']['main']['App']['FlexSetSidetoneLevel'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetTunePower(arg1) {
|
||||
return window['go']['main']['App']['FlexSetTunePower'](arg1);
|
||||
}
|
||||
@@ -366,6 +398,10 @@ export function GetCATState() {
|
||||
return window['go']['main']['App']['GetCATState']();
|
||||
}
|
||||
|
||||
export function GetCWDecoderPitch() {
|
||||
return window['go']['main']['App']['GetCWDecoderPitch']();
|
||||
}
|
||||
|
||||
export function GetClublogCtyInfo() {
|
||||
return window['go']['main']['App']['GetClublogCtyInfo']();
|
||||
}
|
||||
@@ -502,8 +538,8 @@ export function HasBuiltinReferences(arg1) {
|
||||
return window['go']['main']['App']['HasBuiltinReferences'](arg1);
|
||||
}
|
||||
|
||||
export function ImportADIF(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3);
|
||||
export function ImportADIF(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function ImportAwardReferencesText(arg1, arg2) {
|
||||
@@ -878,6 +914,10 @@ export function SetCATMode(arg1) {
|
||||
return window['go']['main']['App']['SetCATMode'](arg1);
|
||||
}
|
||||
|
||||
export function SetCWDecoderPitch(arg1) {
|
||||
return window['go']['main']['App']['SetCWDecoderPitch'](arg1);
|
||||
}
|
||||
|
||||
export function SetClublogCtyEnabled(arg1) {
|
||||
return window['go']['main']['App']['SetClublogCtyEnabled'](arg1);
|
||||
}
|
||||
|
||||
@@ -458,6 +458,16 @@ export namespace cat {
|
||||
nr_level: number;
|
||||
anf: boolean;
|
||||
anf_level: number;
|
||||
mode?: string;
|
||||
cw_speed: number;
|
||||
cw_pitch: number;
|
||||
cw_break_in_delay: number;
|
||||
cw_sidetone: boolean;
|
||||
cw_mon_level: number;
|
||||
apf: boolean;
|
||||
apf_level: number;
|
||||
filter_lo: number;
|
||||
filter_hi: number;
|
||||
amp_available: boolean;
|
||||
amp_model?: string;
|
||||
amp_operate: boolean;
|
||||
@@ -496,6 +506,16 @@ export namespace cat {
|
||||
this.nr_level = source["nr_level"];
|
||||
this.anf = source["anf"];
|
||||
this.anf_level = source["anf_level"];
|
||||
this.mode = source["mode"];
|
||||
this.cw_speed = source["cw_speed"];
|
||||
this.cw_pitch = source["cw_pitch"];
|
||||
this.cw_break_in_delay = source["cw_break_in_delay"];
|
||||
this.cw_sidetone = source["cw_sidetone"];
|
||||
this.cw_mon_level = source["cw_mon_level"];
|
||||
this.apf = source["apf"];
|
||||
this.apf_level = source["apf_level"];
|
||||
this.filter_lo = source["filter_lo"];
|
||||
this.filter_hi = source["filter_hi"];
|
||||
this.amp_available = source["amp_available"];
|
||||
this.amp_model = source["amp_model"];
|
||||
this.amp_operate = source["amp_operate"];
|
||||
|
||||
@@ -257,6 +257,17 @@ type FlexTXState struct {
|
||||
NRLevel int `json:"nr_level"`
|
||||
ANF bool `json:"anf"`
|
||||
ANFLevel int `json:"anf_level"`
|
||||
// CW / mode-specific controls.
|
||||
Mode string `json:"mode,omitempty"` // active slice mode (CW/USB/LSB/DIGU…)
|
||||
CWSpeed int `json:"cw_speed"`
|
||||
CWPitch int `json:"cw_pitch"`
|
||||
CWBreakInDelay int `json:"cw_break_in_delay"`
|
||||
CWSidetone bool `json:"cw_sidetone"`
|
||||
CWMonLevel int `json:"cw_mon_level"` // sidetone level
|
||||
APF bool `json:"apf"`
|
||||
APFLevel int `json:"apf_level"`
|
||||
FilterLo int `json:"filter_lo"`
|
||||
FilterHi int `json:"filter_hi"`
|
||||
// External amplifier (PowerGenius XL).
|
||||
AmpAvailable bool `json:"amp_available"`
|
||||
AmpModel string `json:"amp_model,omitempty"`
|
||||
@@ -307,6 +318,15 @@ type FlexController interface {
|
||||
SetNRLevel(int) error
|
||||
SetANF(bool) error
|
||||
SetANFLevel(int) error
|
||||
SetAPF(bool) error
|
||||
SetAPFLevel(int) error
|
||||
// CW keyer + mode-specific controls.
|
||||
SetCWSpeed(int) error
|
||||
SetCWPitch(int) error
|
||||
SetCWBreakInDelay(int) error
|
||||
SetCWSidetone(bool) error
|
||||
SetSidetoneLevel(int) error
|
||||
SetCWFilter(int) error
|
||||
// External amplifier (PowerGenius XL) operate/standby.
|
||||
SetAmpOperate(bool) error
|
||||
}
|
||||
|
||||
@@ -78,6 +78,10 @@ type flexSlice struct {
|
||||
nrLevel int
|
||||
anf bool // auto notch filter
|
||||
anfLevel int
|
||||
apf bool // CW audio peaking filter
|
||||
apfLevel int
|
||||
filterLo int // slice filter low cut (Hz)
|
||||
filterHi int // slice filter high cut (Hz)
|
||||
}
|
||||
|
||||
// flexTX mirrors the radio's transmit/ATU/interlock objects (the SmartSDR-style
|
||||
@@ -97,6 +101,12 @@ type flexTX struct {
|
||||
micLevel int
|
||||
atuStatus string
|
||||
atuMemories bool
|
||||
// CW keyer params (set via the top-level "cw" commands).
|
||||
cwSpeed int // WPM
|
||||
cwPitch int // Hz
|
||||
cwBreakInDelay int // ms (QSK delay)
|
||||
cwSidetone bool // sidetone (audible monitor) enable
|
||||
cwMonLevel int // sidetone level (mon_gain_cw)
|
||||
}
|
||||
|
||||
// flexAmp mirrors the external amplifier object (PowerGenius XL). handle is the
|
||||
@@ -176,6 +186,7 @@ func (f *Flex) Connect() error {
|
||||
f.send("sub atu all") // antenna-tuner status + memories
|
||||
f.send("sub amplifier all") // external amplifier (PowerGenius XL) operate/standby
|
||||
f.send("sub radio all") // radio-wide incl. interlock (TX/RX state)
|
||||
f.send("sub cwx all") // CWX: the LIVE CW speed/pitch/break-in (transmit holds only a static default)
|
||||
f.startMeters(conn) // open the UDP VITA-49 stream for live meters
|
||||
if f.spotsEnabled {
|
||||
// Subscribe so the radio pushes existing spots (we learn their indices),
|
||||
@@ -364,12 +375,43 @@ func (f *Flex) handleStatus(payload string) {
|
||||
f.tx.mon = val == "1"
|
||||
case "mon_gain_sb":
|
||||
f.tx.monLevel = atoiDefault(val, f.tx.monLevel)
|
||||
case "mon_gain_cw":
|
||||
f.tx.cwMonLevel = atoiDefault(val, f.tx.cwMonLevel)
|
||||
case "sidetone", "cw_sidetone":
|
||||
f.tx.cwSidetone = val == "1"
|
||||
// NOTE: speed/pitch/break_in_delay also appear in the transmit
|
||||
// object, but they are STALE defaults there — the LIVE values come
|
||||
// from the cwx object (see the cwx branch). Parsing them here too
|
||||
// would let the stale value overwrite the correct one depending on
|
||||
// status arrival order, so we deliberately ignore them here.
|
||||
case "mic_level", "miclevel":
|
||||
f.tx.micLevel = atoiDefault(val, f.tx.micLevel)
|
||||
}
|
||||
}
|
||||
f.mu.Unlock()
|
||||
}
|
||||
// CWX object — the LIVE CW keyer values (speed/pitch/break-in delay).
|
||||
// SmartSDR reads these here; the transmit object only carries a static
|
||||
// default. Logged in full so we can confirm the exact field names.
|
||||
if len(fields) >= 1 && fields[0] == "cwx" {
|
||||
debugLog.Printf("Flex: status %s", payload)
|
||||
f.mu.Lock()
|
||||
for _, kv := range fields[1:] {
|
||||
key, val, ok := splitKV(kv)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "wpm", "speed", "cw_speed":
|
||||
f.tx.cwSpeed = atoiDefault(val, f.tx.cwSpeed)
|
||||
case "pitch", "cw_pitch":
|
||||
f.tx.cwPitch = atoiDefault(val, f.tx.cwPitch)
|
||||
case "delay", "break_in_delay", "cw_break_in_delay":
|
||||
f.tx.cwBreakInDelay = atoiDefault(val, f.tx.cwBreakInDelay)
|
||||
}
|
||||
}
|
||||
f.mu.Unlock()
|
||||
}
|
||||
// ATU object — auto-tuner status + memories.
|
||||
if len(fields) >= 1 && fields[0] == "atu" {
|
||||
debugLog.Printf("Flex: status %s", payload)
|
||||
@@ -425,6 +467,16 @@ func (f *Flex) handleStatus(payload string) {
|
||||
f.amp.operate = val == "1" || strings.EqualFold(val, "OPERATE")
|
||||
case "mode":
|
||||
f.amp.operate = strings.EqualFold(val, "OPERATE")
|
||||
case "state":
|
||||
// The PowerGenius XL reports its live state here (the status
|
||||
// push has no operate= field). Anything but STANDBY/OFF means
|
||||
// the amp is IN LINE (OPERATE) — IDLE = operate, not keyed.
|
||||
switch strings.ToUpper(val) {
|
||||
case "STANDBY", "OFF", "POWERED_OFF", "DISCONNECTED":
|
||||
f.amp.operate = false
|
||||
case "OPERATE", "IDLE", "TRANSMIT", "TX", "RECEIVE", "RX", "KEYED", "OPERATING":
|
||||
f.amp.operate = true
|
||||
}
|
||||
case "fault":
|
||||
f.amp.fault = val
|
||||
}
|
||||
@@ -582,11 +634,28 @@ func (f *Flex) handleStatus(payload string) {
|
||||
s.anf = val == "1"
|
||||
case "anf_level":
|
||||
s.anfLevel = atoiDefault(val, s.anfLevel)
|
||||
case "apf":
|
||||
s.apf = val == "1"
|
||||
case "apf_level":
|
||||
s.apfLevel = atoiDefault(val, s.apfLevel)
|
||||
case "filter_lo":
|
||||
s.filterLo = atoiDefault(val, s.filterLo)
|
||||
case "filter_hi":
|
||||
s.filterHi = atoiDefault(val, s.filterHi)
|
||||
}
|
||||
}
|
||||
f.mu.Unlock()
|
||||
}
|
||||
|
||||
// defInt returns v, or def when v is zero (so sliders show sane defaults before
|
||||
// the radio has pushed the real value).
|
||||
func defInt(v, def int) int {
|
||||
if v == 0 {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// ReadState returns the cached state derived from the radio's push messages —
|
||||
// no round-trip, so it's always current.
|
||||
func (f *Flex) ReadState() (RigState, error) {
|
||||
@@ -899,9 +968,16 @@ func (f *Flex) FlexState() FlexTXState {
|
||||
MicLevel: f.tx.micLevel,
|
||||
ATUStatus: f.tx.atuStatus,
|
||||
ATUMemories: f.tx.atuMemories,
|
||||
// CW keyer (defaults applied so the sliders show sane values pre-read).
|
||||
CWSpeed: defInt(f.tx.cwSpeed, 25),
|
||||
CWPitch: defInt(f.tx.cwPitch, 600),
|
||||
CWBreakInDelay: defInt(f.tx.cwBreakInDelay, 30),
|
||||
CWSidetone: f.tx.cwSidetone,
|
||||
CWMonLevel: f.tx.cwMonLevel,
|
||||
}
|
||||
if _, rx := f.rxSliceLocked(); rx != nil {
|
||||
st.RXAvail = true
|
||||
st.Mode = strings.ToUpper(rx.mode)
|
||||
st.AGCMode = rx.agcMode
|
||||
st.AGCThreshold = rx.agcThreshold
|
||||
st.AudioLevel = rx.audioLevel
|
||||
@@ -911,6 +987,10 @@ func (f *Flex) FlexState() FlexTXState {
|
||||
st.NRLevel = rx.nrLevel
|
||||
st.ANF = rx.anf
|
||||
st.ANFLevel = rx.anfLevel
|
||||
st.APF = rx.apf
|
||||
st.APFLevel = rx.apfLevel
|
||||
st.FilterLo = rx.filterLo
|
||||
st.FilterHi = rx.filterHi
|
||||
}
|
||||
if f.amp.handle != "" {
|
||||
st.AmpAvailable = true
|
||||
@@ -960,6 +1040,10 @@ func (f *Flex) sendSlice(param string, val any) error {
|
||||
rx.anf = val == "1"
|
||||
case "anf_level":
|
||||
rx.anfLevel = toInt(val)
|
||||
case "apf":
|
||||
rx.apf = val == "1"
|
||||
case "apf_level":
|
||||
rx.apfLevel = toInt(val)
|
||||
}
|
||||
}
|
||||
f.mu.Unlock()
|
||||
@@ -1000,6 +1084,104 @@ func (f *Flex) SetNR(on bool) error { return f.sendSlice("nr", boolFlex(
|
||||
func (f *Flex) SetNRLevel(l int) error { return f.sendSlice("nr_level", clampLevel(l)) }
|
||||
func (f *Flex) SetANF(on bool) error { return f.sendSlice("anf", boolFlex(on)) }
|
||||
func (f *Flex) SetANFLevel(l int) error { return f.sendSlice("anf_level", clampLevel(l)) }
|
||||
func (f *Flex) SetAPF(on bool) error { return f.sendSlice("apf", boolFlex(on)) }
|
||||
func (f *Flex) SetAPFLevel(l int) error { return f.sendSlice("apf_level", clampLevel(l)) }
|
||||
|
||||
// ── CW keyer controls (top-level "cw" commands) ──
|
||||
|
||||
func (f *Flex) SetCWSpeed(wpm int) error {
|
||||
if wpm < 5 {
|
||||
wpm = 5
|
||||
} else if wpm > 100 {
|
||||
wpm = 100
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.tx.cwSpeed = wpm
|
||||
f.mu.Unlock()
|
||||
f.send(fmt.Sprintf("cw wpm %d", wpm))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Flex) SetCWPitch(hz int) error {
|
||||
if hz < 100 {
|
||||
hz = 100
|
||||
} else if hz > 6000 {
|
||||
hz = 6000
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.tx.cwPitch = hz
|
||||
f.mu.Unlock()
|
||||
f.send(fmt.Sprintf("cw pitch %d", hz))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Flex) SetCWBreakInDelay(ms int) error {
|
||||
if ms < 0 {
|
||||
ms = 0
|
||||
} else if ms > 2000 {
|
||||
ms = 2000
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.tx.cwBreakInDelay = ms
|
||||
f.mu.Unlock()
|
||||
f.send(fmt.Sprintf("cw break_in_delay %d", ms))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Flex) SetCWSidetone(on bool) error {
|
||||
f.mu.Lock()
|
||||
f.tx.cwSidetone = on
|
||||
f.mu.Unlock()
|
||||
f.send("cw sidetone " + boolWord(on))
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSidetoneLevel sets the CW sidetone (audible monitor) gain via mon_gain_cw.
|
||||
func (f *Flex) SetSidetoneLevel(l int) error {
|
||||
l = clampLevel(l)
|
||||
return f.txSet(fmt.Sprintf("transmit set mon_gain_cw=%d", l), "mon_gain_cw", func(t *flexTX) { t.cwMonLevel = l })
|
||||
}
|
||||
|
||||
// SetCWFilter changes the CW passband WIDTH to bw Hz while keeping the current
|
||||
// filter CENTER fixed — so the frequency never shifts. The new low/high cuts are
|
||||
// center ± bw/2, where center is the midpoint of the slice's current filter
|
||||
// (falling back to the CW pitch only if the filter isn't known yet).
|
||||
func (f *Flex) SetCWFilter(bw int) error {
|
||||
if bw < 50 {
|
||||
bw = 50
|
||||
}
|
||||
f.mu.Lock()
|
||||
idx, rx := f.rxSliceLocked()
|
||||
connected := f.conn != nil
|
||||
center := 0
|
||||
if rx != nil && (rx.filterLo != 0 || rx.filterHi != 0) {
|
||||
center = (rx.filterLo + rx.filterHi) / 2
|
||||
} else {
|
||||
center = defInt(f.tx.cwPitch, 600)
|
||||
}
|
||||
lo := center - bw/2
|
||||
hi := center + bw/2
|
||||
if rx != nil {
|
||||
rx.filterLo, rx.filterHi = lo, hi
|
||||
}
|
||||
f.mu.Unlock()
|
||||
if !connected {
|
||||
return fmt.Errorf("flex: not connected")
|
||||
}
|
||||
if rx == nil || idx < 0 {
|
||||
return fmt.Errorf("flex: no receive slice")
|
||||
}
|
||||
f.send(fmt.Sprintf("filt %d %d %d", idx, lo, hi))
|
||||
return nil
|
||||
}
|
||||
|
||||
// boolWord renders a Flex on/off boolean as the word form some commands want.
|
||||
func boolWord(on bool) string {
|
||||
if on {
|
||||
return "on"
|
||||
}
|
||||
return "off"
|
||||
}
|
||||
|
||||
// connected reports whether the TCP link is up (commands are no-ops otherwise).
|
||||
func (f *Flex) connected() bool {
|
||||
|
||||
@@ -15,6 +15,7 @@ package cwdecode
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// Status is a periodic snapshot for the UI (pitch lock, speed, signal).
|
||||
@@ -39,6 +40,11 @@ type Decoder struct {
|
||||
mags []float64 // per-bin magnitude this hop
|
||||
nbuf []float64 // scratch for the noise percentile
|
||||
|
||||
// Fixed-pitch target (Hz). 0 = auto-search; >0 = lock to the nearest bin and
|
||||
// ignore everything else (e.g. follow the radio's CW pitch). Set live from
|
||||
// another goroutine, so it's atomic.
|
||||
targetHz atomic.Int32
|
||||
|
||||
// Pitch lock.
|
||||
lockIdx int // index of the locked tone bin, -1 = unlocked
|
||||
candIdx int // current argmax candidate while unlocked
|
||||
@@ -89,10 +95,10 @@ func New(sampleRate int, onChar func(string), onStatus func(Status)) *Decoder {
|
||||
d := &Decoder{
|
||||
fs: sampleRate,
|
||||
hop: sampleRate / 250, // ~4 ms resolution
|
||||
win: sampleRate / 100, // ~10 ms Goertzel window (snappy edges)
|
||||
win: sampleRate / 72, // ~14 ms Goertzel window (selective, fairly snappy)
|
||||
dotHops: 15, // ~20 WPM seed
|
||||
acqSNR: 1.5, // mild: ignore pure noise, still catch weak
|
||||
strongSNR: 2.6, // a clearly-strong tone locks in 1 hop
|
||||
acqSNR: 1.9, // ignore noise spikes (looser locked onto noise = garbage)
|
||||
strongSNR: 3.2, // only a genuinely strong tone locks in 1 hop
|
||||
lockIdx: -1,
|
||||
candIdx: -1,
|
||||
statusEvery: 25, // ~10 Hz
|
||||
@@ -103,9 +109,10 @@ func New(sampleRate int, onChar func(string), onStatus func(Status)) *Decoder {
|
||||
d.hop = 1
|
||||
}
|
||||
d.relockHops = int(0.8 * float64(d.fs) / float64(d.hop)) // release lock after ~0.8 s quiet
|
||||
// Candidate CW tones: 250–1200 Hz every 25 Hz (covers most rigs' audio
|
||||
// offset). The locked bin is the pitch; only its magnitude is decoded.
|
||||
for f := 250.0; f <= 1200.0; f += 25 {
|
||||
// Candidate CW tones: 400–1000 Hz every 25 Hz. Deliberately NOT lower: strong
|
||||
// low-frequency noise/hum (pink/red noise rises toward DC) would otherwise win
|
||||
// the argmax and lock the decoder onto ~250 Hz junk instead of the signal.
|
||||
for f := 400.0; f <= 1000.0; f += 25 {
|
||||
d.freqs = append(d.freqs, f)
|
||||
d.coeffs = append(d.coeffs, 2*math.Cos(2*math.Pi*f/float64(d.fs)))
|
||||
}
|
||||
@@ -114,6 +121,21 @@ func New(sampleRate int, onChar func(string), onStatus func(Status)) *Decoder {
|
||||
return d
|
||||
}
|
||||
|
||||
// SetTarget fixes the decode pitch to hz (lock to the nearest bin, ignore other
|
||||
// tones), or returns to auto-search when hz <= 0. Safe to call concurrently.
|
||||
func (d *Decoder) SetTarget(hz int) { d.targetHz.Store(int32(hz)) }
|
||||
|
||||
// nearestBin returns the bin index closest to hz.
|
||||
func (d *Decoder) nearestBin(hz float64) int {
|
||||
best, bestD := 0, math.Inf(1)
|
||||
for i, f := range d.freqs {
|
||||
if dd := math.Abs(f - hz); dd < bestD {
|
||||
bestD, best = dd, i
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// Reset clears decode state (e.g. when the user re-arms the decoder).
|
||||
func (d *Decoder) Reset() {
|
||||
d.ring = d.ring[:0]
|
||||
@@ -168,6 +190,14 @@ func (d *Decoder) analyze() {
|
||||
}
|
||||
d.lastRMS = math.Min(1, math.Sqrt(sumSq/n)/32768*4)
|
||||
|
||||
// Fixed-pitch mode: lock straight to the target bin, skip the auto search.
|
||||
// A narrow filter at the known pitch is exactly how a skimmer avoids QRM.
|
||||
if th := int(d.targetHz.Load()); th > 0 {
|
||||
d.lockIdx = d.nearestBin(float64(th))
|
||||
d.lastPitch = d.freqs[d.lockIdx]
|
||||
return
|
||||
}
|
||||
|
||||
// Noise floor = 40th percentile of the bins (robust to a few strong tones).
|
||||
copy(d.nbuf, d.mags)
|
||||
sort.Float64s(d.nbuf)
|
||||
@@ -183,7 +213,7 @@ func (d *Decoder) analyze() {
|
||||
// Tiered acquisition: a clearly strong tone locks on the FIRST hop (so we
|
||||
// don't eat the first element of a strong signal), a marginal/weak tone
|
||||
// locks after a couple of stable hops (so we don't lock onto pure noise).
|
||||
if snr > d.strongSNR || (d.candHops >= 2 && snr > d.acqSNR) {
|
||||
if snr > d.strongSNR || (d.candHops >= 3 && snr > d.acqSNR) {
|
||||
d.lockIdx = maxIdx
|
||||
d.peak, d.floor = maxMag, d.noise // seed the envelope to this bin
|
||||
d.quietHops = 0
|
||||
@@ -203,21 +233,28 @@ func (d *Decoder) step() {
|
||||
on := false
|
||||
if d.lockIdx >= 0 {
|
||||
m := d.mags[d.lockIdx]
|
||||
// Fast-attack / slow-release peak; fast-drop / slow-rise floor.
|
||||
// Peak: fast attack, slow release.
|
||||
if m > d.peak {
|
||||
d.peak += (m - d.peak) * 0.4
|
||||
} else {
|
||||
d.peak += (m - d.peak) * 0.02
|
||||
}
|
||||
// Floor: drops fast toward the signal, but only RISES between marks (when
|
||||
// keyed up). Letting the floor rise during a long dash would shrink the
|
||||
// span until the dash drops below the threshold and fragments into dots —
|
||||
// the cause of the "all dots" garbage on a strong clean signal.
|
||||
if m < d.floor {
|
||||
d.floor += (m - d.floor) * 0.4
|
||||
} else {
|
||||
d.floor += (m - d.floor) * 0.005 // creep up slowly so marks aren't swallowed
|
||||
} else if !d.state {
|
||||
d.floor += (m - d.floor) * 0.02
|
||||
}
|
||||
span := d.peak - d.floor
|
||||
if span > d.floor*0.22+1e-9 {
|
||||
onTh := d.floor + 0.50*span
|
||||
offTh := d.floor + 0.30*span
|
||||
// The frozen floor already stops dashes fragmenting, so keep balanced
|
||||
// thresholds: low enough that short inter-element GAPS are still seen
|
||||
// (otherwise elements merge into >7-symbol runs that decode to nothing).
|
||||
if span > d.floor*0.3+1e-9 {
|
||||
onTh := d.floor + 0.55*span
|
||||
offTh := d.floor + 0.35*span
|
||||
if d.state {
|
||||
on = m > offTh
|
||||
} else {
|
||||
@@ -258,7 +295,10 @@ func (d *Decoder) step() {
|
||||
// clicks/noise.
|
||||
func (d *Decoder) endMark(hops int) {
|
||||
h := float64(hops)
|
||||
if h < d.dotHops*0.35 {
|
||||
// Reject clicks/noise: shorter than a third of a dot AND an absolute floor
|
||||
// of ~4 hops (~16 ms, i.e. faster than ~75 WPM) so noise can't drag the
|
||||
// dot-length estimate down to the clamp (which produced 100 WPM garbage).
|
||||
if h < d.dotHops*0.35 || h < 4 {
|
||||
return
|
||||
}
|
||||
if h > d.dotHops*2 {
|
||||
@@ -273,12 +313,12 @@ func (d *Decoder) endMark(hops int) {
|
||||
// adaptDot nudges the dot-length estimate toward an observation (EMA, clamped
|
||||
// to ~5–100 WPM).
|
||||
func (d *Decoder) adaptDot(obs float64) {
|
||||
d.dotHops = d.dotHops*0.7 + obs*0.3
|
||||
if d.dotHops < 3 {
|
||||
d.dotHops = 3
|
||||
d.dotHops = d.dotHops*0.8 + obs*0.2 // slower: a few odd marks can't yank it
|
||||
if d.dotHops < 5 { // 5 hops ≈ 60 WPM ceiling — never 100
|
||||
d.dotHops = 5
|
||||
}
|
||||
if d.dotHops > 60 {
|
||||
d.dotHops = 60
|
||||
if d.dotHops > 55 {
|
||||
d.dotHops = 55
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,7 +347,9 @@ func (d *Decoder) flushChar() {
|
||||
if d.onChar != nil {
|
||||
d.onChar(string(c))
|
||||
}
|
||||
} else if d.onChar != nil {
|
||||
} else if d.onChar != nil && len(d.elem) <= 7 {
|
||||
// Only flag a genuinely Morse-shaped but unknown char with "?". An
|
||||
// over-long element run is noise — drop it silently rather than spam "?".
|
||||
d.onChar("?")
|
||||
}
|
||||
d.elem = d.elem[:0]
|
||||
|
||||
@@ -138,6 +138,50 @@ func TestDecodeFirstCharStrong(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeWithAmplitudeRipple(t *testing.T) {
|
||||
const fs = 16000
|
||||
// A real signal's tone amplitude wobbles within a mark; if the floor chases
|
||||
// it, dashes fragment into dots ("all dots" garbage). Apply ±30% ripple.
|
||||
samples := keyMessageAmp("CQ TEST DE OM", fs, 24, 800, 10000)
|
||||
rp := 0.0
|
||||
for i := range samples {
|
||||
rp += 2 * math.Pi * 35 / float64(fs) // 35 Hz amplitude wobble
|
||||
samples[i] = int16(float64(samples[i]) * (1 + 0.3*math.Sin(rp)))
|
||||
}
|
||||
var sb strings.Builder
|
||||
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
|
||||
for i := 0; i < len(samples); i += 256 {
|
||||
end := i + 256
|
||||
if end > len(samples) {
|
||||
end = len(samples)
|
||||
}
|
||||
d.Process(samples[i:end])
|
||||
}
|
||||
got := strings.ToUpper(sb.String())
|
||||
if !strings.Contains(got, "TEST DE OM") {
|
||||
t.Fatalf("dashes fragmented under amplitude ripple: decoded %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeCQFixedPitch(t *testing.T) {
|
||||
const fs = 16000
|
||||
var sb strings.Builder
|
||||
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
|
||||
d.SetTarget(700) // fixed pitch like the user's manual override
|
||||
samples := keyMessageAmp("CQ CQ CQ DE OM", fs, 26, 700, 9000)
|
||||
for i := 0; i < len(samples); i += 200 {
|
||||
end := i + 200
|
||||
if end > len(samples) {
|
||||
end = len(samples)
|
||||
}
|
||||
d.Process(samples[i:end])
|
||||
}
|
||||
got := strings.ToUpper(sb.String())
|
||||
if n := strings.Count(got, "CQ"); n < 2 {
|
||||
t.Fatalf("first element of CQ dropped: decoded %q (only %d CQ)", got, n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeNumbersAndProsign(t *testing.T) {
|
||||
const fs = 16000
|
||||
var sb strings.Builder
|
||||
|
||||
Reference in New Issue
Block a user