Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8740a4ba66 | ||
|
|
8ccad7ca65 | ||
|
|
fa7df57435 | ||
|
|
812e4f05e5 | ||
|
|
6ec31b61ce | ||
|
|
93c8f6b9d3 | ||
|
|
65c22232dd | ||
|
|
edede0bc1e |
@@ -89,6 +89,9 @@ 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)
|
||||
keyCATTCISpots = "cat.tci.spots" // push cluster spots to the TCI panorama
|
||||
|
||||
// Audio (Digital Voice Keyer + QSO recorder). Machine-local hardware, so
|
||||
// global (not per-profile) like CAT/rotator. Device fields store the
|
||||
@@ -265,6 +268,9 @@ 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)
|
||||
TCISpots bool `json:"tci_spots"` // push cluster spots to the TCI panorama
|
||||
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 +614,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
|
||||
@@ -3851,7 +3858,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, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault)
|
||||
if err != nil {
|
||||
return CATSettings{}, err
|
||||
}
|
||||
@@ -3865,6 +3872,9 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
||||
IcomPort: m[keyCATIcomPort],
|
||||
IcomBaud: 115200,
|
||||
IcomAddr: 0x98, // IC-7610 default
|
||||
TCIHost: m[keyCATTCIHost],
|
||||
TCIPort: 40001,
|
||||
TCISpots: m[keyCATTCISpots] == "1",
|
||||
PollMs: 250,
|
||||
DelayMs: 0,
|
||||
DigitalDefault: m[keyCATDigitalDefault],
|
||||
@@ -3872,6 +3882,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
|
||||
}
|
||||
@@ -3930,6 +3943,10 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
||||
if s.FlexSpots {
|
||||
flexSpots = "1"
|
||||
}
|
||||
tciSpots := "0"
|
||||
if s.TCISpots {
|
||||
tciSpots = "1"
|
||||
}
|
||||
if s.DigitalDefault == "" {
|
||||
s.DigitalDefault = "FT8"
|
||||
}
|
||||
@@ -3943,6 +3960,9 @@ 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),
|
||||
keyCATTCISpots: tciSpots,
|
||||
keyCATPollMs: strconv.Itoa(s.PollMs),
|
||||
keyCATDelayMs: strconv.Itoa(s.DelayMs),
|
||||
keyCATDigitalDefault: strings.ToUpper(strings.TrimSpace(s.DigitalDefault)),
|
||||
@@ -7440,6 +7460,13 @@ func (a *App) FlexSetTXAntenna(ant string) error {
|
||||
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"
|
||||
|
||||
@@ -7629,7 +7656,7 @@ func (a *App) reloadCAT() {
|
||||
}
|
||||
a.cat.SetPollInterval(time.Duration(s.PollMs) * time.Millisecond)
|
||||
a.cat.SetCommandDelay(time.Duration(s.DelayMs) * time.Millisecond)
|
||||
a.catFlexSpots = s.Enabled && s.Backend == "flex" && s.FlexSpots
|
||||
a.catFlexSpots = s.Enabled && ((s.Backend == "flex" && s.FlexSpots) || (s.Backend == "tci" && s.TCISpots))
|
||||
if !s.Enabled {
|
||||
a.cat.Stop()
|
||||
return
|
||||
@@ -7656,6 +7683,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, s.TCISpots))
|
||||
default:
|
||||
// Unknown backend → stop and emit a dummy state so the UI shows it.
|
||||
a.cat.Stop()
|
||||
|
||||
+12
-2
@@ -818,6 +818,7 @@ export default function App() {
|
||||
// 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' | '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 () => {
|
||||
@@ -1991,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
|
||||
@@ -2028,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
|
||||
@@ -2823,6 +2832,7 @@ export default function App() {
|
||||
toLabel={callsign}
|
||||
beamAzimuths={showBeamOnMap ? beamHeadings : []}
|
||||
boomAzimuth={showBeamOnMap && ubPattern && ubPattern !== 'normal' ? boomHeading : null}
|
||||
zoomSignal={mapZoomSignal}
|
||||
/>
|
||||
);
|
||||
case 'map2':
|
||||
@@ -2853,7 +2863,7 @@ 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':
|
||||
@@ -3908,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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
|
||||
FlexMox, FlexAmpOperate,
|
||||
GetPGXLStatus, PGXLSetFanMode,
|
||||
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna,
|
||||
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit,
|
||||
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
|
||||
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
|
||||
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
|
||||
@@ -21,6 +21,7 @@ type FlexState = {
|
||||
atu_status?: string; atu_memories: boolean;
|
||||
rx_avail: boolean; agc_mode?: string; agc_threshold: number; audio_level: number; mute: boolean;
|
||||
rx_ant?: string; tx_ant?: string; ant_list?: string[]; tx_ant_list?: string[];
|
||||
split: boolean; rx_freq_hz?: number; tx_freq_hz?: number;
|
||||
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number;
|
||||
mode?: string;
|
||||
cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number;
|
||||
@@ -35,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, mute: false,
|
||||
rx_avail: false, agc_threshold: 0, audio_level: 0, mute: false, split: false,
|
||||
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, anf_level: 0,
|
||||
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,
|
||||
@@ -186,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
|
||||
@@ -306,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">
|
||||
@@ -339,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">
|
||||
|
||||
@@ -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);
|
||||
@@ -225,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);
|
||||
|
||||
|
||||
@@ -254,7 +254,14 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
||||
// 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 };
|
||||
@@ -290,6 +297,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
||||
});
|
||||
}
|
||||
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);
|
||||
}, []);
|
||||
@@ -301,9 +309,11 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
||||
// every rebuild so the user's choices win. No-op before the grid is ready.
|
||||
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>) {
|
||||
|
||||
@@ -133,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 {
|
||||
@@ -715,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, tci_spots: false, poll_ms: 250, delay_ms: 0,
|
||||
digital_default: 'FT8',
|
||||
});
|
||||
const [rotator, setRotator] = useState<RotatorSettings>({
|
||||
@@ -1892,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>
|
||||
@@ -1961,6 +1964,27 @@ 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>
|
||||
<label className="col-span-2 flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={!!catCfg.tci_spots} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, tci_spots: !!c }))} />
|
||||
Show cluster spots on the panorama <span className="text-xs text-muted-foreground">(spots from OpsLog's DX cluster appear on the SDR panadapter)</span>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
{(catCfg.backend === 'omnirig' || catCfg.backend === 'icom') && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
|
||||
@@ -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,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.15';
|
||||
export const APP_VERSION = '0.16.1';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+2
@@ -196,6 +196,8 @@ 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>;
|
||||
|
||||
@@ -358,6 +358,10 @@ 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);
|
||||
}
|
||||
|
||||
@@ -512,6 +512,9 @@ 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;
|
||||
@@ -565,6 +568,9 @@ 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"];
|
||||
@@ -1170,6 +1176,9 @@ export namespace main {
|
||||
icom_port: string;
|
||||
icom_baud: number;
|
||||
icom_addr: number;
|
||||
tci_host: string;
|
||||
tci_port: number;
|
||||
tci_spots: boolean;
|
||||
poll_ms: number;
|
||||
delay_ms: number;
|
||||
digital_default: string;
|
||||
@@ -1189,6 +1198,9 @@ 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.tci_spots = source["tci_spots"];
|
||||
this.poll_ms = source["poll_ms"];
|
||||
this.delay_ms = source["delay_ms"];
|
||||
this.digital_default = source["digital_default"];
|
||||
|
||||
@@ -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
|
||||
|
||||
+5
-1
@@ -247,7 +247,10 @@ 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
|
||||
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"`
|
||||
@@ -320,6 +323,7 @@ type FlexController interface {
|
||||
SetMute(bool) error
|
||||
SetRXAntenna(string) error
|
||||
SetTXAntenna(string) error
|
||||
SetSplit(bool) error
|
||||
SetNB(bool) error
|
||||
SetNBLevel(int) error
|
||||
SetNR(bool) error
|
||||
|
||||
+85
-9
@@ -54,6 +54,7 @@ type Flex struct {
|
||||
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
|
||||
|
||||
@@ -148,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{},
|
||||
}
|
||||
@@ -314,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.
|
||||
@@ -1050,6 +1062,11 @@ 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)
|
||||
@@ -1170,14 +1187,73 @@ func (f *Flex) SetAudioLevel(l int) error { return f.sendSlice("audio_level",
|
||||
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) }
|
||||
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)) }
|
||||
|
||||
// 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) ──
|
||||
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
//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/…)
|
||||
spotsEnabled bool // mirror cluster spots onto the TCI panorama
|
||||
|
||||
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; spots turns
|
||||
// on mirroring OpsLog's cluster spots onto the TCI panorama.
|
||||
func NewTCI(host string, port int, digitalDefault string, spots bool) *TCI {
|
||||
if port <= 0 || port > 65535 {
|
||||
port = tciDefaultPort
|
||||
}
|
||||
return &TCI{host: strings.TrimSpace(host), port: port, digitalDefault: strings.TrimSpace(digitalDefault), spotsEnabled: spots}
|
||||
}
|
||||
|
||||
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)
|
||||
if t.spotsEnabled {
|
||||
_ = t.send("spot_clear;") // drop any leftover spots from a previous session
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendSpot mirrors a cluster spot onto the TCI panorama (implements Spotter).
|
||||
// The radio replaces a spot that has the same callsign, so re-spotting updates
|
||||
// it in place. No-op when spot mirroring is disabled.
|
||||
func (t *TCI) SendSpot(s SpotInfo) error {
|
||||
if !t.spotsEnabled {
|
||||
return nil
|
||||
}
|
||||
call := strings.TrimSpace(s.Callsign)
|
||||
if call == "" || s.FreqHz <= 0 {
|
||||
return nil
|
||||
}
|
||||
color := strings.TrimSpace(s.Color)
|
||||
if color == "" {
|
||||
color = "#FFFFA500" // opaque orange default
|
||||
}
|
||||
color = "0x" + strings.TrimPrefix(color, "#")
|
||||
mode := strings.ToLower(strings.TrimSpace(s.Mode))
|
||||
// Commas/semicolons would break TCI's comma-separated argument parsing.
|
||||
text := strings.NewReplacer(",", " ", ";", " ").Replace(s.Comment)
|
||||
return t.send(fmt.Sprintf("spot:%s,%s,%d,%s,%s;", call, mode, s.FreqHz, color, text))
|
||||
}
|
||||
|
||||
// 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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||
appVersion = "0.15"
|
||||
appVersion = "0.16.1"
|
||||
|
||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||
// to https://us.i.posthog.com for a US project.
|
||||
|
||||
Reference in New Issue
Block a user