11 Commits
Author SHA1 Message Date
rouggy fa7df57435 chore: release v0.16 2026-07-03 15:13:03 +02:00
rouggy 812e4f05e5 feat: TCI implementation for CAT Control of SunSDR 2026-07-03 15:11:32 +02:00
rouggy 6ec31b61ce fix: bug while creating a new profile 2026-07-02 11:41:53 +02:00
rouggy 93c8f6b9d3 fix: persistence of awards column bug corrected 2026-06-30 11:11:16 +02:00
rouggy 65c22232dd fix: bug on map zoom 2026-06-30 09:39:21 +02:00
rouggy edede0bc1e fix: zoom taking too long after clicking a spot 2026-06-30 09:14:50 +02:00
rouggy 2712902057 chore: release v0.15 2026-06-29 23:27:11 +02:00
rouggy 9281645359 feat: better looking buttons in flexradio panel 2026-06-29 23:21:54 +02:00
rouggy a05dd6b3a9 feat: Added Antenna selection to Flex FlexPanel
feat: New settings flexradio to select antennas per band
2026-06-29 23:07:52 +02:00
rouggy a2401d7fd3 fix: when in autocall cw writing a call stop transmission 2026-06-28 20:55:59 +02:00
rouggy 053b351dab feat: adding choice for recent qso in main tab 2026-06-28 20:49:14 +02:00
17 changed files with 920 additions and 106 deletions
+102 -2
View File
@@ -89,6 +89,8 @@ const (
keyCATIcomPort = "cat.icom.port" // Icom USB CI-V serial port (e.g. COM5) keyCATIcomPort = "cat.icom.port" // Icom USB CI-V serial port (e.g. COM5)
keyCATIcomBaud = "cat.icom.baud" // Icom CI-V baud (default 115200) keyCATIcomBaud = "cat.icom.baud" // Icom CI-V baud (default 115200)
keyCATIcomAddr = "cat.icom.addr" // Icom CI-V address, decimal (IC-7610 = 152 / 0x98) keyCATIcomAddr = "cat.icom.addr" // Icom CI-V address, decimal (IC-7610 = 152 / 0x98)
keyCATTCIHost = "cat.tci.host" // TCI host (Expert Electronics SunSDR / ExpertSDR2)
keyCATTCIPort = "cat.tci.port" // TCI WebSocket port (default 40001)
// Audio (Digital Voice Keyer + QSO recorder). Machine-local hardware, so // Audio (Digital Voice Keyer + QSO recorder). Machine-local hardware, so
// global (not per-profile) like CAT/rotator. Device fields store the // global (not per-profile) like CAT/rotator. Device fields store the
@@ -265,6 +267,8 @@ type CATSettings struct {
IcomPort string `json:"icom_port"` // Icom USB CI-V serial port (e.g. COM5) IcomPort string `json:"icom_port"` // Icom USB CI-V serial port (e.g. COM5)
IcomBaud int `json:"icom_baud"` // Icom CI-V baud (default 115200) IcomBaud int `json:"icom_baud"` // Icom CI-V baud (default 115200)
IcomAddr int `json:"icom_addr"` // Icom CI-V address, decimal (IC-7610 = 152) IcomAddr int `json:"icom_addr"` // Icom CI-V address, decimal (IC-7610 = 152)
TCIHost string `json:"tci_host"` // TCI host (Expert Electronics SunSDR)
TCIPort int `json:"tci_port"` // TCI WebSocket port (default 40001)
PollMs int `json:"poll_ms"` // poll interval in ms (default 250) PollMs int `json:"poll_ms"` // poll interval in ms (default 250)
DelayMs int `json:"delay_ms"` // pause between commands (default 0) DelayMs int `json:"delay_ms"` // pause between commands (default 0)
DigitalDefault string `json:"digital_default"` // when CAT says DATA, surface this mode (FT8/FT4/RTTY/…) DigitalDefault string `json:"digital_default"` // when CAT says DATA, surface this mode (FT8/FT4/RTTY/…)
@@ -608,7 +612,8 @@ func (a *App) startup(ctx context.Context) {
// Route CAT/OmniRig debug lines into the unified app log (they used to go // Route CAT/OmniRig debug lines into the unified app log (they used to go
// to a separate cat.log in the old HamLog folder, which users couldn't find). // to a separate cat.log in the old HamLog folder, which users couldn't find).
cat.LogSink = applog.Printf cat.LogSink = applog.Printf
audio.LogSink = applog.Printf // capture audio-goroutine panics in the app log audio.LogSink = applog.Printf // capture audio-goroutine panics in the app log
extsvc.LogSink = applog.Printf // log raw QRZ (and other) service responses for diagnosis
applog.Printf("startup: data dir = %s", dataDir) applog.Printf("startup: data dir = %s", dataDir)
// The local SQLite file ALWAYS holds per-operator configuration — settings, // The local SQLite file ALWAYS holds per-operator configuration — settings,
// station profiles, rigs/antennas, cluster nodes, UDP, QSL templates, award // station profiles, rigs/antennas, cluster nodes, UDP, QSL templates, award
@@ -3851,7 +3856,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if a.settings == nil { if a.settings == nil {
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized") return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
} }
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault) m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATTCIHost, keyCATTCIPort, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault)
if err != nil { if err != nil {
return CATSettings{}, err return CATSettings{}, err
} }
@@ -3865,6 +3870,8 @@ func (a *App) GetCATSettings() (CATSettings, error) {
IcomPort: m[keyCATIcomPort], IcomPort: m[keyCATIcomPort],
IcomBaud: 115200, IcomBaud: 115200,
IcomAddr: 0x98, // IC-7610 default IcomAddr: 0x98, // IC-7610 default
TCIHost: m[keyCATTCIHost],
TCIPort: 40001,
PollMs: 250, PollMs: 250,
DelayMs: 0, DelayMs: 0,
DigitalDefault: m[keyCATDigitalDefault], DigitalDefault: m[keyCATDigitalDefault],
@@ -3872,6 +3879,9 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if n, _ := strconv.Atoi(m[keyCATFlexPort]); n > 0 && n <= 65535 { if n, _ := strconv.Atoi(m[keyCATFlexPort]); n > 0 && n <= 65535 {
out.FlexPort = n out.FlexPort = n
} }
if n, _ := strconv.Atoi(m[keyCATTCIPort]); n > 0 && n <= 65535 {
out.TCIPort = n
}
if n, _ := strconv.Atoi(m[keyCATIcomBaud]); n > 0 { if n, _ := strconv.Atoi(m[keyCATIcomBaud]); n > 0 {
out.IcomBaud = n out.IcomBaud = n
} }
@@ -3943,6 +3953,8 @@ func (a *App) SaveCATSettings(s CATSettings) error {
keyCATIcomPort: strings.TrimSpace(s.IcomPort), keyCATIcomPort: strings.TrimSpace(s.IcomPort),
keyCATIcomBaud: strconv.Itoa(s.IcomBaud), keyCATIcomBaud: strconv.Itoa(s.IcomBaud),
keyCATIcomAddr: strconv.Itoa(s.IcomAddr), keyCATIcomAddr: strconv.Itoa(s.IcomAddr),
keyCATTCIHost: strings.TrimSpace(s.TCIHost),
keyCATTCIPort: strconv.Itoa(s.TCIPort),
keyCATPollMs: strconv.Itoa(s.PollMs), keyCATPollMs: strconv.Itoa(s.PollMs),
keyCATDelayMs: strconv.Itoa(s.DelayMs), keyCATDelayMs: strconv.Itoa(s.DelayMs),
keyCATDigitalDefault: strings.ToUpper(strings.TrimSpace(s.DigitalDefault)), keyCATDigitalDefault: strings.ToUpper(strings.TrimSpace(s.DigitalDefault)),
@@ -7426,6 +7438,90 @@ func (a *App) FlexSetMute(on bool) error {
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetMute(on) }) return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetMute(on) })
} }
func (a *App) FlexSetRXAntenna(ant string) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetRXAntenna(ant) })
}
func (a *App) FlexSetTXAntenna(ant string) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetTXAntenna(ant) })
}
func (a *App) FlexSetSplit(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetSplit(on) })
}
// keyFlexBandAnt stores the per-band RX/TX antenna map (global, machine-local).
const keyFlexBandAnt = "flex.band_antennas"
// FlexBandAnt is the antenna pair to select on a given band.
type FlexBandAnt struct {
RX string `json:"rx"`
TX string `json:"tx"`
}
// GetFlexBandAntennas returns the band→{rx,tx} antenna map (band key uppercased,
// e.g. "20M").
func (a *App) GetFlexBandAntennas() (map[string]FlexBandAnt, error) {
out := map[string]FlexBandAnt{}
if a.settings == nil {
return out, nil
}
v, _ := a.settings.GetGlobal(a.ctx, keyFlexBandAnt)
if strings.TrimSpace(v) == "" {
return out, nil
}
_ = json.Unmarshal([]byte(v), &out)
return out, nil
}
// SaveFlexBandAntennas persists the band→antenna map.
func (a *App) SaveFlexBandAntennas(m map[string]FlexBandAnt) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
b, err := json.Marshal(m)
if err != nil {
return err
}
return a.settings.SetGlobal(a.ctx, keyFlexBandAnt, string(b))
}
// FlexApplyBandAntenna selects the configured RX/TX antennas for the given band
// (no-op if the backend isn't a Flex or the band has no mapping). Called on band
// changes and spot clicks so the right antennas follow the frequency.
func (a *App) FlexApplyBandAntenna(band string) error {
if a.cat == nil {
return nil
}
band = strings.ToUpper(strings.TrimSpace(band))
if band == "" {
return nil
}
m, _ := a.GetFlexBandAntennas()
ant, ok := m[band]
if !ok {
return nil
}
return a.cat.FlexDo(func(fc cat.FlexController) error {
if strings.TrimSpace(ant.RX) != "" {
_ = fc.SetRXAntenna(ant.RX)
}
if strings.TrimSpace(ant.TX) != "" {
_ = fc.SetTXAntenna(ant.TX)
}
return nil
})
}
func (a *App) FlexSetNB(on bool) error { func (a *App) FlexSetNB(on bool) error {
if a.cat == nil { if a.cat == nil {
return fmt.Errorf("cat not initialized") return fmt.Errorf("cat not initialized")
@@ -7579,6 +7675,10 @@ func (a *App) reloadCAT() {
// Native Icom CI-V over the radio's USB serial port (local control). // Native Icom CI-V over the radio's USB serial port (local control).
// Same civ protocol a future network backend will reuse for remote. // Same civ protocol a future network backend will reuse for remote.
a.cat.Start(cat.NewIcomSerial(s.IcomPort, s.IcomBaud, s.IcomAddr, s.DigitalDefault)) a.cat.Start(cat.NewIcomSerial(s.IcomPort, s.IcomBaud, s.IcomAddr, s.DigitalDefault))
case "tci":
// Expert Electronics TCI (WebSocket) — SunSDR / ExpertSDR2, or any
// TCI-compatible server.
a.cat.Start(cat.NewTCI(s.TCIHost, s.TCIPort, s.DigitalDefault))
default: default:
// Unknown backend → stop and emit a dummy state so the UI shows it. // Unknown backend → stop and emit a dummy state so the UI shows it.
a.cat.Stop() a.cat.Stop()
+58 -7
View File
@@ -13,7 +13,7 @@ import {
GetStartupStatus, CheckForUpdate, GetStartupStatus, CheckForUpdate,
WorkedBefore, WorkedBefore,
SetCompactMode, SetCompactMode,
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna,
GetSecretStatus, UnlockSecrets, GetSecretStatus, UnlockSecrets,
RefreshCtyDat, DownloadAllReferenceLists, RefreshCtyDat, DownloadAllReferenceLists,
RotatorGoTo, RotatorStop, GetRotatorHeading, RotatorGoTo, RotatorStop, GetRotatorHeading,
@@ -817,11 +817,12 @@ export default function App() {
// map ("map1"), the locator street map ("map2"), the cluster grid or the // map ("map1"), the locator street map ("map2"), the cluster grid or the
// worked-before grid. Per-profile (stored via SetUIPref → profile-prefixed), // worked-before grid. Per-profile (stored via SetUIPref → profile-prefixed),
// so it's loaded async on mount and re-read on profile:changed below. // so it's loaded async on mount and re-read on profile:changed below.
type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex'; type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex' | 'recent';
const [mapZoomSignal, setMapZoomSignal] = useState(0); // bump → world map auto-zooms now
const [mainPaneLeft, setMainPaneLeft] = useState<MainPaneKind>('map1'); const [mainPaneLeft, setMainPaneLeft] = useState<MainPaneKind>('map1');
const [mainPaneRight, setMainPaneRight] = useState<MainPaneKind>('map2'); const [mainPaneRight, setMainPaneRight] = useState<MainPaneKind>('map2');
const loadMainPanes = useCallback(async () => { const loadMainPanes = useCallback(async () => {
const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked' || v === 'flex'; const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked' || v === 'flex' || v === 'recent';
const [l, r] = await Promise.all([ const [l, r] = await Promise.all([
GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneLeft').catch(() => ''),
GetUIPref('mainPaneRight').catch(() => ''), GetUIPref('mainPaneRight').catch(() => ''),
@@ -1431,6 +1432,19 @@ export default function App() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// FlexRadio: apply the per-band RX/TX antennas whenever the band changes (rig
// QSY, spot click, manual band) — so the right antennas follow the frequency.
// Skipped while the band is LOCKED (entering an old QSO off-air) and for non-Flex
// backends. The backend no-ops if the band has no configured mapping.
const lastAntBandRef = useRef('');
useEffect(() => {
if (catState.backend !== 'flex' || locks.band) return;
const b = band.trim();
if (!b || b === lastAntBandRef.current) return;
lastAntBandRef.current = b;
FlexApplyBandAntenna(b).catch(() => {});
}, [band, catState.backend, locks.band]);
// Cluster live wiring: hydrate per-server status + saved server list, // Cluster live wiring: hydrate per-server status + saved server list,
// then subscribe to push events. // then subscribe to push events.
async function reloadClusterMeta() { async function reloadClusterMeta() {
@@ -1978,6 +1992,11 @@ export default function App() {
setLookupBusy(true); setLookupBusy(true);
try { try {
const r = await LookupCallsign(call); const r = await LookupCallsign(call);
// Discard a STALE result: the operator already moved to another call
// (clicked a new spot / typed) while this lookup was in flight. Applying it
// would clobber the current call's fields and zoom the map to the wrong
// station — the bug where replacing a call didn't re-zoom the map.
if (call !== callsignValRef.current.trim().toUpperCase()) return;
lastLookedUpRef.current = call; lastLookedUpRef.current = call;
// cty.dat carries ONLY DXCC-entity data (country / CQ / ITU zones / continent). // cty.dat carries ONLY DXCC-entity data (country / CQ / ITU zones / continent).
// A QRZ/HamQTH hit is far richer (name, QTH, grid, address, image). When the // A QRZ/HamQTH hit is far richer (name, QTH, grid, address, image). When the
@@ -2015,6 +2034,9 @@ export default function App() {
qsl_via: d.qsl_via || (r.qsl_via ?? ''), qsl_via: d.qsl_via || (r.qsl_via ?? ''),
})); }));
if (r.dxcc && r.dxcc > 0) runWorkedBefore(call, r.dxcc); if (r.dxcc && r.dxcc > 0) runWorkedBefore(call, r.dxcc);
// The DX location is now known (grid set above) — force the world map to
// auto-zoom right away, so it doesn't lag behind the resolved QSO.
setMapZoomSignal((n) => n + 1);
// Recording: tie it to the resolved callsign. Start once a real (≥3-char) // Recording: tie it to the resolved callsign. Start once a real (≥3-char)
// call resolves — covers the fast CW workflow (type → Enter, no blur). If // call resolves — covers the fast CW workflow (type → Enter, no blur). If
// we're already recording a DIFFERENT call (the operator edited the // we're already recording a DIFFERENT call (the operator edited the
@@ -2065,8 +2087,14 @@ export default function App() {
// applyUdpCall saw current != lastUdpCall and refused every later UDP call. // applyUdpCall saw current != lastUdpCall and refused every later UDP call.
if (opts?.force) lastUdpCallRef.current = v.trim().toUpperCase(); if (opts?.force) lastUdpCallRef.current = v.trim().toUpperCase();
// A callsign appeared (someone answered the CQ, or a spot was clicked) → // A callsign appeared (someone answered the CQ, or a spot was clicked) →
// stop auto-calling so we don't key over the contact. // stop auto-calling so we don't key over the contact. If a CQ was actually
if (v.trim() !== '') stopAutoCall(); // in flight, abort the current transmission too so it stops IMMEDIATELY
// rather than finishing the buffered call. (autoCallMacroRef flips to -1 on
// the first keystroke, so we only abort once.)
if (v.trim() !== '') {
if (autoCallMacroRef.current !== -1) WinkeyerStop().catch(() => {});
stopAutoCall();
}
// No-op guard: external apps (MSHV/WSJT-X) re-broadcast the same DX call // No-op guard: external apps (MSHV/WSJT-X) re-broadcast the same DX call
// on every status packet. If it matches what's already in the entry, // on every status packet. If it matches what's already in the entry,
// do nothing — otherwise we'd re-run the QRZ lookup, hit the cache and // do nothing — otherwise we'd re-run the QRZ lookup, hit the cache and
@@ -2804,6 +2832,7 @@ export default function App() {
toLabel={callsign} toLabel={callsign}
beamAzimuths={showBeamOnMap ? beamHeadings : []} beamAzimuths={showBeamOnMap ? beamHeadings : []}
boomAzimuth={showBeamOnMap && ubPattern && ubPattern !== 'normal' ? boomHeading : null} boomAzimuth={showBeamOnMap && ubPattern && ubPattern !== 'normal' ? boomHeading : null}
zoomSignal={mapZoomSignal}
/> />
); );
case 'map2': case 'map2':
@@ -2834,7 +2863,29 @@ export default function App() {
case 'flex': case 'flex':
return ( return (
<div className="h-full w-full min-h-0 rounded-lg overflow-hidden border border-border"> <div className="h-full w-full min-h-0 rounded-lg overflow-hidden border border-border">
<FlexPanel /> <FlexPanel onCWSpeed={(w) => { setWkWpm(w); WinkeyerSetSpeed(w).catch(() => {}); saveWk({ wpm: w }); }} />
</div>
);
case 'recent':
return (
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
<RecentQSOsGrid
rows={qsosWithAwards as any}
total={total}
awardCols={awardCols}
onRowDoubleClicked={(q) => openEdit(q.id as number)}
onUpdateFromCty={bulkUpdateFromCty}
onUpdateFromQRZ={bulkUpdateFromQRZ}
onUpdateFromClublog={bulkUpdateFromClublog}
onSendTo={bulkSendTo}
onSendRecording={bulkSendRecording}
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
onBulkEdit={openBulkEdit}
onExportSelected={exportSelectedADIF}
onExportFiltered={exportFilteredADIF}
onDelete={(ids) => setDeletingIds(ids)}
onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
/>
</div> </div>
); );
} }
@@ -3867,7 +3918,7 @@ export default function App() {
backend is a FlexRadio. */} backend is a FlexRadio. */}
{catState.backend === 'flex' && ( {catState.backend === 'flex' && (
<TabsContent value="flex" className="flex-1 min-h-0 p-0"> <TabsContent value="flex" className="flex-1 min-h-0 p-0">
<FlexPanel /> <FlexPanel onCWSpeed={(w) => { setWkWpm(w); WinkeyerSetSpeed(w).catch(() => {}); saveWk({ wpm: w }); }} />
</TabsContent> </TabsContent>
)} )}
+47 -6
View File
@@ -5,7 +5,7 @@ import {
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic, FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
FlexMox, FlexAmpOperate, FlexMox, FlexAmpOperate,
GetPGXLStatus, PGXLSetFanMode, GetPGXLStatus, PGXLSetFanMode,
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit,
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel, FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay, FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter, FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
@@ -20,6 +20,8 @@ type FlexState = {
mon: boolean; mon_level: number; mic_level: number; mon: boolean; mon_level: number; mic_level: number;
atu_status?: string; atu_memories: boolean; atu_status?: string; atu_memories: boolean;
rx_avail: boolean; agc_mode?: string; agc_threshold: number; audio_level: number; mute: boolean; rx_avail: boolean; agc_mode?: string; agc_threshold: number; audio_level: number; mute: boolean;
rx_ant?: string; tx_ant?: string; ant_list?: string[]; tx_ant_list?: string[];
split: boolean; rx_freq_hz?: number; tx_freq_hz?: number;
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number; nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number;
mode?: string; mode?: string;
cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number; cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number;
@@ -34,7 +36,7 @@ const ZERO: FlexState = {
available: false, rf_power: 0, tune_power: 0, tune: false, transmitting: false, available: false, rf_power: 0, tune_power: 0, tune: false, transmitting: false,
vox_enable: false, vox_level: 0, vox_delay: 0, proc_enable: false, proc_level: 0, vox_enable: false, vox_level: 0, vox_delay: 0, proc_enable: false, proc_level: 0,
mon: false, mon_level: 0, mic_level: 0, atu_memories: false, mon: false, mon_level: 0, mic_level: 0, atu_memories: false,
rx_avail: false, agc_threshold: 0, audio_level: 0, mute: false, rx_avail: false, agc_threshold: 0, audio_level: 0, mute: false, split: false,
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, anf_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, 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, apf: false, apf_level: 0, filter_lo: 0, filter_hi: 0,
@@ -110,8 +112,11 @@ function Chip({ on, onClick, label, disabled, accent = 'emerald' }: {
}[accent]; }[accent];
return ( return (
<button type="button" onClick={onClick} disabled={disabled} <button type="button" onClick={onClick} disabled={disabled}
className={cn('w-14 shrink-0 px-2 py-1 rounded-md text-[11px] font-bold border transition-colors disabled:opacity-30', className={cn(
on ? onCls : 'bg-card text-muted-foreground border-border hover:bg-muted')}> // min width (not fixed) so a longer label like STONE keeps symmetric
// padding instead of overflowing; short labels (NB/NR/APF) stay aligned.
'min-w-[3.5rem] shrink-0 px-2.5 py-1 rounded-md text-[11px] font-bold border text-center tracking-wide transition-all disabled:opacity-30',
on ? cn(onCls, 'shadow-sm') : 'bg-card text-muted-foreground border-border hover:bg-muted hover:border-muted-foreground/30')}>
{label} {label}
</button> </button>
); );
@@ -182,7 +187,9 @@ function Card({ icon: Icon, title, accent, children }: { icon: any; title: strin
); );
} }
export function FlexPanel() { // onCWSpeed (optional): notified when the operator changes CW speed here, so the
// host can keep the WinKeyer (which actually sends the macros) in sync.
export function FlexPanel({ onCWSpeed }: { onCWSpeed?: (wpm: number) => void } = {}) {
const [st, setSt] = useState<FlexState>(ZERO); const [st, setSt] = useState<FlexState>(ZERO);
const hold = useRef<Record<string, number>>({}); const hold = useRef<Record<string, number>>({});
// Peak-hold: keep the highest reading for ~2 s so the jittery VITA-49 meters // Peak-hold: keep the highest reading for ~2 s so the jittery VITA-49 meters
@@ -302,6 +309,21 @@ export function FlexPanel() {
<Power className="size-4 inline mr-1 -mt-0.5" /> MOX <Power className="size-4 inline mr-1 -mt-0.5" /> MOX
</button> </button>
</div> </div>
<div className="flex items-center gap-2">
<button type="button" disabled={off}
title="Split: RX/TX on separate slices. ON creates a TX slice +1 kHz (CW) / +5 kHz (SSB) up, like SmartSDR."
onClick={() => change('split', !st.split, () => FlexSetSplit(!st.split))}
className={cn('px-3 py-1.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
st.split ? 'bg-sky-600 text-white border-sky-600 shadow-[0_0_12px] shadow-sky-600/50' : 'bg-card text-sky-700 border-sky-400 hover:bg-sky-50')}>
SPLIT
</button>
{st.split && !!st.tx_freq_hz && (
<span className="text-[11px] font-mono text-muted-foreground whitespace-nowrap">
TX {(st.tx_freq_hz / 1e6).toFixed(3)}
{!!st.rx_freq_hz && ` (${(st.tx_freq_hz - st.rx_freq_hz) >= 0 ? '+' : ''}${((st.tx_freq_hz - st.rx_freq_hz) / 1000).toFixed(1)} kHz)`}
</span>
)}
</div>
{!isCW ? ( {!isCW ? (
<div className="border-t border-border/60 pt-3 space-y-3"> <div className="border-t border-border/60 pt-3 space-y-3">
@@ -335,7 +357,7 @@ export function FlexPanel() {
<div className="border-t border-border/60 pt-3 space-y-3"> <div className="border-t border-border/60 pt-3 space-y-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="w-16 shrink-0 text-[11px] font-bold text-muted-foreground">Speed</span> <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))} /> <Slider value={st.cw_speed} disabled={off} max={60} accent="#0d9488" onChange={(v) => { change('cw_speed', v, () => FlexSetCWSpeed(v)); onCWSpeed?.(v); }} />
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.cw_speed} wpm</span> <span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.cw_speed} wpm</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -357,6 +379,25 @@ export function FlexPanel() {
{/* RECEIVE */} {/* RECEIVE */}
<Card icon={AudioLines} title="Receive (active slice)" accent="#0891b2"> <Card icon={AudioLines} title="Receive (active slice)" accent="#0891b2">
{((st.ant_list?.length ?? 0) > 0 || (st.tx_ant_list?.length ?? 0) > 0) && (
<div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Ant</span>
<div className="flex items-center gap-1.5 flex-1 min-w-0">
<span className="text-[10px] text-muted-foreground">RX</span>
<select disabled={rxOff} value={st.rx_ant ?? ''}
onChange={(e) => change('rx_ant', e.target.value, () => FlexSetRXAntenna(e.target.value))}
className="h-7 flex-1 min-w-0 rounded-md border border-input bg-background px-1.5 text-xs font-mono disabled:opacity-40">
{(st.ant_list ?? []).map((a) => <option key={a} value={a}>{a}</option>)}
</select>
<span className="text-[10px] text-muted-foreground">TX</span>
<select disabled={rxOff} value={st.tx_ant ?? ''}
onChange={(e) => change('tx_ant', e.target.value, () => FlexSetTXAntenna(e.target.value))}
className="h-7 flex-1 min-w-0 rounded-md border border-input bg-background px-1.5 text-xs font-mono disabled:opacity-40">
{((st.tx_ant_list?.length ? st.tx_ant_list : st.ant_list) ?? []).map((a) => <option key={a} value={a}>{a}</option>)}
</select>
</div>
</div>
)}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC</span> <span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC</span>
<Segmented value={(st.agc_mode || 'med').toLowerCase()} options={AGC} disabled={rxOff} <Segmented value={(st.agc_mode || 'med').toLowerCase()} options={AGC} disabled={rxOff}
+13 -11
View File
@@ -81,10 +81,11 @@ interface WorldProps {
beamAzimuths?: number[]; // radiating heading(s) (deg) → draw a beam lobe each beamAzimuths?: number[]; // radiating heading(s) (deg) → draw a beam lobe each
beamWidth?: number; // beamwidth (deg), default 30 beamWidth?: number; // beamwidth (deg), default 30
boomAzimuth?: number | null; // mechanical boom (rotor) heading → grey reference line boomAzimuth?: number | null; // mechanical boom (rotor) heading → grey reference line
zoomSignal?: number; // bump to force an auto-zoom now (e.g. QRZ lookup finished)
} }
// WorldMap — great-circle path + beam lobe(s), the "map1" pane. // WorldMap — great-circle path + beam lobe(s), the "map1" pane.
export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, beamWidth, boomAzimuth }: WorldProps) { export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, beamWidth, boomAzimuth, zoomSignal }: WorldProps) {
const worldRef = useRef<HTMLDivElement>(null); const worldRef = useRef<HTMLDivElement>(null);
const worldMap = useRef<L.Map | null>(null); const worldMap = useRef<L.Map | null>(null);
const worldOverlay = useRef<L.LayerGroup | null>(null); const worldOverlay = useRef<L.LayerGroup | null>(null);
@@ -164,15 +165,16 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
return out; return out;
}; };
for (const az of beamAzimuths) { for (const az of beamAzimuths) {
// Draw the lobe as a single FILLED shape that HUGS the great-circle // Draw the lobe as a DENSE fan of translucent great-circle radials, not
// curve: bound it by the two edge radials (az ± half-beamwidth), each a // a filled polygon: a polygon smears badly near the poles on Mercator
// great-circle line, joined by a short far cap. Filling between the two // (a poleward beam degenerates into a giant triangle), while each radial
// curved edges follows the arc instead of making a straight triangle. // LINE stays clean at any azimuth. A small angular step makes the lines
const edgeL = radial(az - half); // overlap into a solid-looking lobe (no separate strokes), darker near
const edgeR = radial(az + half); // the antenna and fanning out toward the front.
const lobe = [[from.lat, from.lon], ...edgeL, ...edgeR.slice().reverse()] as [number, number][]; for (let b = az - half; b <= az + half + 0.001; b += 0.5) {
L.polygon(unwrapLon(lobe) as L.LatLngExpression[], const line = unwrapLon([[from.lat, from.lon], ...radial(b)]);
{ stroke: false, fillColor: '#ff2d2d', fillOpacity: 0.18, smoothFactor: 0 }).addTo(wo); L.polyline(line as L.LatLngExpression[], { color: '#ff2d2d', weight: 6, opacity: 0.10, smoothFactor: 0 }).addTo(wo);
}
const cl = unwrapLon([[from.lat, from.lon], ...radial(az)]); const cl = unwrapLon([[from.lat, from.lon], ...radial(az)]);
// Dark casing under the boresight so the bright dashed line stays // Dark casing under the boresight so the bright dashed line stays
@@ -224,7 +226,7 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
} }
setTimeout(() => { wm.invalidateSize(); }, 0); setTimeout(() => { wm.invalidateSize(); }, 0);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, boomAzimuth, autoZoom]); }, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, boomAzimuth, autoZoom, zoomSignal]);
const path = pathBetween(fromGrid, toGrid); const path = pathBetween(fromGrid, toGrid);
+12 -2
View File
@@ -254,7 +254,14 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
// Compute initial column defs: all columns defined, but those not marked // Compute initial column defs: all columns defined, but those not marked
// defaultVisible start hidden. The user's saved state (loaded onGridReady) // defaultVisible start hidden. The user's saved state (loaded onGridReady)
// overrides this so a previously toggled column wins. // overrides this so a previously toggled column wins.
// While AG Grid rebuilds columns (award columns load async), it fires column
// events that would otherwise trigger a save of the DEFAULT visibility before
// we re-apply the user's saved state — clobbering it. This flag suppresses the
// auto-save during that window. Set in the memo (runs at render, before the
// column events) and cleared by the re-apply effect below.
const restoringRef = useRef(true);
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => { const columnDefs = useMemo<ColDef<QSOForm>[]>(() => {
restoringRef.current = true;
const base = COL_CATALOG.map((c) => { const base = COL_CATALOG.map((c) => {
const { group: _g, label: _l, defaultVisible, ...rest } = c; const { group: _g, label: _l, defaultVisible, ...rest } = c;
return { ...rest, hide: !defaultVisible }; return { ...rest, hide: !defaultVisible };
@@ -290,6 +297,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
}); });
} }
const saveColumnState = useCallback(() => { const saveColumnState = useCallback(() => {
if (restoringRef.current) return; // ignore the events fired by a column rebuild
const state = gridRef.current?.api?.getColumnState(); const state = gridRef.current?.api?.getColumnState();
if (state) saveState(COL_STATE_KEY, state); if (state) saveState(COL_STATE_KEY, state);
}, []); }, []);
@@ -301,9 +309,11 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
// every rebuild so the user's choices win. No-op before the grid is ready. // every rebuild so the user's choices win. No-op before the grid is ready.
useEffect(() => { useEffect(() => {
const api = gridRef.current?.api; const api = gridRef.current?.api;
if (!api) return;
const local = loadLocal(COL_STATE_KEY); const local = loadLocal(COL_STATE_KEY);
if (local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true }); if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
// Re-enable saving once AG Grid has settled the column events from the rebuild.
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
return () => window.clearTimeout(t);
}, [awardCols]); }, [awardCols]);
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) { function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
+137 -20
View File
@@ -38,6 +38,7 @@ import {
TestLoTWUpload, ListTQSLStationLocations, TestLoTWUpload, ListTQSLStationLocations,
ComputeStationInfo, ComputeStationInfo,
GetUIPref, SetUIPref, GetUIPref, SetUIPref,
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas,
} from '../../wailsjs/go/main/App'; } from '../../wailsjs/go/main/App';
import type { profile as profileModels } from '../../wailsjs/go/models'; import type { profile as profileModels } from '../../wailsjs/go/models';
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types'; import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
@@ -132,8 +133,10 @@ const emptyProfile = (): Profile => ({
is_active: false, is_active: false,
sort_order: 0, sort_order: 0,
db: { backend: '', host: '', port: 3306, user: '', password: '', database: '' }, db: { backend: '', host: '', port: 3306, user: '', password: '', database: '' },
created_at: '' as any, // Server-managed timestamps — send null (NOT "") so Go's time.Time unmarshal
updated_at: '' as any, // leaves the zero value instead of failing to parse an empty RFC3339 string.
created_at: null as any,
updated_at: null as any,
}); });
interface Props { interface Props {
@@ -174,13 +177,27 @@ type SectionId =
| 'antenna' | 'antenna'
| 'antgenius' | 'antgenius'
| 'pgxl' | 'pgxl'
| 'flex'
| 'audio'; | 'audio';
type TreeNode = type TreeNode =
| { kind: 'group'; label: string; icon?: any; defaultOpen?: boolean; children: TreeNode[] } | { kind: 'group'; label: string; icon?: any; defaultOpen?: boolean; children: TreeNode[] }
| { kind: 'item'; label: string; id: SectionId; disabled?: boolean }; | { kind: 'item'; label: string; id: SectionId; disabled?: boolean };
const TREE: TreeNode[] = [ // buildTree returns the settings sidebar. The FlexRadio item only appears when
// the active CAT backend is a Flex (per-band antenna config is Flex-specific).
function buildTree(flexAvailable: boolean): TreeNode[] {
const hardware: TreeNode[] = [
{ kind: 'item', label: 'CAT interface', id: 'cat' },
{ kind: 'item', label: 'PstRotator', id: 'rotator' },
{ kind: 'item', label: 'CW Keyer', id: 'winkeyer' },
{ kind: 'item', label: 'UltraBeam', id: 'antenna' },
{ kind: 'item', label: 'Antenna Genius', id: 'antgenius' },
{ kind: 'item', label: 'Power Genius', id: 'pgxl' },
...(flexAvailable ? [{ kind: 'item', label: 'FlexRadio', id: 'flex' } as TreeNode] : []),
{ kind: 'item', label: 'Audio devices', id: 'audio' },
];
return [
{ {
kind: 'group', label: 'User Configuration', icon: User, defaultOpen: true, children: [ kind: 'group', label: 'User Configuration', icon: User, defaultOpen: true, children: [
{ kind: 'item', label: 'Station Information', id: 'station' }, { kind: 'item', label: 'Station Information', id: 'station' },
@@ -206,17 +223,10 @@ const TREE: TreeNode[] = [
], ],
}, },
{ {
kind: 'group', label: 'Hardware Configuration', icon: Server, defaultOpen: true, children: [ kind: 'group', label: 'Hardware Configuration', icon: Server, defaultOpen: true, children: hardware,
{ kind: 'item', label: 'CAT interface', id: 'cat' },
{ kind: 'item', label: 'PstRotator', id: 'rotator' },
{ kind: 'item', label: 'CW Keyer', id: 'winkeyer' },
{ kind: 'item', label: 'UltraBeam', id: 'antenna' },
{ kind: 'item', label: 'Antenna Genius', id: 'antgenius' },
{ kind: 'item', label: 'Power Genius', id: 'pgxl' },
{ kind: 'item', label: 'Audio devices', id: 'audio' },
],
}, },
]; ];
}
// Map section id → friendly name (used in breadcrumb / placeholders). // Map section id → friendly name (used in breadcrumb / placeholders).
const SECTION_LABELS: Partial<Record<SectionId, string>> = { const SECTION_LABELS: Partial<Record<SectionId, string>> = {
@@ -240,6 +250,7 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
antenna: 'UltraBeam', antenna: 'UltraBeam',
antgenius: 'Antenna Genius', antgenius: 'Antenna Genius',
pgxl: 'Power Genius', pgxl: 'Power Genius',
flex: 'FlexRadio',
audio: 'Audio devices', audio: 'Audio devices',
}; };
@@ -248,12 +259,13 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
interface TreeProps { interface TreeProps {
selected: SectionId; selected: SectionId;
onSelect: (id: SectionId) => void; onSelect: (id: SectionId) => void;
flexAvailable?: boolean;
} }
function Tree({ selected, onSelect }: TreeProps) { function Tree({ selected, onSelect, flexAvailable }: TreeProps) {
return ( return (
<nav className="text-sm"> <nav className="text-sm">
{TREE.map((node, i) => ( {buildTree(!!flexAvailable).map((node, i) => (
<TreeNodeView key={i} node={node} depth={0} selected={selected} onSelect={onSelect} /> <TreeNodeView key={i} node={node} depth={0} selected={selected} onSelect={onSelect} />
))} ))}
</nav> </nav>
@@ -485,14 +497,16 @@ const MAIN_PANE_OPTIONS: { value: string; label: string }[] = [
{ value: 'map2', label: 'Map — locator (street)' }, { value: 'map2', label: 'Map — locator (street)' },
{ value: 'cluster', label: 'Cluster spots' }, { value: 'cluster', label: 'Cluster spots' },
{ value: 'worked', label: 'Worked before' }, { value: 'worked', label: 'Worked before' },
{ value: 'recent', label: 'Recent QSOs' },
]; ];
function MainViewPanes({ onChanged, flexAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean }) { function MainViewPanes({ onChanged, flexAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean }) {
const [left, setLeft] = useState('map1'); const [left, setLeft] = useState('map1');
const [right, setRight] = useState('map2'); const [right, setRight] = useState('map2');
// FlexRadio is only offered when the CAT backend is a Flex. // FlexRadio is only offered when the CAT backend is a Flex. Sorted A→Z.
const options = flexAvailable const options = (flexAvailable
? [...MAIN_PANE_OPTIONS, { value: 'flex', label: 'FlexRadio controls' }] ? [...MAIN_PANE_OPTIONS, { value: 'flex', label: 'FlexRadio controls' }]
: MAIN_PANE_OPTIONS; : [...MAIN_PANE_OPTIONS]
).sort((a, b) => a.label.localeCompare(b.label));
useEffect(() => { useEffect(() => {
const valid = (v: string) => v === 'flex' || MAIN_PANE_OPTIONS.some((o) => o.value === v); const valid = (v: string) => v === 'flex' || MAIN_PANE_OPTIONS.some((o) => o.value === v);
Promise.all([GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => '')]) Promise.all([GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => '')])
@@ -583,6 +597,90 @@ function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
); );
} }
// FlexBandAntennasPanel — pick the RX/TX antenna per band. Applied automatically
// when the band changes (frequency change / spot click). Antennas come live from
// the connected FlexRadio; bands come from Lists → Bands.
function FlexBandAntennasPanel({ bands }: { bands: string[] }) {
const [rxList, setRxList] = useState<string[]>([]);
const [txList, setTxList] = useState<string[]>([]);
const [map, setMap] = useState<Record<string, { rx: string; tx: string }>>({});
const [msg, setMsg] = useState('');
useEffect(() => {
GetFlexState().then((s: any) => {
setRxList((s?.ant_list ?? []) as string[]);
setTxList(((s?.tx_ant_list?.length ? s.tx_ant_list : s?.ant_list) ?? []) as string[]);
}).catch(() => {});
GetFlexBandAntennas().then((m: any) => setMap(m ?? {})).catch(() => {});
}, []);
const set = (band: string, side: 'rx' | 'tx', v: string) => {
const key = band.toUpperCase();
setMap((m) => {
const cur = m[key] ?? { rx: '', tx: '' };
const next = { ...m, [key]: { ...cur, [side]: v } };
SaveFlexBandAntennas(next as any)
.then(() => { setMsg('Saved'); window.setTimeout(() => setMsg(''), 1200); })
.catch((e: any) => setMsg(String(e?.message ?? e)));
return next;
});
};
return (
<div className="space-y-3">
<div>
<h3 className="text-base font-semibold text-foreground">FlexRadio per-band antennas</h3>
<p className="text-xs text-muted-foreground">
Choose the RX and TX antenna for each band. They're applied automatically when the band
changes (frequency change or clicking a spot).
</p>
</div>
{rxList.length === 0 && (
<div className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-md px-3 py-2 max-w-xl">
No antennas reported yet — make sure the FlexRadio is connected (CAT interface), then reopen this panel.
</div>
)}
{bands.length === 0 ? (
<p className="text-xs text-muted-foreground">No bands configured — add them in Lists → Bands.</p>
) : (
<div className="rounded-md border border-border overflow-hidden max-w-xl">
<table className="w-full text-sm">
<thead className="bg-muted/40 text-muted-foreground text-xs">
<tr>
<th className="text-left px-3 py-1.5 font-semibold">Band</th>
<th className="text-left px-3 py-1.5 font-semibold">RX antenna</th>
<th className="text-left px-3 py-1.5 font-semibold">TX antenna</th>
</tr>
</thead>
<tbody>
{bands.map((b) => {
const e = map[b.toUpperCase()] ?? { rx: '', tx: '' };
return (
<tr key={b} className="border-t border-border/50">
<td className="px-3 py-1.5 font-mono font-semibold">{b}</td>
<td className="px-3 py-1.5">
<select value={e.rx ?? ''} onChange={(ev) => set(b, 'rx', ev.target.value)}
className="h-8 w-32 rounded-md border border-input bg-background px-1.5 text-xs font-mono">
<option value="">— none —</option>
{rxList.map((a) => <option key={a} value={a}>{a}</option>)}
</select>
</td>
<td className="px-3 py-1.5">
<select value={e.tx ?? ''} onChange={(ev) => set(b, 'tx', ev.target.value)}
className="h-8 w-32 rounded-md border border-input bg-background px-1.5 text-xs font-mono">
<option value="">— none —</option>
{txList.map((a) => <option key={a} value={a}>{a}</option>)}
</select>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{msg && <span className="text-[11px] text-muted-foreground">{msg}</span>}
</div>
);
}
export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable }: Props) { export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable }: Props) {
const [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station'); const [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -619,7 +717,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [modeDraft, setModeDraft] = useState(''); const [modeDraft, setModeDraft] = useState('');
const [catCfg, setCatCfg] = useState<CATSettings>({ const [catCfg, setCatCfg] = useState<CATSettings>({
enabled: false, backend: 'omnirig', omnirig_rig: 1, flex_host: '', flex_port: 4992, flex_spots: false, enabled: false, backend: 'omnirig', omnirig_rig: 1, flex_host: '', flex_port: 4992, flex_spots: false,
icom_port: '', icom_baud: 115200, icom_addr: 0x98, poll_ms: 250, delay_ms: 0, icom_port: '', icom_baud: 115200, icom_addr: 0x98, tci_host: '', tci_port: 40001, poll_ms: 250, delay_ms: 0,
digital_default: 'FT8', digital_default: 'FT8',
}); });
const [rotator, setRotator] = useState<RotatorSettings>({ const [rotator, setRotator] = useState<RotatorSettings>({
@@ -1796,6 +1894,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<SelectItem value="omnirig">OmniRig (any rig, Windows COM)</SelectItem> <SelectItem value="omnirig">OmniRig (any rig, Windows COM)</SelectItem>
<SelectItem value="flex">FlexRadio / SmartSDR (native)</SelectItem> <SelectItem value="flex">FlexRadio / SmartSDR (native)</SelectItem>
<SelectItem value="icom">Icom CI-V (USB serial)</SelectItem> <SelectItem value="icom">Icom CI-V (USB serial)</SelectItem>
<SelectItem value="tci">TCI (Expert Electronics / SunSDR)</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -1865,6 +1964,23 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div> </div>
</> </>
)} )}
{catCfg.backend === 'tci' && (
<>
<div className="space-y-1">
<Label>TCI host</Label>
<Input placeholder="127.0.0.1" value={catCfg.tci_host ?? ''}
onChange={(e) => setCatCfg((s) => ({ ...s, tci_host: e.target.value }))} />
</div>
<div className="space-y-1">
<Label>Port</Label>
<Input type="number" value={catCfg.tci_port || 40001}
onChange={(e) => setCatCfg((s) => ({ ...s, tci_port: parseInt(e.target.value) || 40001 }))} />
</div>
<p className="col-span-2 text-xs text-muted-foreground">
Enable the TCI server in ExpertSDR2/EESDR (Options → TCI). Default port 40001. Use 127.0.0.1 when OpsLog runs on the same PC.
</p>
</>
)}
{(catCfg.backend === 'omnirig' || catCfg.backend === 'icom') && ( {(catCfg.backend === 'omnirig' || catCfg.backend === 'icom') && (
<> <>
<div className="space-y-1"> <div className="space-y-1">
@@ -3846,6 +3962,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
antenna: UltrabeamPanel, antenna: UltrabeamPanel,
antgenius: AntGeniusPanelSettings, antgenius: AntGeniusPanelSettings,
pgxl: PGXLPanelSettings, pgxl: PGXLPanelSettings,
flex: () => <FlexBandAntennasPanel bands={lists.bands ?? []} />,
audio: AudioPanel, audio: AudioPanel,
}; };
@@ -3863,7 +3980,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<div className="grid grid-cols-[320px_1fr] min-h-0 overflow-hidden"> <div className="grid grid-cols-[320px_1fr] min-h-0 overflow-hidden">
{/* Left sidebar tree */} {/* Left sidebar tree */}
<aside className="border-r border-border bg-muted/30 overflow-y-auto p-2"> <aside className="border-r border-border bg-muted/30 overflow-y-auto p-2">
<Tree selected={selected} onSelect={setSelected} /> <Tree selected={selected} onSelect={setSelected} flexAvailable={flexAvailable} />
</aside> </aside>
{/* Right content pane */} {/* Right content pane */}
+17 -1
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
AllCommunityModule, ModuleRegistry, themeQuartz, AllCommunityModule, ModuleRegistry, themeQuartz,
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent, type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
@@ -96,7 +96,12 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
const count = wb?.count ?? 0; const count = wb?.count ?? 0;
const entries = wb?.entries ?? []; const entries = wb?.entries ?? [];
// Suppress auto-save while AG Grid rebuilds columns (award columns load async),
// so the default visibility doesn't clobber the user's saved state. See the
// matching note in RecentQSOsGrid.
const restoringRef = useRef(true);
const columnDefs = useMemo<ColDef<WorkedEntry>[]>(() => { const columnDefs = useMemo<ColDef<WorkedEntry>[]>(() => {
restoringRef.current = true;
const base = COL_CATALOG.map((c) => { const base = COL_CATALOG.map((c) => {
const { group: _g, label: _l, defaultVisible, ...rest } = c; const { group: _g, label: _l, defaultVisible, ...rest } = c;
return { ...rest, hide: !defaultVisible }; return { ...rest, hide: !defaultVisible };
@@ -127,10 +132,21 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
}); });
} }
const saveColumnState = useCallback(() => { const saveColumnState = useCallback(() => {
if (restoringRef.current) return; // ignore events fired by a column rebuild
const state = gridRef.current?.api?.getColumnState(); const state = gridRef.current?.api?.getColumnState();
if (state) saveState(COL_STATE_KEY, state); if (state) saveState(COL_STATE_KEY, state);
}, []); }, []);
// Re-apply the saved column state after the award columns load (they rebuild
// the column set), so the user's visibility choices win over the defaults.
useEffect(() => {
const api = gridRef.current?.api;
const local = loadLocal(COL_STATE_KEY);
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
return () => window.clearTimeout(t);
}, [awardCols]);
function isColVisible(colId: string): boolean { function isColVisible(colId: string): boolean {
const col = gridRef.current?.api?.getColumn(colId); const col = gridRef.current?.api?.getColumn(colId);
return col ? col.isVisible() : !!COL_CATALOG.find((c) => c.colId === colId)?.defaultVisible; return col ? col.isVisible() : !!COL_CATALOG.find((c) => c.colId === colId)?.defaultVisible;
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About). // Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go). // Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.14.1'; export const APP_VERSION = '0.16';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+12
View File
@@ -138,6 +138,8 @@ export function FlexATUStart():Promise<void>;
export function FlexAmpOperate(arg1:boolean):Promise<void>; export function FlexAmpOperate(arg1:boolean):Promise<void>;
export function FlexApplyBandAntenna(arg1:string):Promise<void>;
export function FlexMox(arg1:boolean):Promise<void>; export function FlexMox(arg1:boolean):Promise<void>;
export function FlexSetAGCMode(arg1:string):Promise<void>; export function FlexSetAGCMode(arg1:string):Promise<void>;
@@ -190,8 +192,14 @@ export function FlexSetProcessor(arg1:boolean):Promise<void>;
export function FlexSetProcessorLevel(arg1:number):Promise<void>; export function FlexSetProcessorLevel(arg1:number):Promise<void>;
export function FlexSetRXAntenna(arg1:string):Promise<void>;
export function FlexSetSidetoneLevel(arg1:number):Promise<void>; export function FlexSetSidetoneLevel(arg1:number):Promise<void>;
export function FlexSetSplit(arg1:boolean):Promise<void>;
export function FlexSetTXAntenna(arg1:string):Promise<void>;
export function FlexSetTunePower(arg1:number):Promise<void>; export function FlexSetTunePower(arg1:number):Promise<void>;
export function FlexSetVox(arg1:boolean):Promise<void>; export function FlexSetVox(arg1:boolean):Promise<void>;
@@ -258,6 +266,8 @@ export function GetEmailSettings():Promise<main.EmailSettings>;
export function GetExternalServices():Promise<extsvc.ExternalServices>; export function GetExternalServices():Promise<extsvc.ExternalServices>;
export function GetFlexBandAntennas():Promise<Record<string, main.FlexBandAnt>>;
export function GetFlexState():Promise<cat.FlexTXState>; export function GetFlexState():Promise<cat.FlexTXState>;
export function GetIcomState():Promise<cat.IcomTXState>; export function GetIcomState():Promise<cat.IcomTXState>;
@@ -522,6 +532,8 @@ export function SaveEmailSettings(arg1:main.EmailSettings):Promise<void>;
export function SaveExternalServices(arg1:extsvc.ExternalServices):Promise<void>; export function SaveExternalServices(arg1:extsvc.ExternalServices):Promise<void>;
export function SaveFlexBandAntennas(arg1:Record<string, main.FlexBandAnt>):Promise<void>;
export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>; export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>; export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
+24
View File
@@ -242,6 +242,10 @@ export function FlexAmpOperate(arg1) {
return window['go']['main']['App']['FlexAmpOperate'](arg1); return window['go']['main']['App']['FlexAmpOperate'](arg1);
} }
export function FlexApplyBandAntenna(arg1) {
return window['go']['main']['App']['FlexApplyBandAntenna'](arg1);
}
export function FlexMox(arg1) { export function FlexMox(arg1) {
return window['go']['main']['App']['FlexMox'](arg1); return window['go']['main']['App']['FlexMox'](arg1);
} }
@@ -346,10 +350,22 @@ export function FlexSetProcessorLevel(arg1) {
return window['go']['main']['App']['FlexSetProcessorLevel'](arg1); return window['go']['main']['App']['FlexSetProcessorLevel'](arg1);
} }
export function FlexSetRXAntenna(arg1) {
return window['go']['main']['App']['FlexSetRXAntenna'](arg1);
}
export function FlexSetSidetoneLevel(arg1) { export function FlexSetSidetoneLevel(arg1) {
return window['go']['main']['App']['FlexSetSidetoneLevel'](arg1); return window['go']['main']['App']['FlexSetSidetoneLevel'](arg1);
} }
export function FlexSetSplit(arg1) {
return window['go']['main']['App']['FlexSetSplit'](arg1);
}
export function FlexSetTXAntenna(arg1) {
return window['go']['main']['App']['FlexSetTXAntenna'](arg1);
}
export function FlexSetTunePower(arg1) { export function FlexSetTunePower(arg1) {
return window['go']['main']['App']['FlexSetTunePower'](arg1); return window['go']['main']['App']['FlexSetTunePower'](arg1);
} }
@@ -482,6 +498,10 @@ export function GetExternalServices() {
return window['go']['main']['App']['GetExternalServices'](); return window['go']['main']['App']['GetExternalServices']();
} }
export function GetFlexBandAntennas() {
return window['go']['main']['App']['GetFlexBandAntennas']();
}
export function GetFlexState() { export function GetFlexState() {
return window['go']['main']['App']['GetFlexState'](); return window['go']['main']['App']['GetFlexState']();
} }
@@ -1010,6 +1030,10 @@ export function SaveExternalServices(arg1) {
return window['go']['main']['App']['SaveExternalServices'](arg1); return window['go']['main']['App']['SaveExternalServices'](arg1);
} }
export function SaveFlexBandAntennas(arg1) {
return window['go']['main']['App']['SaveFlexBandAntennas'](arg1);
}
export function SaveListsSettings(arg1) { export function SaveListsSettings(arg1) {
return window['go']['main']['App']['SaveListsSettings'](arg1); return window['go']['main']['App']['SaveListsSettings'](arg1);
} }
+18
View File
@@ -512,10 +512,17 @@ export namespace cat {
atu_status?: string; atu_status?: string;
atu_memories: boolean; atu_memories: boolean;
rx_avail: boolean; rx_avail: boolean;
split: boolean;
rx_freq_hz?: number;
tx_freq_hz?: number;
agc_mode?: string; agc_mode?: string;
agc_threshold: number; agc_threshold: number;
audio_level: number; audio_level: number;
mute: boolean; mute: boolean;
rx_ant?: string;
tx_ant?: string;
ant_list?: string[];
tx_ant_list?: string[];
nb: boolean; nb: boolean;
nb_level: number; nb_level: number;
nr: boolean; nr: boolean;
@@ -561,10 +568,17 @@ export namespace cat {
this.atu_status = source["atu_status"]; this.atu_status = source["atu_status"];
this.atu_memories = source["atu_memories"]; this.atu_memories = source["atu_memories"];
this.rx_avail = source["rx_avail"]; this.rx_avail = source["rx_avail"];
this.split = source["split"];
this.rx_freq_hz = source["rx_freq_hz"];
this.tx_freq_hz = source["tx_freq_hz"];
this.agc_mode = source["agc_mode"]; this.agc_mode = source["agc_mode"];
this.agc_threshold = source["agc_threshold"]; this.agc_threshold = source["agc_threshold"];
this.audio_level = source["audio_level"]; this.audio_level = source["audio_level"];
this.mute = source["mute"]; this.mute = source["mute"];
this.rx_ant = source["rx_ant"];
this.tx_ant = source["tx_ant"];
this.ant_list = source["ant_list"];
this.tx_ant_list = source["tx_ant_list"];
this.nb = source["nb"]; this.nb = source["nb"];
this.nb_level = source["nb_level"]; this.nb_level = source["nb_level"];
this.nr = source["nr"]; this.nr = source["nr"];
@@ -1162,6 +1176,8 @@ export namespace main {
icom_port: string; icom_port: string;
icom_baud: number; icom_baud: number;
icom_addr: number; icom_addr: number;
tci_host: string;
tci_port: number;
poll_ms: number; poll_ms: number;
delay_ms: number; delay_ms: number;
digital_default: string; digital_default: string;
@@ -1181,6 +1197,8 @@ export namespace main {
this.icom_port = source["icom_port"]; this.icom_port = source["icom_port"];
this.icom_baud = source["icom_baud"]; this.icom_baud = source["icom_baud"];
this.icom_addr = source["icom_addr"]; this.icom_addr = source["icom_addr"];
this.tci_host = source["tci_host"];
this.tci_port = source["tci_port"];
this.poll_ms = source["poll_ms"]; this.poll_ms = source["poll_ms"];
this.delay_ms = source["delay_ms"]; this.delay_ms = source["delay_ms"];
this.digital_default = source["digital_default"]; this.digital_default = source["digital_default"];
+1 -1
View File
@@ -6,6 +6,7 @@ require (
github.com/braheezy/shine-mp3 v0.1.0 github.com/braheezy/shine-mp3 v0.1.0
github.com/go-ole/go-ole v1.3.0 github.com/go-ole/go-ole v1.3.0
github.com/go-sql-driver/mysql v1.10.0 github.com/go-sql-driver/mysql v1.10.0
github.com/gorilla/websocket v1.5.3
github.com/moutend/go-wca v0.3.0 github.com/moutend/go-wca v0.3.0
github.com/wailsapp/wails/v2 v2.11.0 github.com/wailsapp/wails/v2 v2.11.0
github.com/wneessen/go-mail v0.7.3 github.com/wneessen/go-mail v0.7.3
@@ -22,7 +23,6 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect github.com/labstack/echo/v4 v4.13.3 // indirect
github.com/labstack/gommon v0.4.2 // indirect github.com/labstack/gommon v0.4.2 // indirect
+34 -24
View File
@@ -39,18 +39,18 @@ type Backend interface {
// and RxFreqHz is the active VFO (where they listen). When not split, // and RxFreqHz is the active VFO (where they listen). When not split,
// RxFreqHz is 0 — the UI shouldn't show a redundant RX field. // RxFreqHz is 0 — the UI shouldn't show a redundant RX field.
type RigState struct { type RigState struct {
Enabled bool `json:"enabled"` // user toggled CAT on Enabled bool `json:"enabled"` // user toggled CAT on
Connected bool `json:"connected"` // backend says rig is online Connected bool `json:"connected"` // backend says rig is online
Backend string `json:"backend,omitempty"` // active backend name Backend string `json:"backend,omitempty"` // active backend name
RigNum int `json:"rig_num,omitempty"` // OmniRig slot 1 or 2 (when applicable) RigNum int `json:"rig_num,omitempty"` // OmniRig slot 1 or 2 (when applicable)
Rig string `json:"rig,omitempty"` // rig model (best-effort) Rig string `json:"rig,omitempty"` // rig model (best-effort)
FreqHz int64 `json:"freq_hz,omitempty"` // TX freq (= active VFO when not split) FreqHz int64 `json:"freq_hz,omitempty"` // TX freq (= active VFO when not split)
RxFreqHz int64 `json:"freq_rx_hz,omitempty"` // RX freq, only set when Split RxFreqHz int64 `json:"freq_rx_hz,omitempty"` // RX freq, only set when Split
Split bool `json:"split,omitempty"` // rig is in split mode Split bool `json:"split,omitempty"` // rig is in split mode
Mode string `json:"mode,omitempty"` // ADIF mode (SSB/CW/DATA/AM/FM/RTTY) Mode string `json:"mode,omitempty"` // ADIF mode (SSB/CW/DATA/AM/FM/RTTY)
Band string `json:"band,omitempty"` // computed from FreqHz Band string `json:"band,omitempty"` // computed from FreqHz
Vfo string `json:"vfo,omitempty"` // "A" | "B" | "AA" | "AB" | "BA" | "BB" Vfo string `json:"vfo,omitempty"` // "A" | "B" | "AA" | "AB" | "BA" | "BB"
Error string `json:"error,omitempty"` // last connect/poll error if any Error string `json:"error,omitempty"` // last connect/poll error if any
UpdatedAt time.Time `json:"updated_at,omitempty"` UpdatedAt time.Time `json:"updated_at,omitempty"`
} }
@@ -230,7 +230,7 @@ func (m *Manager) SendSpot(s SpotInfo) {
// FlexRadio control tab. Levels are 0-100. (Phase 1: controls + state pushed by // FlexRadio control tab. Levels are 0-100. (Phase 1: controls + state pushed by
// the radio over TCP; live meters arrive over a separate UDP stream later.) // the radio over TCP; live meters arrive over a separate UDP stream later.)
type FlexTXState struct { type FlexTXState struct {
Available bool `json:"available"` // backend is Flex and handshaked Available bool `json:"available"` // backend is Flex and handshaked
Model string `json:"model,omitempty"` Model string `json:"model,omitempty"`
RFPower int `json:"rf_power"` RFPower int `json:"rf_power"`
TunePower int `json:"tune_power"` TunePower int `json:"tune_power"`
@@ -247,17 +247,24 @@ type FlexTXState struct {
ATUStatus string `json:"atu_status,omitempty"` ATUStatus string `json:"atu_status,omitempty"`
ATUMemories bool `json:"atu_memories"` ATUMemories bool `json:"atu_memories"`
// Active RX slice DSP controls. // Active RX slice DSP controls.
RXAvail bool `json:"rx_avail"` // an RX slice exists RXAvail bool `json:"rx_avail"` // an RX slice exists
AGCMode string `json:"agc_mode,omitempty"` Split bool `json:"split"` // RX/TX on separate slices
AGCThreshold int `json:"agc_threshold"` RXFreqHz int64 `json:"rx_freq_hz,omitempty"` // RX slice freq when split
AudioLevel int `json:"audio_level"` TXFreqHz int64 `json:"tx_freq_hz,omitempty"` // TX slice freq when split
Mute bool `json:"mute"` AGCMode string `json:"agc_mode,omitempty"`
NB bool `json:"nb"` AGCThreshold int `json:"agc_threshold"`
NBLevel int `json:"nb_level"` AudioLevel int `json:"audio_level"`
NR bool `json:"nr"` Mute bool `json:"mute"`
NRLevel int `json:"nr_level"` RXAnt string `json:"rx_ant,omitempty"` // selected RX antenna
ANF bool `json:"anf"` TXAnt string `json:"tx_ant,omitempty"` // selected TX antenna
ANFLevel int `json:"anf_level"` AntList []string `json:"ant_list,omitempty"` // antennas selectable for RX
TXAntList []string `json:"tx_ant_list,omitempty"` // antennas selectable for TX
NB bool `json:"nb"`
NBLevel int `json:"nb_level"`
NR bool `json:"nr"`
NRLevel int `json:"nr_level"`
ANF bool `json:"anf"`
ANFLevel int `json:"anf_level"`
// CW / mode-specific controls. // CW / mode-specific controls.
Mode string `json:"mode,omitempty"` // active slice mode (CW/USB/LSB/DIGU…) Mode string `json:"mode,omitempty"` // active slice mode (CW/USB/LSB/DIGU…)
CWSpeed int `json:"cw_speed"` CWSpeed int `json:"cw_speed"`
@@ -314,6 +321,9 @@ type FlexController interface {
SetAGCThreshold(int) error SetAGCThreshold(int) error
SetAudioLevel(int) error SetAudioLevel(int) error
SetMute(bool) error SetMute(bool) error
SetRXAntenna(string) error
SetTXAntenna(string) error
SetSplit(bool) error
SetNB(bool) error SetNB(bool) error
SetNBLevel(int) error SetNBLevel(int) error
SetNR(bool) error SetNR(bool) error
+143 -30
View File
@@ -33,27 +33,28 @@ type Flex struct {
model string model string
gotHandle bool gotHandle bool
slices map[int]*flexSlice slices map[int]*flexSlice
tx flexTX // transmit/ATU state pushed by the radio (FlexRadio tab) tx flexTX // transmit/ATU state pushed by the radio (FlexRadio tab)
amp flexAmp // external amplifier (PowerGenius XL) state amp flexAmp // external amplifier (PowerGenius XL) state
txSetAt map[string]time.Time // status field → when WE last set it (ignore the radio's lagging echo briefly) txSetAt map[string]time.Time // status field → when WE last set it (ignore the radio's lagging echo briefly)
lastStateSig string // last logged derived-state signature (log only on change) lastStateSig string // last logged derived-state signature (log only on change)
boundClientID string // GUI client (SmartSDR) we bound to; "" until bound. Binding lets this non-GUI client receive GUI-tied data (CW pitch/speed, break-in delay, RF power). boundClientID string // GUI client (SmartSDR) we bound to; "" until bound. Binding lets this non-GUI client receive GUI-tied data (CW pitch/speed, break-in delay, RF power).
// Live meters streamed over UDP (VITA-49). meterMeta is the definitions // Live meters streamed over UDP (VITA-49). meterMeta is the definitions
// pushed over TCP; meterVal the latest scaled values keyed by meter id. // pushed over TCP; meterVal the latest scaled values keyed by meter id.
udpConn *net.UDPConn udpConn *net.UDPConn
meterMeta map[int]meterInfo meterMeta map[int]meterInfo
meterVal map[int]float64 meterVal map[int]float64
meterSub map[int]bool // ids we've already sent "sub meter <id>" for meterSub map[int]bool // ids we've already sent "sub meter <id>" for
meterLogAt time.Time // throttle for value logging meterLogAt time.Time // throttle for value logging
vitaSeen int // count of UDP datagrams (first few logged for diag) vitaSeen int // count of UDP datagrams (first few logged for diag)
meterRawLogged bool // log the first raw meter-definition status once meterRawLogged bool // log the first raw meter-definition status once
txRawLogged bool // log the first raw transmit status once (field-name audit) txRawLogged bool // log the first raw transmit status once (field-name audit)
spotsEnabled bool // push cluster spots + manage the panadapter overlay spotsEnabled bool // push cluster spots + manage the panadapter overlay
spotIdx map[int]bool // panadapter spot indices currently known to the radio spotIdx map[int]bool // panadapter spot indices currently known to the radio
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
pendingSplit map[int]bool // seq → awaiting the new TX slice's index (split create)
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click) spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
sentCmds map[int]string // seq → command text, so an R<seq> error names the command sentCmds map[int]string // seq → command text, so an R<seq> error names the command
@@ -76,14 +77,18 @@ type flexSlice struct {
mute bool // RX audio muted mute bool // RX audio muted
nb bool // noise blanker nb bool // noise blanker
nbLevel int nbLevel int
nr bool // noise reduction nr bool // noise reduction
nrLevel int nrLevel int
anf bool // auto notch filter anf bool // auto notch filter
anfLevel int anfLevel int
apf bool // CW audio peaking filter apf bool // CW audio peaking filter
apfLevel int apfLevel int
filterLo int // slice filter low cut (Hz) filterLo int // slice filter low cut (Hz)
filterHi int // slice filter high cut (Hz) filterHi int // slice filter high cut (Hz)
rxAnt string // selected RX antenna (e.g. ANT1, ANT2, RX_A)
txAnt string // selected TX antenna
antList []string // antennas valid for this slice (RX side)
txAntList []string // antennas valid for TX
} }
// flexTX mirrors the radio's transmit/ATU/interlock objects (the SmartSDR-style // flexTX mirrors the radio's transmit/ATU/interlock objects (the SmartSDR-style
@@ -144,7 +149,7 @@ func NewFlex(host string, port int, spotsEnabled bool) *Flex {
return &Flex{ return &Flex{
host: strings.TrimSpace(host), port: port, host: strings.TrimSpace(host), port: port,
slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled, slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled,
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{}, spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{}, pendingSplit: map[int]bool{},
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{}, meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{}, sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
} }
@@ -184,8 +189,8 @@ func (f *Flex) Connect() error {
// Identify ourselves in SmartSDR's client list, then stream slice + transmit // Identify ourselves in SmartSDR's client list, then stream slice + transmit
// (TX/split) status. Command names per the SmartSDR TCP/IP API docs. // (TX/split) status. Command names per the SmartSDR TCP/IP API docs.
f.send("client program OpsLog") f.send("client program OpsLog")
f.send("sub slice all") // slice/receiver: freq, mode, AGC, NB/NR/ANF, audio… f.send("sub slice all") // slice/receiver: freq, mode, AGC, NB/NR/ANF, audio…
f.send("sub tx all") // transmit: rfpower, tunepower, vox, processor, mon, mic f.send("sub tx all") // transmit: rfpower, tunepower, vox, processor, mon, mic
f.send("sub atu all") // antenna-tuner status + memories f.send("sub atu all") // antenna-tuner status + memories
f.send("sub amplifier all") // external amplifier (PowerGenius XL) operate/standby 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 radio all") // radio-wide incl. interlock (TX/RX state)
@@ -310,7 +315,18 @@ func (f *Flex) reader(conn net.Conn) {
f.spotIdx[idx] = true f.spotIdx[idx] = true
} }
} }
// A successful "slice create" for split returns the new slice's index;
// make that slice the transmitter so RX/TX are on separate slices.
splitSeq := f.pendingSplit[seq]
if splitSeq {
delete(f.pendingSplit, seq)
}
f.mu.Unlock() f.mu.Unlock()
if splitSeq && ok && len(parts) >= 3 {
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
f.send(fmt.Sprintf("slice s %d tx=1", idx))
}
}
} }
} }
// Connection ended. // Connection ended.
@@ -673,6 +689,14 @@ func (f *Flex) handleStatus(payload string) {
s.audioLevel = atoiDefault(val, s.audioLevel) s.audioLevel = atoiDefault(val, s.audioLevel)
case "audio_mute", "mute": case "audio_mute", "mute":
s.mute = val == "1" s.mute = val == "1"
case "rxant":
s.rxAnt = val
case "txant":
s.txAnt = val
case "ant_list":
s.antList = splitCSV(val)
case "tx_ant_list":
s.txAntList = splitCSV(val)
case "nb": case "nb":
s.nb = val == "1" s.nb = val == "1"
case "nb_level": case "nb_level":
@@ -960,6 +984,18 @@ func splitKV(kv string) (key, val string, ok bool) {
} }
// atoiDefault parses an int (or a float like "20.0", truncated), else def. // atoiDefault parses an int (or a float like "20.0", truncated), else def.
// splitCSV splits a comma-separated antenna list (e.g. "ANT1,ANT2,RX_A") into a
// trimmed slice, dropping empties.
func splitCSV(s string) []string {
out := []string{}
for _, p := range strings.Split(s, ",") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
func atoiDefault(s string, def int) int { func atoiDefault(s string, def int) int {
s = strings.TrimSpace(s) s = strings.TrimSpace(s)
if n, err := strconv.Atoi(s); err == nil { if n, err := strconv.Atoi(s); err == nil {
@@ -1026,6 +1062,11 @@ func (f *Flex) FlexState() FlexTXState {
CWSidetone: f.tx.cwSidetone, CWSidetone: f.tx.cwSidetone,
CWMonLevel: f.tx.cwMonLevel, CWMonLevel: f.tx.cwMonLevel,
} }
if rx, tx := f.pickSlicesLocked(); rx != nil && tx != nil && rx != tx && rx.freqHz != tx.freqHz {
st.Split = true
st.RXFreqHz = rx.freqHz
st.TXFreqHz = tx.freqHz
}
if _, rx := f.rxSliceLocked(); rx != nil { if _, rx := f.rxSliceLocked(); rx != nil {
st.RXAvail = true st.RXAvail = true
st.Mode = strings.ToUpper(rx.mode) st.Mode = strings.ToUpper(rx.mode)
@@ -1043,6 +1084,13 @@ func (f *Flex) FlexState() FlexTXState {
st.APFLevel = rx.apfLevel st.APFLevel = rx.apfLevel
st.FilterLo = rx.filterLo st.FilterLo = rx.filterLo
st.FilterHi = rx.filterHi st.FilterHi = rx.filterHi
st.RXAnt = rx.rxAnt
st.TXAnt = rx.txAnt
st.AntList = rx.antList
st.TXAntList = rx.txAntList
if len(st.TXAntList) == 0 {
st.TXAntList = rx.antList // many configs share one antenna list
}
} }
if f.amp.handle != "" { if f.amp.handle != "" {
st.AmpAvailable = true st.AmpAvailable = true
@@ -1098,6 +1146,10 @@ func (f *Flex) sendSlice(param string, val any) error {
rx.apf = val == "1" rx.apf = val == "1"
case "apf_level": case "apf_level":
rx.apfLevel = toInt(val) rx.apfLevel = toInt(val)
case "rxant":
rx.rxAnt = fmt.Sprint(val)
case "txant":
rx.txAnt = fmt.Sprint(val)
} }
} }
f.mu.Unlock() f.mu.Unlock()
@@ -1133,14 +1185,75 @@ func (f *Flex) SetAGCMode(m string) error {
func (f *Flex) SetAGCThreshold(l int) error { return f.sendSlice("agc_threshold", clampLevel(l)) } func (f *Flex) SetAGCThreshold(l int) error { return f.sendSlice("agc_threshold", clampLevel(l)) }
func (f *Flex) SetAudioLevel(l int) error { return f.sendSlice("audio_level", clampLevel(l)) } func (f *Flex) SetAudioLevel(l int) error { return f.sendSlice("audio_level", clampLevel(l)) }
func (f *Flex) SetMute(on bool) error { return f.sendSlice("audio_mute", boolFlex(on)) } func (f *Flex) SetMute(on bool) error { return f.sendSlice("audio_mute", boolFlex(on)) }
func (f *Flex) SetNB(on bool) error { return f.sendSlice("nb", boolFlex(on)) } func (f *Flex) SetRXAntenna(a string) error { return f.sendSlice("rxant", a) }
func (f *Flex) SetNBLevel(l int) error { return f.sendSlice("nb_level", clampLevel(l)) } func (f *Flex) SetTXAntenna(a string) error { return f.sendSlice("txant", a) }
func (f *Flex) SetNR(on bool) error { return f.sendSlice("nr", boolFlex(on)) }
func (f *Flex) SetNRLevel(l int) error { return f.sendSlice("nr_level", clampLevel(l)) } // SetSplit toggles 2-slice split like SmartSDR's SPLIT button. ON creates a TX
func (f *Flex) SetANF(on bool) error { return f.sendSlice("anf", boolFlex(on)) } // slice at RX freq + offset (+1 kHz on CW, +5 kHz otherwise) and makes it the
func (f *Flex) SetANFLevel(l int) error { return f.sendSlice("anf_level", clampLevel(l)) } // transmitter; OFF removes the extra TX slice and returns to simplex (RX slice
func (f *Flex) SetAPF(on bool) error { return f.sendSlice("apf", boolFlex(on)) } // transmits again).
func (f *Flex) SetAPFLevel(l int) error { return f.sendSlice("apf_level", clampLevel(l)) } func (f *Flex) SetSplit(on bool) error {
f.mu.Lock()
if f.conn == nil {
f.mu.Unlock()
return fmt.Errorf("flex: not connected")
}
rx, tx := f.pickSlicesLocked()
rxIdx, txIdx := -1, -1
for i, s := range f.slices {
if s == rx {
rxIdx = i
}
if s == tx {
txIdx = i
}
}
alreadySplit := rx != nil && tx != nil && rx != tx
var rxFreq int64
var rxMode, rxAnt string
if rx != nil {
rxFreq, rxMode, rxAnt = rx.freqHz, rx.mode, rx.rxAnt
}
f.mu.Unlock()
if on {
if alreadySplit || rx == nil {
return nil // already split (or no slice)
}
offset := int64(5000)
if strings.HasPrefix(strings.ToUpper(rxMode), "CW") {
offset = 1000
}
cmd := fmt.Sprintf("slice create freq=%.6f mode=%s", float64(rxFreq+offset)/1e6, rxMode)
if rxAnt != "" {
cmd += " ant=" + rxAnt
}
if seq := f.send(cmd); seq > 0 {
f.mu.Lock()
f.pendingSplit[seq] = true // mark the new slice TX once its index returns
f.mu.Unlock()
}
return nil
}
if !alreadySplit {
return nil
}
if txIdx >= 0 {
f.send(fmt.Sprintf("slice remove %d", txIdx))
}
if rxIdx >= 0 {
f.send(fmt.Sprintf("slice s %d tx=1", rxIdx))
}
return nil
}
func (f *Flex) SetNB(on bool) error { return f.sendSlice("nb", boolFlex(on)) }
func (f *Flex) SetNBLevel(l int) error { return f.sendSlice("nb_level", clampLevel(l)) }
func (f *Flex) SetNR(on bool) error { return f.sendSlice("nr", boolFlex(on)) }
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) ── // ── CW keyer controls (top-level "cw" commands) ──
+294
View File
@@ -0,0 +1,294 @@
//go:build windows
package cat
import (
"fmt"
"net"
"strconv"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
)
// TCI is a native backend for Expert Electronics' TCI protocol (SunSDR2/MB1/
// ColibriNANO via ExpertSDR2/EESDR, and TCI-compatible apps). TCI is a text
// protocol over a WebSocket: the server streams state ("vfo:0,0,14100000;",
// "modulation:0,cw;", "trx:0,true;") and accepts the same commands to control
// the rig. We keep the pushed state cached so ReadState is instant, like Flex.
//
// Pure Go (gorilla/websocket, no CGO). Default port 40001.
type TCI struct {
host string
port int
digitalDefault string // surfaced when the rig reports a digital mode (FT8/…)
mu sync.Mutex // guards conn + writes + state
conn *websocket.Conn
ready bool
// Cached state pushed by the radio.
device string
freqA int64 // VFO A (RX) frequency, Hz (vfo:0,0)
freqB int64 // VFO B (TX in split), Hz (vfo:0,1)
mode string
split bool
tx bool
lastSig string // last logged state signature (log only on change)
}
const tciDefaultPort = 40001
// NewTCI builds a TCI backend for the given host/port. digitalDefault is the
// mode surfaced when the radio reports a generic digital modulation.
func NewTCI(host string, port int, digitalDefault string) *TCI {
if port <= 0 || port > 65535 {
port = tciDefaultPort
}
return &TCI{host: strings.TrimSpace(host), port: port, digitalDefault: strings.TrimSpace(digitalDefault)}
}
func (t *TCI) Name() string { return "tci" }
// Connect opens the WebSocket and starts the reader goroutine. The reader keeps
// our cached state current from the radio's push messages.
func (t *TCI) Connect() error {
t.mu.Lock()
already := t.conn != nil
host, port := t.host, t.port
t.mu.Unlock()
if already {
return nil
}
if host == "" {
return fmt.Errorf("tci: no host configured")
}
url := fmt.Sprintf("ws://%s", net.JoinHostPort(host, strconv.Itoa(port)))
dialer := websocket.Dialer{HandshakeTimeout: 5 * time.Second}
conn, _, err := dialer.Dial(url, nil)
if err != nil {
return fmt.Errorf("tci: connect %s: %w", url, err)
}
t.mu.Lock()
t.conn = conn
t.ready = false
t.mu.Unlock()
debugLog.Printf("TCI: connected to %s", url)
go t.reader(conn)
return nil
}
// Disconnect closes the WebSocket; the reader goroutine then exits.
func (t *TCI) Disconnect() {
t.mu.Lock()
c := t.conn
t.conn = nil
t.ready = false
t.mu.Unlock()
if c != nil {
_ = c.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
_ = c.Close()
}
}
// ReadState returns the cached state pushed by the radio.
func (t *TCI) ReadState() (RigState, error) {
t.mu.Lock()
defer t.mu.Unlock()
if t.conn == nil {
return RigState{}, fmt.Errorf("tci: not connected")
}
st := RigState{Connected: t.ready, Rig: t.device}
if !t.ready {
return st, nil
}
// ADIF convention: FreqHz is the TX freq. In split, TX is VFO B.
if t.split && t.freqB > 0 {
st.FreqHz = t.freqB
st.RxFreqHz = t.freqA
st.Split = true
} else {
st.FreqHz = t.freqA
}
st.Mode = tciModeToADIF(t.mode, t.digitalDefault)
if st.FreqHz > 0 {
st.Band = BandFromHz(st.FreqHz)
}
sig := fmt.Sprintf("%d/%d/%v/%s", st.FreqHz, st.RxFreqHz, st.Split, st.Mode)
if sig != t.lastSig {
t.lastSig = sig
debugLog.Printf("TCI: state tx=%d rx=%d split=%v mode=%s", st.FreqHz, st.RxFreqHz, st.Split, st.Mode)
}
return st, nil
}
// SetFrequency tunes VFO A (the main/RX VFO).
func (t *TCI) SetFrequency(hz int64) error {
return t.send(fmt.Sprintf("vfo:0,0,%d;", hz))
}
// SetMode maps an ADIF mode to a TCI modulation and sets it. USB vs LSB is
// chosen from the current VFO-A frequency (< 10 MHz → LSB).
func (t *TCI) SetMode(mode string) error {
t.mu.Lock()
freq := t.freqA
t.mu.Unlock()
m := adifToTCIMode(mode, freq)
if m == "" {
return nil
}
return t.send(fmt.Sprintf("modulation:0,%s;", m))
}
// SetPTT keys or unkeys the transmitter (VFO 0).
func (t *TCI) SetPTT(on bool) error {
return t.send(fmt.Sprintf("trx:0,%t;", on))
}
// send writes a command to the WebSocket (one writer at a time).
func (t *TCI) send(cmd string) error {
t.mu.Lock()
c := t.conn
t.mu.Unlock()
if c == nil {
return fmt.Errorf("tci: not connected")
}
_ = c.SetWriteDeadline(time.Now().Add(3 * time.Second))
if err := c.WriteMessage(websocket.TextMessage, []byte(cmd)); err != nil {
debugLog.Printf("TCI: send %q failed: %v", cmd, err)
return err
}
debugLog.Printf("TCI: → %s", cmd)
return nil
}
// reader drains push messages and keeps the cached state current until the
// connection closes.
func (t *TCI) reader(conn *websocket.Conn) {
for {
_, data, err := conn.ReadMessage()
if err != nil {
break
}
// A frame may carry several ";"-terminated commands.
for _, cmd := range strings.Split(string(data), ";") {
t.handle(strings.TrimSpace(cmd))
}
}
t.mu.Lock()
if t.conn == conn {
t.conn = nil
t.ready = false
}
t.mu.Unlock()
debugLog.Printf("TCI: reader ended")
}
// handle parses one "command:args" message and updates the cache.
func (t *TCI) handle(msg string) {
if msg == "" {
return
}
name, args := msg, ""
if i := strings.IndexByte(msg, ':'); i >= 0 {
name, args = msg[:i], msg[i+1:]
}
f := strings.Split(args, ",")
get := func(i int) string {
if i < len(f) {
return strings.TrimSpace(f[i])
}
return ""
}
t.mu.Lock()
defer t.mu.Unlock()
switch strings.ToLower(name) {
case "device":
t.device = strings.TrimSpace(args)
case "ready", "start":
t.ready = true
case "stop":
t.ready = false
case "vfo":
// vfo:<rx>,<channel>,<freq>
if get(0) == "0" {
hz, _ := strconv.ParseInt(get(2), 10, 64)
if hz > 0 {
t.ready = true // receiving live state → treat as ready even without an explicit "ready;"
switch get(1) {
case "0":
t.freqA = hz
case "1":
t.freqB = hz
}
}
}
case "modulation":
if get(0) == "0" {
t.mode = strings.ToLower(get(1))
}
case "split_enable":
if get(0) == "0" {
t.split = get(1) == "true"
}
case "trx":
if get(0) == "0" {
t.tx = get(1) == "true"
}
}
}
// tciModeToADIF converts a TCI modulation to an ADIF mode. Generic digital
// modulations surface the operator's chosen digital default (FT8/FT4/RTTY…).
func tciModeToADIF(m, digitalDefault string) string {
switch strings.ToLower(strings.TrimSpace(m)) {
case "usb", "lsb", "dsb":
return "SSB"
case "cw":
return "CW"
case "am", "sam":
return "AM"
case "nfm", "wfm", "fm":
return "FM"
case "digu", "digl":
if digitalDefault != "" {
return strings.ToUpper(digitalDefault)
}
return "DATA"
case "drm":
return "DIGITALVOICE"
case "":
return ""
default:
return strings.ToUpper(m)
}
}
// adifToTCIMode maps an ADIF mode to a TCI modulation. USB/LSB is chosen from
// the frequency (< 10 MHz → LSB) as usual. Digital modes → digu.
func adifToTCIMode(mode string, freqHz int64) string {
switch strings.ToUpper(strings.TrimSpace(mode)) {
case "SSB", "USB", "LSB":
if freqHz > 0 && freqHz < 10_000_000 {
return "lsb"
}
return "usb"
case "CW", "CWR", "CW-R":
return "cw"
case "AM":
return "am"
case "FM", "NFM":
return "nfm"
case "RTTY":
return "digl"
case "":
return ""
default:
// FT8/FT4/PSK/DATA/JT… → upper-sideband digital.
return "digu"
}
}
+6
View File
@@ -16,6 +16,10 @@ import (
// subscription used elsewhere for callsign data — they're different keys. // subscription used elsewhere for callsign data — they're different keys.
const qrzAPIURL = "https://logbook.qrz.com/api" const qrzAPIURL = "https://logbook.qrz.com/api"
// LogSink receives diagnostics such as raw QRZ responses. Set to applog.Printf
// at startup; defaults to a no-op so the package is usable without wiring.
var LogSink = func(string, ...any) {}
// UploadQRZ pushes one ADIF record to the QRZ.com logbook identified by // UploadQRZ pushes one ADIF record to the QRZ.com logbook identified by
// apiKey. It returns OK when the QSO is inserted or already present // apiKey. It returns OK when the QSO is inserted or already present
// (QRZ reports a duplicate as a FAIL with a "duplicate" reason, which we // (QRZ reports a duplicate as a FAIL with a "duplicate" reason, which we
@@ -67,6 +71,7 @@ func UploadQRZ(ctx context.Context, client *http.Client, apiKey, adifRecord stri
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return UploadResult{}, fmt.Errorf("qrz: http %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) return UploadResult{}, fmt.Errorf("qrz: http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
} }
LogSink("qrz: INSERT raw response: %s", strings.TrimSpace(string(body)))
return parseQRZResponse(string(body)) return parseQRZResponse(string(body))
} }
@@ -171,6 +176,7 @@ func TestQRZ(ctx context.Context, client *http.Client, apiKey string) (string, e
} }
defer resp.Body.Close() defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
LogSink("qrz: STATUS raw response: %s", strings.TrimSpace(string(body)))
vals, err := url.ParseQuery(strings.TrimSpace(string(body))) vals, err := url.ParseQuery(strings.TrimSpace(string(body)))
if err != nil { if err != nil {
return "", fmt.Errorf("qrz: bad response: %w", err) return "", fmt.Errorf("qrz: bad response: %w", err)
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const ( const (
// appVersion is stamped on every heartbeat (and could feed the About box). // appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.14.1" appVersion = "0.16"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change // posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project. // to https://us.i.posthog.com for a US project.