17 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
rouggy 64f2d38d87 chore: release v0.14.1 2026-06-28 20:39:15 +02:00
rouggy 299184712a fix: beam heading full color 2026-06-28 20:38:59 +02:00
rouggy 76c1e2df60 fix: forcing 3 sec for each notification instead of one replacing the other 2026-06-28 19:12:55 +02:00
rouggy 165f33caa5 fix: recording not being sent if mail enable was not checked 2026-06-28 19:08:32 +02:00
rouggy 464a1c702c feat: added mute and AGC-T to Flex control 2026-06-27 21:03:13 +02:00
rouggy 19c5045dc6 fix: autocall in remote not working properly 2026-06-27 20:28:29 +02:00
21 changed files with 1236 additions and 245 deletions
+171 -8
View File
@@ -89,6 +89,8 @@ const (
keyCATIcomPort = "cat.icom.port" // Icom USB CI-V serial port (e.g. COM5)
keyCATIcomBaud = "cat.icom.baud" // Icom CI-V baud (default 115200)
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
// 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)
IcomBaud int `json:"icom_baud"` // Icom CI-V baud (default 115200)
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)
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/…)
@@ -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
// to a separate cat.log in the old HamLog folder, which users couldn't find).
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)
// The local SQLite file ALWAYS holds per-operator configuration — settings,
// station profiles, rigs/antennas, cluster nodes, UDP, QSL templates, award
@@ -3356,6 +3361,8 @@ type QSLBulkUpdate struct {
SentDate string `json:"sent_date"` // YYYYMMDD — "" = unchanged
RcvdDate string `json:"rcvd_date"` // YYYYMMDD — "" = unchanged
Via string `json:"via"` // QSL_VIA — "" = unchanged
Notes string `json:"notes"` // NOTES — "" = unchanged
Comment string `json:"comment"` // COMMENT — "" = unchanged
}
// BulkUpdateQSL applies paper-QSL sent/received status, dates and via to the
@@ -3388,6 +3395,12 @@ func (a *App) BulkUpdateQSL(ids []int64, u QSLBulkUpdate) (int, error) {
if v := strings.TrimSpace(u.Via); v != "" {
q.QSLVia, changed = v, true
}
if v := strings.TrimSpace(u.Notes); v != "" {
q.Notes, changed = v, true
}
if v := strings.TrimSpace(u.Comment); v != "" {
q.Comment, changed = v, true
}
if changed {
if a.qso.Update(a.ctx, q) == nil {
n++
@@ -3843,7 +3856,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if a.settings == nil {
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
}
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, 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 {
return CATSettings{}, err
}
@@ -3857,6 +3870,8 @@ func (a *App) GetCATSettings() (CATSettings, error) {
IcomPort: m[keyCATIcomPort],
IcomBaud: 115200,
IcomAddr: 0x98, // IC-7610 default
TCIHost: m[keyCATTCIHost],
TCIPort: 40001,
PollMs: 250,
DelayMs: 0,
DigitalDefault: m[keyCATDigitalDefault],
@@ -3864,6 +3879,9 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if n, _ := strconv.Atoi(m[keyCATFlexPort]); n > 0 && n <= 65535 {
out.FlexPort = n
}
if n, _ := strconv.Atoi(m[keyCATTCIPort]); n > 0 && n <= 65535 {
out.TCIPort = n
}
if n, _ := strconv.Atoi(m[keyCATIcomBaud]); n > 0 {
out.IcomBaud = n
}
@@ -3935,6 +3953,8 @@ func (a *App) SaveCATSettings(s CATSettings) error {
keyCATIcomPort: strings.TrimSpace(s.IcomPort),
keyCATIcomBaud: strconv.Itoa(s.IcomBaud),
keyCATIcomAddr: strconv.Itoa(s.IcomAddr),
keyCATTCIHost: strings.TrimSpace(s.TCIHost),
keyCATTCIPort: strconv.Itoa(s.TCIPort),
keyCATPollMs: strconv.Itoa(s.PollMs),
keyCATDelayMs: strconv.Itoa(s.DelayMs),
keyCATDigitalDefault: strings.ToUpper(strings.TrimSpace(s.DigitalDefault)),
@@ -4207,13 +4227,33 @@ func (a *App) saveQSORecording(q *qso.QSO) {
return
}
applog.Printf("qso-rec: saved %s", path)
// Auto-send the recording once the file exists.
if es, _ := a.GetEmailSettings(); es.Enabled && es.AutoSend && strings.TrimSpace(qc.Email) != "" {
_ = a.sendRecordingEmail(qc, path)
// Auto-send the recording once the file exists. Gated ONLY on its own
// "auto-send recording" toggle (email.auto_send) — NOT the SMTP panel's
// master "Enabled" flag, mirroring the eQSL-card auto-send. (Requiring
// Enabled silently skipped sending with no log when only this box was on.)
// Give the operator visible feedback either way.
if es, _ := a.GetEmailSettings(); es.AutoSend {
if strings.TrimSpace(qc.Email) == "" {
applog.Printf("qso-rec: %s not e-mailed — no recipient address known", qc.Callsign)
a.toast(fmt.Sprintf("Recording saved — no e-mail for %s, not sent", qc.Callsign))
} else if err := a.sendRecordingEmail(qc, path); err != nil {
a.toast(fmt.Sprintf("Recording e-mail to %s failed", qc.Callsign))
} else {
a.toast(fmt.Sprintf("Recording e-mailed to %s", qc.Callsign))
a.markRecordingSent(qc.ID)
}
}
}()
}
// toast emits a transient message the frontend shows as a toast notification.
// Used for background events (auto-send results) the UI can't otherwise surface.
func (a *App) toast(msg string) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "toast", msg)
}
}
// sanitizeFilename makes a callsign safe for a filename (slashes etc.).
func sanitizeFilename(s string) string {
s = strings.ToUpper(strings.TrimSpace(s))
@@ -4687,6 +4727,28 @@ func (a *App) fillTemplate(tmpl string, q qso.QSO) string {
return r.Replace(tmpl)
}
// markRecordingSent stamps the QSO so the UI (and the operator) knows its audio
// recording was e-mailed — exposed as the APP_OPSLOG_RECORDING_SENT extra and a
// "Recording sent" column in Recent QSOs. Re-reads the row first so a concurrent
// status update (eQSL/upload) isn't clobbered by writing back the whole row.
func (a *App) markRecordingSent(id int64) {
if a.qso == nil || id == 0 {
return
}
q, err := a.qso.GetByID(a.ctx, id)
if err != nil {
applog.Printf("qso-rec: mark sent: load %d: %v", id, err)
return
}
if q.Extras == nil {
q.Extras = map[string]string{}
}
q.Extras["APP_OPSLOG_RECORDING_SENT"] = time.Now().UTC().Format("2006-01-02")
if err := a.qso.Update(a.ctx, q); err != nil {
applog.Printf("qso-rec: mark sent: update %d: %v", id, err)
}
}
// sendRecordingEmail e-mails a QSO recording to the contacted operator.
func (a *App) sendRecordingEmail(q qso.QSO, attachPath string) error {
s, _ := a.GetEmailSettings()
@@ -4732,7 +4794,11 @@ func (a *App) SendQSORecordingEmail(id int64) error {
if _, e := os.Stat(path); e != nil {
return fmt.Errorf("recording file missing: %s", name)
}
return a.sendRecordingEmail(q, path)
if err := a.sendRecordingEmail(q, path); err != nil {
return err
}
a.markRecordingSent(id)
return nil
}
// ── ClubLog Country File (cty.xml) exceptions ─────────────────────────
@@ -5626,7 +5692,9 @@ func uploadColumnFor(service string) string {
// FindQSOsForUpload returns QSOs whose sent status for the given service
// matches sentStatus ("" = blank). Powers the QSL Manager's Select required.
func (a *App) FindQSOsForUpload(service, sentStatus string) ([]qso.UploadRow, error) {
// FindQSOsForUpload returns FULL QSO rows eligible for upload to a service, so
// the QSL Manager shows the same rich, column-pickable table as Recent QSOs.
func (a *App) FindQSOsForUpload(service, sentStatus string) ([]qso.QSO, error) {
if a.qso == nil {
return nil, fmt.Errorf("db not initialized")
}
@@ -5634,7 +5702,7 @@ func (a *App) FindQSOsForUpload(service, sentStatus string) ([]qso.UploadRow, er
if col == "" {
return nil, fmt.Errorf("unknown service %q", service)
}
return a.qso.ListForUpload(a.ctx, col, strings.ToUpper(strings.TrimSpace(sentStatus)))
return a.qso.ListForUploadFull(a.ctx, col, strings.ToUpper(strings.TrimSpace(sentStatus)))
}
// UploadQSOsManual uploads the given QSO ids to a service on demand
@@ -7363,6 +7431,97 @@ func (a *App) FlexSetAudioLevel(l int) error {
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetAudioLevel(l) })
}
func (a *App) FlexSetMute(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
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 {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
@@ -7516,6 +7675,10 @@ func (a *App) reloadCAT() {
// Native Icom CI-V over the radio's USB serial port (local control).
// Same civ protocol a future network backend will reuse for remote.
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:
// Unknown backend → stop and emit a dummy state so the UI shows it.
a.cat.Stop()
+157 -33
View File
@@ -13,7 +13,7 @@ import {
GetStartupStatus, CheckForUpdate,
WorkedBefore,
SetCompactMode,
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig,
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna,
GetSecretStatus, UnlockSecrets,
RefreshCtyDat, DownloadAllReferenceLists,
RotatorGoTo, RotatorStop, GetRotatorHeading,
@@ -117,7 +117,7 @@ const emptyDetails: DetailsState = {
prop_mode: '', my_rig: '', my_antenna: '',
tx_pwr: undefined,
sat_name: '', sat_mode: '',
contest_id: '', srx: undefined, stx: undefined,
contest_id: '', srx_string: '', stx_string: '',
email: '',
award_refs: '',
};
@@ -206,6 +206,24 @@ function rstCategory(mode: string): keyof RSTLists {
if (['CW', 'RTTY', 'PSK31', 'PSK63', 'PSK', 'PSK125'].includes(m)) return 'cw';
return 'phone';
}
// estimateCwMs roughly estimates how long the resolved text takes to send in CW
// at wpm: ~10 dot-units per character + ~4 extra per word gap, 1 unit = 1200/wpm
// ms (PARIS standard). Used to cap waits on the keyer's "busy" status, which can
// lag by tens of seconds over a remote/serial-over-IP link.
function estimateCwMs(resolved: string, wpm: number): number {
const w = Math.max(5, wpm || 25);
const chars = resolved.replace(/\s/g, '').length;
const spaces = (resolved.match(/\s/g) || []).length;
return ((chars * 10 + spaces * 4) * 1200) / w;
}
// exchangeFields splits a contest exchange the operator typed as free text into
// the ADIF numeric field (SRX/STX, when it's all digits) or the string field
// (SRX_STRING/STX_STRING, when it's alphanumeric like a section/zone).
function exchangeFields(raw?: string): { num?: number; str: string } {
const t = (raw || '').trim();
if (t === '') return { str: '' };
return /^\d+$/.test(t) ? { num: parseInt(t, 10), str: '' } : { str: t };
}
// rstOptions returns the valid report choices for a mode from the user's
// editable lists (Settings → Modes), with a tiny fallback before they load.
function rstOptions(mode: string, lists: RSTLists): string[] {
@@ -403,10 +421,12 @@ export default function App() {
// User changed band/mode in the entry strip → push to the rig if CAT is up.
// Both calls are fire-and-forget; CAT will reflect back via cat:state.
// When the field is LOCKED (🔒, e.g. logging an old QSO off-frequency), we do
// NOT drive the radio — the lock means "this value is decoupled from the rig".
function onBandUserChange(v: string) {
setBand(v);
noteManualEdit();
if (catState.enabled && catState.connected) {
if (catState.enabled && catState.connected && !locks.band && !locks.freq) {
const hz = qsyFreqHz(v, mode);
if (hz > 0) SetCATFrequency(hz).catch(() => {});
}
@@ -415,7 +435,7 @@ export default function App() {
setMode(v);
applyModePreset(v);
noteManualEdit();
if (catState.enabled && catState.connected) {
if (catState.enabled && catState.connected && !locks.mode) {
SetCATMode(v).catch(() => {});
}
}
@@ -470,11 +490,25 @@ export default function App() {
};
// Transient success toast (bottom-right, auto-dismiss). Used for things
// like "spot sent" where a blocking error banner would be overkill.
// Toasts are QUEUED so two firing at once don't clobber each other: each shows
// for 3s, then the next takes over (no gap). `toast` is the one on screen now.
const [toast, setToast] = useState('');
const showToast = useCallback((msg: string) => {
setToast(msg);
window.setTimeout(() => setToast((t) => (t === msg ? '' : t)), 3500);
const toastQueue = useRef<string[]>([]);
const toastTimer = useRef<number | undefined>(undefined);
const advanceToast = useCallback(() => {
const next = toastQueue.current.shift();
if (next === undefined) { toastTimer.current = undefined; setToast(''); return; }
setToast(next);
toastTimer.current = window.setTimeout(advanceToast, 3000);
}, []);
const showToast = useCallback((msg: string) => {
toastQueue.current.push(msg);
if (toastTimer.current === undefined) advanceToast(); // nothing showing → start now
}, [advanceToast]);
const dismissToast = useCallback(() => {
if (toastTimer.current !== undefined) { window.clearTimeout(toastTimer.current); toastTimer.current = undefined; }
advanceToast(); // skip to the next queued toast (or clear if none)
}, [advanceToast]);
// Error banners auto-dismiss after a few seconds (longer than toasts since
// they may be multi-line). The X button still closes them immediately.
useEffect(() => {
@@ -783,11 +817,12 @@ export default function App() {
// map ("map1"), the locator street map ("map2"), the cluster grid or the
// worked-before grid. Per-profile (stored via SetUIPref → profile-prefixed),
// 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 [mainPaneRight, setMainPaneRight] = useState<MainPaneKind>('map2');
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([
GetUIPref('mainPaneLeft').catch(() => ''),
GetUIPref('mainPaneRight').catch(() => ''),
@@ -1059,6 +1094,12 @@ export default function App() {
return () => { offUploaded(); offDone(); offEqsl(); if (t) window.clearTimeout(t); };
}, [refresh]);
// Backend-emitted toast messages (e.g. recording auto-send result/skip).
useEffect(() => {
const off = EventsOn('toast', (msg: any) => { if (msg) showToast(String(msg)); });
return () => { off(); };
}, [showToast]);
// Poll PstRotator for the live antenna heading (status bar). Cheap when the
// rotator is disabled (the backend just reads settings and returns).
useEffect(() => {
@@ -1246,12 +1287,20 @@ export default function App() {
// source behaves identically.
function handleSpotClick(s: any) {
const m = inferSpotMode(s.comment ?? '', s.freq_hz);
if (catState.connected) {
tuneRigCAT(s.freq_hz, m);
} else {
setFreqMhz((s.freq_hz / 1_000_000).toFixed(5));
if (s.band) setBand(s.band);
// Reflect the spot's freq/band in the entry strip IMMEDIATELY (optimistic),
// then tune the rig if CAT is connected. We must not wait for the CAT status
// echo to update the display: on a remote Flex link that echo lags by seconds,
// so the frequency used to only catch up once you nudged the VFO. noteManualEdit()
// freezes the CAT-driven overwrite for 1.5s so a stale in-flight status can't
// revert the optimistic value before the new freq is reported back.
if (s.freq_hz && s.freq_hz > 0) {
const mhz = (s.freq_hz / 1_000_000).toFixed(5);
setFreqMhz(mhz);
setRxFreqMhz(mhz);
noteManualEdit();
}
if (s.band) setBand(s.band);
if (catState.connected) tuneRigCAT(s.freq_hz, m);
if (m) applyModeFromSpot(m);
onCallsignInput(s.dx_call, { force: true });
applySpotPOTA((s as any).pota_ref);
@@ -1383,6 +1432,19 @@ export default function App() {
// 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,
// then subscribe to push events.
async function reloadClusterMeta() {
@@ -1441,30 +1503,38 @@ export default function App() {
// typing: only update when the field is empty, already shows this call, or
// still shows the previous broadcast (i.e. the field content is ours, not
// a different call the user typed). Returns true if it actually changed.
const applyUdpCall = (raw: string): boolean => {
// force = true means an EXPLICIT action (a panadapter/spot click, a remote
// "set call" command) that should always win, even over a call the operator
// already typed. Only WSJT-X's CONTINUOUS DX-call stream keeps the no-clobber
// guard (it re-broadcasts constantly and must not fight what you typed).
const applyUdpCall = (raw: string, force = false): boolean => {
const call = String(raw ?? '').trim();
if (!call) return false;
const upper = call.toUpperCase();
const current = callsignValRef.current.trim().toUpperCase();
const prev = lastUdpCallRef.current;
lastUdpCallRef.current = upper; // remember this broadcast either way
if (current === upper) return false; // already shown → no-op
if (current !== '' && current !== prev) return false; // user typed a different call → leave it
if (current === upper) return false; // already shown → no-op
if (!force && current !== '' && current !== prev) return false; // user typed a different call → leave it
onCallsignInput(call, { force: true }); // programmatic → always look up
return true;
};
const unsubDX = EventsOn('udp:dx_call', (p: any) => {
// Anything that isn't WSJT-X (N1MM, ADIF, a panadapter/cluster click relayed
// over UDP…) is an explicit pick → force it over an existing call.
const force = String(p?.service ?? '').toLowerCase() !== 'wsjt';
// External app moved to a new station → fresh recording for the new target.
if (applyUdpCall(p?.call)) restartRecordingForNewTarget(String(p?.call ?? ''));
if (applyUdpCall(p?.call, force)) restartRecordingForNewTarget(String(p?.call ?? ''));
});
const unsubRC = EventsOn('udp:remote_call', (raw: string) => {
if (applyUdpCall(raw)) restartRecordingForNewTarget(String(raw ?? ''));
if (applyUdpCall(raw, true)) restartRecordingForNewTarget(String(raw ?? '')); // explicit remote pick
});
// Clicked one of OpsLog's spots on the FlexRadio panadapter → fill the call
// (the radio already tuned via trigger_action=Tune, and CAT reads the freq).
// An explicit click always wins over whatever call is currently in the field.
const unsubFlexSpot = EventsOn('flex:spot_clicked', (p: any) => {
const call = String(p?.call ?? '');
if (applyUdpCall(call)) restartRecordingForNewTarget(call);
if (applyUdpCall(call, true)) restartRecordingForNewTarget(call);
});
const unsubProg = EventsOn('import:progress', (p: any) => {
setImportProgress({ processed: Number(p?.processed ?? 0), total: Number(p?.total ?? 0) });
@@ -1561,7 +1631,7 @@ export default function App() {
GRID: station.my_grid || '', COUNTRY: (station as any).my_country || '',
MY_QTH: (station as any).my_city || '', MY_RIG: details.my_rig || '', MY_ANTENNA: details.my_antenna || '',
MY_IOTA: (station as any).my_iota || '', MY_SOTA: (station as any).my_sota_ref || '',
CONT_RX: details.srx != null ? String(details.srx) : '', CONT_TX: details.stx != null ? String(details.stx) : '',
CONT_RX: details.srx_string || '', CONT_TX: details.stx_string || '',
};
let out = text.replace(/<([A-Z_]+)>/g, (_m, k) => vars[k] ?? '');
out = out.replace(/\*/g, myCall).replace(/!/g, his);
@@ -1576,15 +1646,22 @@ export default function App() {
}
async function wkSend(rawText: string) {
setWkSent('');
const resolved = resolveCW(rawText);
const doLog = /<LOGQSO>/i.test(rawText); // resolveCW strips the token (unknown var → "")
await WinkeyerSend(resolveCW(rawText)).catch((e) => setError(String(e?.message ?? e)));
await WinkeyerSend(resolved).catch((e) => setError(String(e?.message ?? e)));
// <LOGQSO> (e.g. "BK 73 TU <LOGQSO>") logs the contact AFTER the keyer has
// finished sending — wait for the busy flag to rise then fall, so the QSO
// isn't logged (and the form cleared) while the CW is still going out.
// finished sending — so the QSO isn't logged (and the form cleared) while CW
// is still going out. We'd like to wait for the busy flag to rise then fall,
// but over a remote/serial-over-IP link that status echo can lag by tens of
// seconds, which used to delay logging ~50s. So cap the wait at the ESTIMATED
// send duration (text length × WPM) plus slack: log when busy clears OR the
// estimate elapses, whichever comes first.
if (!doLog) return;
const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms));
const capMs = Math.round(estimateCwMs(resolved, wkWpm) * 1.4) + 2500; // slack for keyer buffering/lag
for (let i = 0; i < 20 && !wkBusyRef.current; i++) await sleep(50); // ≤1s for sending to start
for (let i = 0; i < 1200 && wkBusyRef.current; i++) await sleep(50); // ≤60s for it to finish
const deadline = Date.now() + capMs;
while (wkBusyRef.current && Date.now() < deadline) await sleep(50);
void save();
}
// stopAutoCall cancels any running auto-call loop.
@@ -1599,8 +1676,14 @@ export default function App() {
const m = wkMacros[i];
if (!m) break;
await wkSend(m.text);
for (let k = 0; k < 20 && !wkBusyRef.current && gen === autoCallGenRef.current; k++) await sleep(50); // ≤1s to start
for (let k = 0; k < 2400 && wkBusyRef.current && gen === autoCallGenRef.current; k++) await sleep(50); // ≤120s to finish
// Wait for the message to finish before the gap+resend. Cap the wait at the
// ESTIMATED send time (not the busy flag alone): over a remote/serial-over-IP
// link the keyer's "busy" status lags badly and stays stuck true for tens of
// seconds, which made the next CQ fire ~120s late — i.e. auto-call looked dead.
const capMs = Math.round(estimateCwMs(resolveCW(m.text), wkWpm) * 1.4) + 2500;
for (let k = 0; k < 20 && !wkBusyRef.current && gen === autoCallGenRef.current; k++) await sleep(50); // ≤1s to start
const deadline = Date.now() + capMs;
while (wkBusyRef.current && gen === autoCallGenRef.current && Date.now() < deadline) await sleep(50);
if (gen !== autoCallGenRef.current) break;
await sleep(Math.max(0, wkAutoCallSecsRef.current) * 1000); // the gap before the next call
}
@@ -1691,6 +1774,10 @@ export default function App() {
// when you first entered the call (minutes early) and won't match LoTW.
const startEqualsEnd = localStorage.getItem('opslog.startEqualsEnd') === '1';
const start = (startEqualsEnd && !locks.start) ? end : baseStart;
// Contest exchanges: a purely-numeric exchange → ADIF SRX/STX (int); an
// alphanumeric one (e.g. a section/zone) → SRX_STRING/STX_STRING.
const srxE = exchangeFields(details.srx_string);
const stxE = exchangeFields(details.stx_string);
const payload: any = {
callsign: callsign.trim().toUpperCase(),
qso_date: start.toISOString(),
@@ -1709,7 +1796,7 @@ export default function App() {
tx_pwr: details.tx_pwr,
sat_name: details.sat_name, sat_mode: details.sat_mode,
contest_id: details.contest_id,
srx: details.srx, stx: details.stx,
srx: srxE.num, stx: stxE.num, srx_string: srxE.str, stx_string: stxE.str,
email: details.email,
};
applyAwardRefs(payload, details.award_refs ?? '', awardFieldRef.current);
@@ -1905,6 +1992,11 @@ export default function App() {
setLookupBusy(true);
try {
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;
// 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
@@ -1942,6 +2034,9 @@ export default function App() {
qsl_via: d.qsl_via || (r.qsl_via ?? ''),
}));
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)
// call resolves — covers the fast CW workflow (type → Enter, no blur). If
// we're already recording a DIFFERENT call (the operator edited the
@@ -1992,8 +2087,14 @@ export default function App() {
// applyUdpCall saw current != lastUdpCall and refused every later UDP call.
if (opts?.force) lastUdpCallRef.current = v.trim().toUpperCase();
// A callsign appeared (someone answered the CQ, or a spot was clicked) →
// stop auto-calling so we don't key over the contact.
if (v.trim() !== '') stopAutoCall();
// stop auto-calling so we don't key over the contact. If a CQ was actually
// 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
// 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
@@ -2731,6 +2832,7 @@ export default function App() {
toLabel={callsign}
beamAzimuths={showBeamOnMap ? beamHeadings : []}
boomAzimuth={showBeamOnMap && ubPattern && ubPattern !== 'normal' ? boomHeading : null}
zoomSignal={mapZoomSignal}
/>
);
case 'map2':
@@ -2761,7 +2863,29 @@ export default function App() {
case 'flex':
return (
<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>
);
}
@@ -2819,7 +2943,7 @@ export default function App() {
<div className="flex items-center gap-1.5 rounded-md border border-emerald-300 bg-emerald-50 text-emerald-800 px-2.5 py-1 text-xs shadow min-w-0 animate-in fade-in">
<Satellite className="size-3.5 shrink-0" />
<span className="truncate" title={toast}>{toast}</span>
<button className="shrink-0 text-emerald-600 hover:text-emerald-800" onClick={() => setToast('')}><X className="size-3" /></button>
<button className="shrink-0 text-emerald-600 hover:text-emerald-800" onClick={dismissToast}><X className="size-3" /></button>
</div>
)}
</div>
@@ -3794,7 +3918,7 @@ export default function App() {
backend is a FlexRadio. */}
{catState.backend === 'flex' && (
<TabsContent value="flex" className="flex-1 min-h-0 p-0">
<FlexPanel />
<FlexPanel onCWSpeed={(w) => { setWkWpm(w); WinkeyerSetSpeed(w).catch(() => {}); saveWk({ wpm: w }); }} />
</TabsContent>
)}
+7 -4
View File
@@ -35,8 +35,11 @@ export interface DetailsState {
sat_name: string;
sat_mode: string;
contest_id: string;
srx?: number;
stx?: number;
// Contest exchanges as free text — most are serials (001) but some are
// alphanumeric (e.g. a section/zone like "ON4"). Stored to ADIF SRX/STX when
// purely numeric, else to SRX_STRING/STX_STRING (handled in App on save).
srx_string?: string;
stx_string?: string;
email: string;
// Award references for the contacted station (set via the Awards tab picker).
// Semicolon-delimited "AWARD@REF" entries, e.g. "POTA@FR-11553;IOTA@EU-064".
@@ -371,10 +374,10 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
<Input value={details.contest_id} onChange={(e) => onChange({ contest_id: e.target.value })} />
</Field>
<Field label="SRX">
<Input type="number" value={details.srx ?? ''} onChange={(e) => onChange({ srx: numOrUndef(e.target.value) })} />
<Input value={details.srx_string ?? ''} placeholder="rcvd exchange" onChange={(e) => onChange({ srx_string: e.target.value })} />
</Field>
<Field label="STX">
<Input type="number" value={details.stx ?? ''} onChange={(e) => onChange({ stx: numOrUndef(e.target.value) })} />
<Input value={details.stx_string ?? ''} placeholder="sent exchange" onChange={(e) => onChange({ stx_string: e.target.value })} />
</Field>
<Field label="Contacted email" span={3}>
<Input value={details.email} placeholder="[email protected]" onChange={(e) => onChange({ email: e.target.value })} />
+62 -14
View File
@@ -1,11 +1,11 @@
import { useEffect, useRef, useState } from 'react';
import { Radio, Zap, Power, AudioLines, Flame, Gauge } from 'lucide-react';
import { Radio, Zap, Power, AudioLines, Flame, Gauge, Volume2, VolumeX } from 'lucide-react';
import {
GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
FlexMox, FlexAmpOperate,
GetPGXLStatus, PGXLSetFanMode,
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel,
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit,
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
@@ -19,7 +19,9 @@ type FlexState = {
proc_enable: boolean; proc_level: number;
mon: boolean; mon_level: number; mic_level: number;
atu_status?: string; atu_memories: boolean;
rx_avail: boolean; agc_mode?: string; agc_threshold: number; audio_level: number;
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;
mode?: string;
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,
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,
rx_avail: false, agc_threshold: 0, audio_level: 0,
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,
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,
@@ -110,8 +112,11 @@ function Chip({ on, onClick, label, disabled, accent = 'emerald' }: {
}[accent];
return (
<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',
on ? onCls : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
className={cn(
// 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}
</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 hold = useRef<Record<string, number>>({});
// 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
</button>
</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 ? (
<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="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))} />
<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>
</div>
<div className="flex items-center gap-2">
@@ -357,20 +379,46 @@ export function FlexPanel() {
{/* RECEIVE */}
<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">
<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}
onChange={(v) => change('agc_mode', v, () => FlexSetAGCMode(v))} />
</div>
<div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Thresh</span>
<Slider value={st.agc_threshold} disabled={rxOff} accent="#64748b" onChange={(v) => change('agc_threshold', v, () => FlexSetAGCThreshold(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.agc_threshold}</span>
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AF</span>
<button type="button" disabled={rxOff}
title={st.mute ? 'Muted — click to unmute' : 'Mute RX audio'}
onClick={() => change('mute', !st.mute, () => FlexSetMute(!st.mute))}
className={cn('shrink-0 rounded p-1 transition-colors disabled:opacity-30',
st.mute ? 'bg-red-600 text-white' : 'text-muted-foreground hover:bg-muted')}>
{st.mute ? <VolumeX className="size-3.5" /> : <Volume2 className="size-3.5" />}
</button>
<Slider value={st.audio_level} disabled={rxOff || st.mute} accent="#16a34a" onChange={(v) => change('audio_level', v, () => FlexSetAudioLevel(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mute ? '—' : st.audio_level}</span>
</div>
<div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AF</span>
<Slider value={st.audio_level} disabled={rxOff} accent="#16a34a" onChange={(v) => change('audio_level', v, () => FlexSetAudioLevel(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.audio_level}</span>
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC-T</span>
<Slider value={st.agc_threshold} disabled={rxOff} accent="#64748b" onChange={(v) => change('agc_threshold', v, () => FlexSetAGCThreshold(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.agc_threshold}</span>
</div>
<div className="border-t border-border/60 pt-3 space-y-3">
<LevelRow label="NB" on={st.nb} disabled={rxOff} value={st.nb_level} accent="amber" sliderAccent="#d97706"
+13 -13
View File
@@ -81,10 +81,11 @@ interface WorldProps {
beamAzimuths?: number[]; // radiating heading(s) (deg) → draw a beam lobe each
beamWidth?: number; // beamwidth (deg), default 30
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.
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 worldMap = useRef<L.Map | null>(null);
const worldOverlay = useRef<L.LayerGroup | null>(null);
@@ -164,21 +165,20 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
return out;
};
for (const az of beamAzimuths) {
// Draw the lobe as a FAN of translucent great-circle radials, not a
// filled polygon: a polygon breaks badly near the poles on Mercator
// (its edges run off toward ±90° and the fill smears across the map),
// while each radial LINE stays clean. The overlapping lines read as a
// lobe — solid near the antenna, fanning out toward the front. Works
// for any azimuth, north/south included.
for (let b = az - half; b <= az + half + 0.001; b += 1.5) {
// Draw the lobe as a DENSE fan of translucent great-circle radials, not
// a filled polygon: a polygon smears badly near the poles on Mercator
// (a poleward beam degenerates into a giant triangle), while each radial
// LINE stays clean at any azimuth. A small angular step makes the lines
// overlap into a solid-looking lobe (no separate strokes), darker near
// the antenna and fanning out toward the front.
for (let b = az - half; b <= az + half + 0.001; b += 0.5) {
const line = unwrapLon([[from.lat, from.lon], ...radial(b)]);
L.polyline(line as L.LatLngExpression[], { color: '#ff2d2d', weight: 6, opacity: 0.12, 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)]);
// Dark casing under the boresight so the bright dashed line stays
// readable on any basemap (esp. dark satellite imagery). Same dashArray
// as the red line so the casing tracks each dash — otherwise the wide
// casing peeks through the gaps and the line looks bumpy.
// readable on any basemap. Same dashArray so the casing tracks each dash.
L.polyline(cl as L.LatLngExpression[], { color: '#000', weight: 4, opacity: 0.4, dashArray: '5 4', smoothFactor: 0 }).addTo(wo);
L.polyline(cl as L.LatLngExpression[], { color: '#ff2d2d', weight: 2, opacity: 0.95, dashArray: '5 4', smoothFactor: 0 })
.bindTooltip(`Beam ${Math.round(az)}°`, { permanent: false, direction: 'top' }).addTo(wo);
@@ -226,7 +226,7 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
}
setTimeout(() => { wm.invalidateSize(); }, 0);
// 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);
+58 -63
View File
@@ -10,6 +10,7 @@ import {
import { cn } from '@/lib/utils';
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign } from '../../wailsjs/go/main/App';
import { Input } from '@/components/ui/input';
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
import { EventsOn } from '../../wailsjs/runtime/runtime';
ModuleRegistry.registerModules([AllCommunityModule]);
@@ -60,6 +61,14 @@ const QSL_STATUSES = [
{ v: 'I', label: 'Ignore' },
];
// QSL routing methods for the paper-QSL "Via" dropdown (was free text).
const QSL_VIA_OPTIONS = [
{ v: '_', label: '— leave —' },
{ v: 'Bureau', label: 'Bureau' },
{ v: 'Direct', label: 'Direct' },
{ v: 'Electronic', label: 'Electronic' },
];
type LogQSO = {
id: number; qso_date: string; callsign: string; band: string; mode: string; country?: string;
qsl_sent?: string; qsl_rcvd?: string; qsl_via?: string; qsl_sent_date?: string; qsl_rcvd_date?: string;
@@ -127,13 +136,16 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
const [paperCall, setPaperCall] = useState('');
const [paperRows, setPaperRows] = useState<LogQSO[]>([]);
const [paperSel, setPaperSel] = useState<Set<number>>(new Set());
const [paperSelAllSig, setPaperSelAllSig] = useState(0); // bump → grid selects all
const [paperBusy, setPaperBusy] = useState(false);
const [paperMsg, setPaperMsg] = useState('');
const [qslRcvd, setQslRcvd] = useState('Y');
const [qslRcvd, setQslRcvd] = useState('N');
const [qslSent, setQslSent] = useState('_');
const [qslRcvdDate, setQslRcvdDate] = useState('');
const [qslSentDate, setQslSentDate] = useState('');
const [qslVia, setQslVia] = useState('');
const [qslVia, setQslVia] = useState('_');
const [qslNotes, setQslNotes] = useState('');
const [qslComment, setQslComment] = useState('');
const searchPaper = useCallback(async () => {
const c = paperCall.trim().toUpperCase();
@@ -144,14 +156,12 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
const list = (r ?? []) as LogQSO[];
setPaperRows(list);
setPaperSel(new Set(list.map((x) => x.id)));
setPaperSelAllSig((n) => n + 1); // tell the grid to select every row
} catch (e: any) { setPaperMsg(String(e?.message ?? e)); setPaperRows([]); }
finally { setPaperBusy(false); }
}, [paperCall]);
function togglePaper(id: number) {
setPaperSel((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
}
const paperAllSel = paperRows.length > 0 && paperSel.size === paperRows.length;
async function applyPaper() {
const ids = paperRows.filter((r) => paperSel.has(r.id)).map((r) => r.id);
@@ -164,7 +174,9 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
rcvd_status: qslRcvd === '_' ? '' : qslRcvd,
sent_date: ymd(qslSentDate),
rcvd_date: ymd(qslRcvdDate),
via: qslVia,
via: qslVia === '_' ? '' : qslVia,
notes: qslNotes,
comment: qslComment,
} as any);
setPaperMsg(`${n} QSO updated.`);
await searchPaper();
@@ -172,11 +184,11 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
finally { setPaperBusy(false); }
}
const [sent, setSent] = useState('R');
const [rows, setRows] = useState<UploadRow[]>([]);
// Selection lives in the (virtualized) ag-grid — it handles 25k rows smoothly.
const gridRef = useRef<AgGridReact<UploadRow>>(null);
// Full QSO rows (so the upload view uses the same rich grid as Recent QSOs).
const [rows, setRows] = useState<any[]>([]);
const [selectedCount, setSelectedCount] = useState(0);
const selectAllNext = useRef(false); // selectAll once after the next data load
const [uploadSelIds, setUploadSelIds] = useState<number[]>([]); // selected QSO ids → upload
const [uploadSelAllSig, setUploadSelAllSig] = useState(0); // bump → grid selects all
const [searching, setSearching] = useState(false);
const [error, setError] = useState('');
const [addNotFound, setAddNotFound] = useState(false);
@@ -210,15 +222,9 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
// Grid selection → just track the count; ids are read from the grid at upload.
function onUploadSelChanged() {
setSelectedCount(gridRef.current?.api?.getSelectedNodes()?.length ?? 0);
}
// After "Select required" loads new rows, select them all (the old default).
function onUploadRowsLoaded() {
if (selectAllNext.current) {
selectAllNext.current = false;
gridRef.current?.api?.selectAll();
}
function onUploadRowSelected(ids: number[]) {
setUploadSelIds(ids);
setSelectedCount(ids.length);
}
const shownConfs = useMemo(() => confirmations.filter((c) => {
@@ -236,10 +242,11 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
setError('');
try {
const r: any = await FindQSOsForUpload(service, sent === '_' ? '' : sent);
const list = (r ?? []) as UploadRow[];
selectAllNext.current = true; // pre-select everything once the grid renders
const list = (r ?? []) as any[];
setRows(list);
setUploadSelIds(list.map((x) => x.id));
setSelectedCount(list.length);
setUploadSelAllSig((n) => n + 1); // pre-select everything once the grid renders
setViewMode('upload');
setShowLog(false);
} catch (e: any) {
@@ -252,7 +259,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
}, [service, sent]);
async function upload() {
const ids = ((gridRef.current?.api?.getSelectedRows() as UploadRow[] | undefined) ?? []).map((r) => r.id);
const ids = uploadSelIds;
if (ids.length === 0) return;
setLogLines([]); setBusy(true); setLogAction('upload'); setShowLog(true);
try { await UploadQSOsManual(service, ids); }
@@ -387,32 +394,16 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
paperRows.length === 0 ? (
<div className="text-sm text-muted-foreground py-10 text-center">Search a callsign to list its QSOs, then set QSL status below.</div>
) : (
<table className="w-full text-xs border-collapse">
<thead className="sticky top-0 bg-card">
<tr className="text-left text-muted-foreground border-b border-border">
<th className="py-1.5 px-2 w-8"><Checkbox checked={paperAllSel} onCheckedChange={() => setPaperSel(paperAllSel ? new Set() : new Set(paperRows.map((r) => r.id)))} /></th>
<th className="py-1.5 px-2">Date UTC</th><th className="py-1.5 px-2">Callsign</th>
<th className="py-1.5 px-2">Band</th><th className="py-1.5 px-2">Mode</th>
<th className="py-1.5 px-2">QSL Sent</th><th className="py-1.5 px-2">QSL Rcvd</th><th className="py-1.5 px-2">Via</th>
</tr>
</thead>
<tbody>
{paperRows.map((r) => (
<tr key={r.id}
className={cn('border-b border-border/40 cursor-pointer hover:bg-accent/30', paperSel.has(r.id) && 'bg-primary/5')}
onClick={() => togglePaper(r.id)}>
<td className="py-1 px-2" onClick={(e) => e.stopPropagation()}><Checkbox checked={paperSel.has(r.id)} onCheckedChange={() => togglePaper(r.id)} /></td>
<td className="py-1 px-2 font-mono">{fmtDate(r.qso_date)}</td>
<td className="py-1 px-2 font-mono font-bold">{r.callsign}</td>
<td className="py-1 px-2">{r.band}</td>
<td className="py-1 px-2">{r.mode}</td>
<td className="py-1 px-2 font-mono">{r.qsl_sent || '—'}{r.qsl_sent_date ? ` ${fmtQslDate(r.qsl_sent_date)}` : ''}</td>
<td className="py-1 px-2 font-mono">{r.qsl_rcvd || '—'}{r.qsl_rcvd_date ? ` ${fmtQslDate(r.qsl_rcvd_date)}` : ''}</td>
<td className="py-1 px-2 text-muted-foreground truncate max-w-[160px]">{r.qsl_via}</td>
</tr>
))}
</tbody>
</table>
// Same grid as Recent QSOs (full columns + column picker). Selection
// drives which QSOs the apply-form below updates; a search selects all.
<div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
<RecentQSOsGrid
rows={paperRows as any}
total={paperRows.length}
selectAllSignal={paperSelAllSig}
onRowSelected={(ids) => setPaperSel(new Set(ids))}
/>
</div>
)
) : service === 'pota' ? (
<div className="space-y-3">
@@ -509,19 +500,12 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
) : rows.length === 0 ? (
<div className="text-sm text-muted-foreground py-10 text-center">Pick a service + sent status, then Select required.</div>
) : (
<div className="h-full w-full">
<AgGridReact<UploadRow>
ref={gridRef}
theme={qslTheme}
rowData={rows}
columnDefs={UPLOAD_COLS}
defaultColDef={{ sortable: true, resizable: true, filter: true }}
rowSelection={{ mode: 'multiRow', checkboxes: true, headerCheckbox: true }}
onSelectionChanged={onUploadSelChanged}
onRowDataUpdated={onUploadRowsLoaded}
animateRows={false}
suppressCellFocus
getRowId={(p) => String((p.data as any).id)}
<div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
<RecentQSOsGrid
rows={rows as any}
total={rows.length}
selectAllSignal={uploadSelAllSig}
onRowSelected={onUploadRowSelected}
/>
</div>
)}
@@ -552,7 +536,18 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Via</label>
<Input className="h-8 w-40" value={qslVia} onChange={(e) => setQslVia(e.target.value)} placeholder="BUREAU / DIRECT / manager" />
<Select value={qslVia} onValueChange={setQslVia}>
<SelectTrigger className="h-8 w-32"><SelectValue /></SelectTrigger>
<SelectContent>{QSL_VIA_OPTIONS.map((o) => <SelectItem key={o.v} value={o.v}>{o.label}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Notes</label>
<Input className="h-8 w-40" value={qslNotes} onChange={(e) => setQslNotes(e.target.value)} placeholder="e.g. paid 3€" />
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Comment</label>
<Input className="h-8 w-40" value={qslComment} onChange={(e) => setQslComment(e.target.value)} placeholder="comment" />
</div>
<div className="flex-1" />
{paperMsg && <span className="text-[11px] text-muted-foreground self-center">{paperMsg}</span>}
+11 -5
View File
@@ -215,6 +215,14 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
return () => window.clearTimeout(t);
}, [draft.dxcc, draft.cqz, draft.ituz, draft.cont, draft.state, draft.callsign, draft.notes, draft.band]);
// Contest exchange typed as free text → numeric SRX/STX when all-digits, else
// the SRX_STRING/STX_STRING field. Mirrors the entry strip (F5) so the field
// accepts letters (sections/zones), not just numbers.
function setExchange(which: 'srx' | 'stx', raw: string) {
const t = raw.trim();
const num = /^\d+$/.test(t) ? parseInt(t, 10) : undefined;
setDraft((d) => ({ ...d, [which]: num, [`${which}_string`]: num != null ? '' : t } as any));
}
function set<K extends keyof QSO>(key: K, value: QSO[K]) {
setDraft((d) => ({ ...d, [key]: value }));
}
@@ -561,7 +569,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
</thead>
<tbody>
{CONFIRMATIONS.map((c) => (
<tr key={c.key} className={cn('text-xs', c.key === confSel && 'bg-accent/40')}>
<tr key={c.key} className="text-xs">
<td className="font-medium pr-2 py-0.5">{c.label}</td>
<td className="w-24"><StatusCell value={val(c.sent)} /></td>
<td className="w-24">{c.rcvd ? <StatusCell value={val(c.rcvd)} /> : <span className="block text-center text-[11px] text-muted-foreground"></span>}</td>
@@ -578,10 +586,8 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
<TabsContent value="contest" className="mt-0">
<div className="grid grid-cols-6 gap-3">
<F label="Contest ID" span={2}><Input value={draft.contest_id ?? ''} onChange={(e) => set('contest_id', e.target.value)} /></F>
<F label="SRX"><Input type="number" value={draft.srx ?? ''} onChange={(e) => set('srx', intOrUndef(e.target.value) as any)} /></F>
<F label="STX"><Input type="number" value={draft.stx ?? ''} onChange={(e) => set('stx', intOrUndef(e.target.value) as any)} /></F>
<F label="SRX string" span={3}><Input value={draft.srx_string ?? ''} onChange={(e) => set('srx_string', e.target.value)} /></F>
<F label="STX string" span={3}><Input value={draft.stx_string ?? ''} onChange={(e) => set('stx_string', e.target.value)} /></F>
<F label="SRX" span={2}><Input value={draft.srx_string || (draft.srx ?? '')} placeholder="rcvd exchange" onChange={(e) => setExchange('srx', e.target.value)} /></F>
<F label="STX" span={2}><Input value={draft.stx_string || (draft.stx ?? '')} placeholder="sent exchange" onChange={(e) => setExchange('stx', e.target.value)} /></F>
<F label="Check"><Input value={draft.check ?? ''} onChange={(e) => set('check', e.target.value)} /></F>
<F label="Precedence"><Input value={draft.precedence ?? ''} onChange={(e) => set('precedence', e.target.value)} /></F>
<F label="ARRL section"><Input value={draft.arrl_sect ?? ''} onChange={(e) => set('arrl_sect', e.target.value)} /></F>
+23 -3
View File
@@ -45,6 +45,9 @@ const hamlogTheme = themeQuartz.withParams({
type Props = {
rows: QSOForm[];
total: number;
// Bump this number to programmatically select every row (e.g. after a search
// in the QSL Manager, where the default is "all selected").
selectAllSignal?: number;
onRowDoubleClicked?: (q: QSOForm) => void;
onRowSelected?: (ids: number[]) => void;
onUpdateFromCty?: (ids: number[]) => void;
@@ -157,6 +160,8 @@ export const COL_CATALOG: ColEntry[] = [
{ group: 'eQSL', label: 'eQSL rcvd date', colId: 'eqsl_rcvd_date', headerName: 'eQSL R date', field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
// App-specific: when OpsLog e-mailed its own QSL card. Distinct from eQSL.cc.
{ group: 'QSL', label: 'OpsLog QSL', colId: 'opslog_qsl_card_sent', headerName: 'OpsLog QSL', width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return (e['APP_OPSLOG_QSL_SENT'] || e['APP_OPSLOG_QSL_CARD_SENT']) ? 'Y' : 'N'; }, defaultVisible: true },
// App-specific: when the QSO's audio recording was e-mailed to the station.
{ group: 'QSL', label: 'Recording sent', colId: 'opslog_recording_sent', headerName: 'Rec sent', width: 100, cellClass: 'font-mono', valueGetter: (p) => { const e = (p.data as any)?.extras ?? {}; return e['APP_OPSLOG_RECORDING_SENT'] ? 'Y' : 'N'; }, defaultVisible: false },
// ── Uploads (online logbooks) ──
// ADIF models these as an "upload status/date" (= YOU pushed the QSO) and,
@@ -223,7 +228,7 @@ export const GROUP_ORDER = [
'Contest', 'Propagation', 'My station', 'Misc',
];
export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onDelete, awardCols }: Props) {
export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onDelete, awardCols }: Props) {
const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [menu, setMenu] = useState<QSOMenuState>(null);
@@ -249,7 +254,14 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
// Compute initial column defs: all columns defined, but those not marked
// defaultVisible start hidden. The user's saved state (loaded onGridReady)
// 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>[]>(() => {
restoringRef.current = true;
const base = COL_CATALOG.map((c) => {
const { group: _g, label: _l, defaultVisible, ...rest } = c;
return { ...rest, hide: !defaultVisible };
@@ -285,6 +297,7 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
});
}
const saveColumnState = useCallback(() => {
if (restoringRef.current) return; // ignore the events fired by a column rebuild
const state = gridRef.current?.api?.getColumnState();
if (state) saveState(COL_STATE_KEY, state);
}, []);
@@ -296,9 +309,11 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
// every rebuild so the user's choices win. No-op before the grid is ready.
useEffect(() => {
const api = gridRef.current?.api;
if (!api) return;
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]);
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
@@ -308,6 +323,11 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
const sel = (gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined) ?? [];
onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null));
}
// Select every row when the caller bumps selectAllSignal (QSL Manager search).
useEffect(() => {
if (selectAllSignal === undefined) return;
gridRef.current?.api?.selectAll();
}, [selectAllSignal]);
// ── Column picker (visibility) ──
// Drives AG Grid via setColumnsVisible(). We don't keep a parallel React
+137 -20
View File
@@ -38,6 +38,7 @@ import {
TestLoTWUpload, ListTQSLStationLocations,
ComputeStationInfo,
GetUIPref, SetUIPref,
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas,
} from '../../wailsjs/go/main/App';
import type { profile as profileModels } from '../../wailsjs/go/models';
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
@@ -132,8 +133,10 @@ const emptyProfile = (): Profile => ({
is_active: false,
sort_order: 0,
db: { backend: '', host: '', port: 3306, user: '', password: '', database: '' },
created_at: '' as any,
updated_at: '' as any,
// Server-managed timestamps — send null (NOT "") so Go's time.Time unmarshal
// 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 {
@@ -174,13 +177,27 @@ type SectionId =
| 'antenna'
| 'antgenius'
| 'pgxl'
| 'flex'
| 'audio';
type TreeNode =
| { kind: 'group'; label: string; icon?: any; defaultOpen?: boolean; children: TreeNode[] }
| { 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: 'item', label: 'Station Information', id: 'station' },
@@ -206,17 +223,10 @@ const TREE: TreeNode[] = [
],
},
{
kind: 'group', label: 'Hardware Configuration', icon: Server, defaultOpen: true, children: [
{ 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' },
],
kind: 'group', label: 'Hardware Configuration', icon: Server, defaultOpen: true, children: hardware,
},
];
];
}
// Map section id → friendly name (used in breadcrumb / placeholders).
const SECTION_LABELS: Partial<Record<SectionId, string>> = {
@@ -240,6 +250,7 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
antenna: 'UltraBeam',
antgenius: 'Antenna Genius',
pgxl: 'Power Genius',
flex: 'FlexRadio',
audio: 'Audio devices',
};
@@ -248,12 +259,13 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
interface TreeProps {
selected: SectionId;
onSelect: (id: SectionId) => void;
flexAvailable?: boolean;
}
function Tree({ selected, onSelect }: TreeProps) {
function Tree({ selected, onSelect, flexAvailable }: TreeProps) {
return (
<nav className="text-sm">
{TREE.map((node, i) => (
{buildTree(!!flexAvailable).map((node, i) => (
<TreeNodeView key={i} node={node} depth={0} selected={selected} onSelect={onSelect} />
))}
</nav>
@@ -485,14 +497,16 @@ const MAIN_PANE_OPTIONS: { value: string; label: string }[] = [
{ value: 'map2', label: 'Map — locator (street)' },
{ value: 'cluster', label: 'Cluster spots' },
{ value: 'worked', label: 'Worked before' },
{ value: 'recent', label: 'Recent QSOs' },
];
function MainViewPanes({ onChanged, flexAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean }) {
const [left, setLeft] = useState('map1');
const [right, setRight] = useState('map2');
// FlexRadio is only offered when the CAT backend is a Flex.
const options = flexAvailable
// FlexRadio is only offered when the CAT backend is a Flex. Sorted A→Z.
const options = (flexAvailable
? [...MAIN_PANE_OPTIONS, { value: 'flex', label: 'FlexRadio controls' }]
: MAIN_PANE_OPTIONS;
: [...MAIN_PANE_OPTIONS]
).sort((a, b) => a.label.localeCompare(b.label));
useEffect(() => {
const valid = (v: string) => v === 'flex' || MAIN_PANE_OPTIONS.some((o) => o.value === v);
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) {
const [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station');
const [loading, setLoading] = useState(true);
@@ -619,7 +717,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [modeDraft, setModeDraft] = useState('');
const [catCfg, setCatCfg] = useState<CATSettings>({
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',
});
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="flex">FlexRadio / SmartSDR (native)</SelectItem>
<SelectItem value="icom">Icom CI-V (USB serial)</SelectItem>
<SelectItem value="tci">TCI (Expert Electronics / SunSDR)</SelectItem>
</SelectContent>
</Select>
</div>
@@ -1865,6 +1964,23 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</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') && (
<>
<div className="space-y-1">
@@ -3846,6 +3962,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
antenna: UltrabeamPanel,
antgenius: AntGeniusPanelSettings,
pgxl: PGXLPanelSettings,
flex: () => <FlexBandAntennasPanel bands={lists.bands ?? []} />,
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">
{/* Left sidebar tree */}
<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>
{/* 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 {
AllCommunityModule, ModuleRegistry, themeQuartz,
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 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>[]>(() => {
restoringRef.current = true;
const base = COL_CATALOG.map((c) => {
const { group: _g, label: _l, defaultVisible, ...rest } = c;
return { ...rest, hide: !defaultVisible };
@@ -127,10 +132,21 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
});
}
const saveColumnState = useCallback(() => {
if (restoringRef.current) return; // ignore events fired by a column rebuild
const state = gridRef.current?.api?.getColumnState();
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 {
const col = gridRef.current?.api?.getColumn(colId);
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).
// Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.14';
export const APP_VERSION = '0.16';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+15 -1
View File
@@ -130,7 +130,7 @@ export function ExportAwards():Promise<string>;
export function FilterFields():Promise<Array<string>>;
export function FindQSOsForUpload(arg1:string,arg2:string):Promise<Array<qso.UploadRow>>;
export function FindQSOsForUpload(arg1:string,arg2:string):Promise<Array<qso.QSO>>;
export function FlexATUBypass():Promise<void>;
@@ -138,6 +138,8 @@ export function FlexATUStart():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 FlexSetAGCMode(arg1:string):Promise<void>;
@@ -174,6 +176,8 @@ export function FlexSetMon(arg1:boolean):Promise<void>;
export function FlexSetMonLevel(arg1:number):Promise<void>;
export function FlexSetMute(arg1:boolean):Promise<void>;
export function FlexSetNB(arg1:boolean):Promise<void>;
export function FlexSetNBLevel(arg1:number):Promise<void>;
@@ -188,8 +192,14 @@ export function FlexSetProcessor(arg1:boolean):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 FlexSetSplit(arg1:boolean):Promise<void>;
export function FlexSetTXAntenna(arg1:string):Promise<void>;
export function FlexSetTunePower(arg1:number):Promise<void>;
export function FlexSetVox(arg1:boolean):Promise<void>;
@@ -256,6 +266,8 @@ export function GetEmailSettings():Promise<main.EmailSettings>;
export function GetExternalServices():Promise<extsvc.ExternalServices>;
export function GetFlexBandAntennas():Promise<Record<string, main.FlexBandAnt>>;
export function GetFlexState():Promise<cat.FlexTXState>;
export function GetIcomState():Promise<cat.IcomTXState>;
@@ -520,6 +532,8 @@ export function SaveEmailSettings(arg1:main.EmailSettings):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 SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
+28
View File
@@ -242,6 +242,10 @@ export function FlexAmpOperate(arg1) {
return window['go']['main']['App']['FlexAmpOperate'](arg1);
}
export function FlexApplyBandAntenna(arg1) {
return window['go']['main']['App']['FlexApplyBandAntenna'](arg1);
}
export function FlexMox(arg1) {
return window['go']['main']['App']['FlexMox'](arg1);
}
@@ -314,6 +318,10 @@ export function FlexSetMonLevel(arg1) {
return window['go']['main']['App']['FlexSetMonLevel'](arg1);
}
export function FlexSetMute(arg1) {
return window['go']['main']['App']['FlexSetMute'](arg1);
}
export function FlexSetNB(arg1) {
return window['go']['main']['App']['FlexSetNB'](arg1);
}
@@ -342,10 +350,22 @@ export function FlexSetProcessorLevel(arg1) {
return window['go']['main']['App']['FlexSetProcessorLevel'](arg1);
}
export function FlexSetRXAntenna(arg1) {
return window['go']['main']['App']['FlexSetRXAntenna'](arg1);
}
export function 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) {
return window['go']['main']['App']['FlexSetTunePower'](arg1);
}
@@ -478,6 +498,10 @@ export function GetExternalServices() {
return window['go']['main']['App']['GetExternalServices']();
}
export function GetFlexBandAntennas() {
return window['go']['main']['App']['GetFlexBandAntennas']();
}
export function GetFlexState() {
return window['go']['main']['App']['GetFlexState']();
}
@@ -1006,6 +1030,10 @@ export function SaveExternalServices(arg1) {
return window['go']['main']['App']['SaveExternalServices'](arg1);
}
export function SaveFlexBandAntennas(arg1) {
return window['go']['main']['App']['SaveFlexBandAntennas'](arg1);
}
export function SaveListsSettings(arg1) {
return window['go']['main']['App']['SaveListsSettings'](arg1);
}
+24 -24
View File
@@ -512,9 +512,17 @@ export namespace cat {
atu_status?: string;
atu_memories: boolean;
rx_avail: boolean;
split: boolean;
rx_freq_hz?: number;
tx_freq_hz?: number;
agc_mode?: string;
agc_threshold: number;
audio_level: number;
mute: boolean;
rx_ant?: string;
tx_ant?: string;
ant_list?: string[];
tx_ant_list?: string[];
nb: boolean;
nb_level: number;
nr: boolean;
@@ -560,9 +568,17 @@ export namespace cat {
this.atu_status = source["atu_status"];
this.atu_memories = source["atu_memories"];
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_threshold = source["agc_threshold"];
this.audio_level = source["audio_level"];
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_level = source["nb_level"];
this.nr = source["nr"];
@@ -1160,6 +1176,8 @@ export namespace main {
icom_port: string;
icom_baud: number;
icom_addr: number;
tci_host: string;
tci_port: number;
poll_ms: number;
delay_ms: number;
digital_default: string;
@@ -1179,6 +1197,8 @@ export namespace main {
this.icom_port = source["icom_port"];
this.icom_baud = source["icom_baud"];
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.delay_ms = source["delay_ms"];
this.digital_default = source["digital_default"];
@@ -1562,6 +1582,8 @@ export namespace main {
sent_date: string;
rcvd_date: string;
via: string;
notes: string;
comment: string;
static createFrom(source: any = {}) {
return new QSLBulkUpdate(source);
@@ -1574,6 +1596,8 @@ export namespace main {
this.sent_date = source["sent_date"];
this.rcvd_date = source["rcvd_date"];
this.via = source["via"];
this.notes = source["notes"];
this.comment = source["comment"];
}
}
export class QSLDefaults {
@@ -2895,30 +2919,6 @@ export namespace qso {
return a;
}
}
export class UploadRow {
id: number;
qso_date: string;
callsign: string;
band: string;
mode: string;
country: string;
status: string;
static createFrom(source: any = {}) {
return new UploadRow(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.qso_date = source["qso_date"];
this.callsign = source["callsign"];
this.band = source["band"];
this.mode = source["mode"];
this.country = source["country"];
this.status = source["status"];
}
}
export class WorkedBefore {
callsign: string;
count: number;
+1 -1
View File
@@ -6,6 +6,7 @@ require (
github.com/braheezy/shine-mp3 v0.1.0
github.com/go-ole/go-ole v1.3.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/wailsapp/wails/v2 v2.11.0
github.com/wneessen/go-mail v0.7.3
@@ -22,7 +23,6 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/godbus/dbus/v5 v5.1.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/labstack/echo/v4 v4.13.3 // indirect
github.com/labstack/gommon v0.4.2 // indirect
+35 -23
View File
@@ -39,18 +39,18 @@ type Backend interface {
// and RxFreqHz is the active VFO (where they listen). When not split,
// RxFreqHz is 0 — the UI shouldn't show a redundant RX field.
type RigState struct {
Enabled bool `json:"enabled"` // user toggled CAT on
Connected bool `json:"connected"` // backend says rig is online
Backend string `json:"backend,omitempty"` // active backend name
RigNum int `json:"rig_num,omitempty"` // OmniRig slot 1 or 2 (when applicable)
Rig string `json:"rig,omitempty"` // rig model (best-effort)
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
Split bool `json:"split,omitempty"` // rig is in split mode
Mode string `json:"mode,omitempty"` // ADIF mode (SSB/CW/DATA/AM/FM/RTTY)
Band string `json:"band,omitempty"` // computed from FreqHz
Vfo string `json:"vfo,omitempty"` // "A" | "B" | "AA" | "AB" | "BA" | "BB"
Error string `json:"error,omitempty"` // last connect/poll error if any
Enabled bool `json:"enabled"` // user toggled CAT on
Connected bool `json:"connected"` // backend says rig is online
Backend string `json:"backend,omitempty"` // active backend name
RigNum int `json:"rig_num,omitempty"` // OmniRig slot 1 or 2 (when applicable)
Rig string `json:"rig,omitempty"` // rig model (best-effort)
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
Split bool `json:"split,omitempty"` // rig is in split mode
Mode string `json:"mode,omitempty"` // ADIF mode (SSB/CW/DATA/AM/FM/RTTY)
Band string `json:"band,omitempty"` // computed from FreqHz
Vfo string `json:"vfo,omitempty"` // "A" | "B" | "AA" | "AB" | "BA" | "BB"
Error string `json:"error,omitempty"` // last connect/poll error if any
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
// the radio over TCP; live meters arrive over a separate UDP stream later.)
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"`
RFPower int `json:"rf_power"`
TunePower int `json:"tune_power"`
@@ -247,16 +247,24 @@ type FlexTXState struct {
ATUStatus string `json:"atu_status,omitempty"`
ATUMemories bool `json:"atu_memories"`
// Active RX slice DSP controls.
RXAvail bool `json:"rx_avail"` // an RX slice exists
AGCMode string `json:"agc_mode,omitempty"`
AGCThreshold int `json:"agc_threshold"`
AudioLevel int `json:"audio_level"`
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"`
RXAvail bool `json:"rx_avail"` // an RX slice exists
Split bool `json:"split"` // RX/TX on separate slices
RXFreqHz int64 `json:"rx_freq_hz,omitempty"` // RX slice freq when split
TXFreqHz int64 `json:"tx_freq_hz,omitempty"` // TX slice freq when split
AGCMode string `json:"agc_mode,omitempty"`
AGCThreshold int `json:"agc_threshold"`
AudioLevel int `json:"audio_level"`
Mute bool `json:"mute"`
RXAnt string `json:"rx_ant,omitempty"` // selected RX antenna
TXAnt string `json:"tx_ant,omitempty"` // selected TX antenna
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.
Mode string `json:"mode,omitempty"` // active slice mode (CW/USB/LSB/DIGU…)
CWSpeed int `json:"cw_speed"`
@@ -312,6 +320,10 @@ type FlexController interface {
SetAGCMode(string) error
SetAGCThreshold(int) error
SetAudioLevel(int) error
SetMute(bool) error
SetRXAntenna(string) error
SetTXAntenna(string) error
SetSplit(bool) error
SetNB(bool) error
SetNBLevel(int) error
SetNR(bool) error
+150 -30
View File
@@ -33,27 +33,28 @@ type Flex struct {
model string
gotHandle bool
slices map[int]*flexSlice
tx flexTX // transmit/ATU state pushed by the radio (FlexRadio tab)
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)
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).
slices map[int]*flexSlice
tx flexTX // transmit/ATU state pushed by the radio (FlexRadio tab)
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)
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).
// Live meters streamed over UDP (VITA-49). meterMeta is the definitions
// pushed over TCP; meterVal the latest scaled values keyed by meter id.
udpConn *net.UDPConn
meterMeta map[int]meterInfo
meterVal map[int]float64
meterSub map[int]bool // ids we've already sent "sub meter <id>" for
meterLogAt time.Time // throttle for value logging
vitaSeen int // count of UDP datagrams (first few logged for diag)
meterRawLogged bool // log the first raw meter-definition status once
txRawLogged bool // log the first raw transmit status once (field-name audit)
udpConn *net.UDPConn
meterMeta map[int]meterInfo
meterVal map[int]float64
meterSub map[int]bool // ids we've already sent "sub meter <id>" for
meterLogAt time.Time // throttle for value logging
vitaSeen int // count of UDP datagrams (first few logged for diag)
meterRawLogged bool // log the first raw meter-definition status once
txRawLogged bool // log the first raw transmit status once (field-name audit)
spotsEnabled bool // push cluster spots + manage the panadapter overlay
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
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)
sentCmds map[int]string // seq → command text, so an R<seq> error names the command
@@ -73,16 +74,21 @@ type flexSlice struct {
agcMode string // off | slow | med | fast
agcThreshold int // 0-100
audioLevel int // 0-100 (RX volume)
mute bool // RX audio muted
nb bool // noise blanker
nbLevel int
nr bool // noise reduction
nr bool // noise reduction
nrLevel int
anf bool // auto notch filter
anf bool // auto notch filter
anfLevel int
apf bool // CW audio peaking filter
apf bool // CW audio peaking filter
apfLevel int
filterLo int // slice filter low cut (Hz)
filterHi int // slice filter high cut (Hz)
filterLo int // slice filter low 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
@@ -143,7 +149,7 @@ func NewFlex(host string, port int, spotsEnabled bool) *Flex {
return &Flex{
host: strings.TrimSpace(host), port: port,
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{},
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
}
@@ -183,8 +189,8 @@ func (f *Flex) Connect() error {
// Identify ourselves in SmartSDR's client list, then stream slice + transmit
// (TX/split) status. Command names per the SmartSDR TCP/IP API docs.
f.send("client program OpsLog")
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 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 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)
@@ -309,7 +315,18 @@ func (f *Flex) reader(conn net.Conn) {
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()
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.
@@ -670,6 +687,16 @@ func (f *Flex) handleStatus(payload string) {
s.agcThreshold = atoiDefault(val, s.agcThreshold)
case "audio_level":
s.audioLevel = atoiDefault(val, s.audioLevel)
case "audio_mute", "mute":
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":
s.nb = val == "1"
case "nb_level":
@@ -957,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.
// 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 {
s = strings.TrimSpace(s)
if n, err := strconv.Atoi(s); err == nil {
@@ -1023,12 +1062,18 @@ func (f *Flex) FlexState() FlexTXState {
CWSidetone: f.tx.cwSidetone,
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 {
st.RXAvail = true
st.Mode = strings.ToUpper(rx.mode)
st.AGCMode = rx.agcMode
st.AGCThreshold = rx.agcThreshold
st.AudioLevel = rx.audioLevel
st.Mute = rx.mute
st.NB = rx.nb
st.NBLevel = rx.nbLevel
st.NR = rx.nr
@@ -1039,6 +1084,13 @@ func (f *Flex) FlexState() FlexTXState {
st.APFLevel = rx.apfLevel
st.FilterLo = rx.filterLo
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 != "" {
st.AmpAvailable = true
@@ -1076,6 +1128,8 @@ func (f *Flex) sendSlice(param string, val any) error {
rx.agcThreshold = toInt(val)
case "audio_level":
rx.audioLevel = toInt(val)
case "audio_mute", "mute":
rx.mute = fmt.Sprint(val) == "1"
case "nb":
rx.nb = val == "1"
case "nb_level":
@@ -1092,6 +1146,10 @@ func (f *Flex) sendSlice(param string, val any) error {
rx.apf = val == "1"
case "apf_level":
rx.apfLevel = toInt(val)
case "rxant":
rx.rxAnt = fmt.Sprint(val)
case "txant":
rx.txAnt = fmt.Sprint(val)
}
}
f.mu.Unlock()
@@ -1126,14 +1184,76 @@ func (f *Flex) SetAGCMode(m string) error {
}
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) 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)) }
func (f *Flex) SetMute(on bool) error { return f.sendSlice("audio_mute", boolFlex(on)) }
func (f *Flex) SetRXAntenna(a string) error { return f.sendSlice("rxant", a) }
func (f *Flex) SetTXAntenna(a string) error { return f.sendSlice("txant", a) }
// SetSplit toggles 2-slice split like SmartSDR's SPLIT button. ON creates a TX
// slice at RX freq + offset (+1 kHz on CW, +5 kHz otherwise) and makes it the
// transmitter; OFF removes the extra TX slice and returns to simplex (RX slice
// transmits again).
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) ──
+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.
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
// 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
@@ -67,6 +71,7 @@ func UploadQRZ(ctx context.Context, client *http.Client, apiKey, adifRecord stri
if resp.StatusCode != http.StatusOK {
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))
}
@@ -171,6 +176,7 @@ func TestQRZ(ctx context.Context, client *http.Client, apiKey string) (string, e
}
defer resp.Body.Close()
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)))
if err != nil {
return "", fmt.Errorf("qrz: bad response: %w", err)
+25
View File
@@ -497,6 +497,31 @@ var uploadStatusCols = map[string]bool{
// ListForUpload returns QSOs whose per-service sent-status column equals
// value ("" matches blank/NULL). Used by the QSL Manager's "Select required".
// ListForUploadFull is like ListForUpload but returns FULL QSO rows so the UI
// can show the same rich, column-pickable table as Recent QSOs. column is an
// upload-status column (validated); value is the status to match ("" = not yet
// uploaded).
func (r *Repo) ListForUploadFull(ctx context.Context, column, value string) ([]QSO, error) {
if !uploadStatusCols[column] {
return nil, fmt.Errorf("invalid upload column %q", column)
}
rows, err := r.db.QueryContext(ctx,
`SELECT `+selectCols+` FROM qso WHERE COALESCE(`+column+`,'') = ? ORDER BY qso_date DESC, id DESC`, value)
if err != nil {
return nil, fmt.Errorf("list for upload: %w", err)
}
defer rows.Close()
out := make([]QSO, 0, 64)
for rows.Next() {
q, err := scanQSO(rows)
if err != nil {
return nil, err
}
out = append(out, q)
}
return out, rows.Err()
}
func (r *Repo) ListForUpload(ctx context.Context, column, value string) ([]UploadRow, error) {
if !uploadStatusCols[column] {
return nil, fmt.Errorf("invalid upload column %q", column)
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const (
// appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.14"
appVersion = "0.16"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project.