Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2712902057 | ||
|
|
9281645359 | ||
|
|
a05dd6b3a9 | ||
|
|
a2401d7fd3 | ||
|
|
053b351dab |
@@ -7426,6 +7426,83 @@ func (a *App) FlexSetMute(on bool) error {
|
|||||||
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetMute(on) })
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetMute(on) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) FlexSetRXAntenna(ant string) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetRXAntenna(ant) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) FlexSetTXAntenna(ant string) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return fmt.Errorf("cat not initialized")
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetTXAntenna(ant) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyFlexBandAnt stores the per-band RX/TX antenna map (global, machine-local).
|
||||||
|
const keyFlexBandAnt = "flex.band_antennas"
|
||||||
|
|
||||||
|
// FlexBandAnt is the antenna pair to select on a given band.
|
||||||
|
type FlexBandAnt struct {
|
||||||
|
RX string `json:"rx"`
|
||||||
|
TX string `json:"tx"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFlexBandAntennas returns the band→{rx,tx} antenna map (band key uppercased,
|
||||||
|
// e.g. "20M").
|
||||||
|
func (a *App) GetFlexBandAntennas() (map[string]FlexBandAnt, error) {
|
||||||
|
out := map[string]FlexBandAnt{}
|
||||||
|
if a.settings == nil {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
v, _ := a.settings.GetGlobal(a.ctx, keyFlexBandAnt)
|
||||||
|
if strings.TrimSpace(v) == "" {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal([]byte(v), &out)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveFlexBandAntennas persists the band→antenna map.
|
||||||
|
func (a *App) SaveFlexBandAntennas(m map[string]FlexBandAnt) error {
|
||||||
|
if a.settings == nil {
|
||||||
|
return fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(m)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return a.settings.SetGlobal(a.ctx, keyFlexBandAnt, string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlexApplyBandAntenna selects the configured RX/TX antennas for the given band
|
||||||
|
// (no-op if the backend isn't a Flex or the band has no mapping). Called on band
|
||||||
|
// changes and spot clicks so the right antennas follow the frequency.
|
||||||
|
func (a *App) FlexApplyBandAntenna(band string) error {
|
||||||
|
if a.cat == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
band = strings.ToUpper(strings.TrimSpace(band))
|
||||||
|
if band == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m, _ := a.GetFlexBandAntennas()
|
||||||
|
ant, ok := m[band]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return a.cat.FlexDo(func(fc cat.FlexController) error {
|
||||||
|
if strings.TrimSpace(ant.RX) != "" {
|
||||||
|
_ = fc.SetRXAntenna(ant.RX)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(ant.TX) != "" {
|
||||||
|
_ = fc.SetTXAntenna(ant.TX)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) FlexSetNB(on bool) error {
|
func (a *App) FlexSetNB(on bool) error {
|
||||||
if a.cat == nil {
|
if a.cat == nil {
|
||||||
return fmt.Errorf("cat not initialized")
|
return fmt.Errorf("cat not initialized")
|
||||||
|
|||||||
+46
-5
@@ -13,7 +13,7 @@ import {
|
|||||||
GetStartupStatus, CheckForUpdate,
|
GetStartupStatus, CheckForUpdate,
|
||||||
WorkedBefore,
|
WorkedBefore,
|
||||||
SetCompactMode,
|
SetCompactMode,
|
||||||
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig,
|
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna,
|
||||||
GetSecretStatus, UnlockSecrets,
|
GetSecretStatus, UnlockSecrets,
|
||||||
RefreshCtyDat, DownloadAllReferenceLists,
|
RefreshCtyDat, DownloadAllReferenceLists,
|
||||||
RotatorGoTo, RotatorStop, GetRotatorHeading,
|
RotatorGoTo, RotatorStop, GetRotatorHeading,
|
||||||
@@ -817,11 +817,11 @@ export default function App() {
|
|||||||
// map ("map1"), the locator street map ("map2"), the cluster grid or the
|
// map ("map1"), the locator street map ("map2"), the cluster grid or the
|
||||||
// worked-before grid. Per-profile (stored via SetUIPref → profile-prefixed),
|
// worked-before grid. Per-profile (stored via SetUIPref → profile-prefixed),
|
||||||
// so it's loaded async on mount and re-read on profile:changed below.
|
// so it's loaded async on mount and re-read on profile:changed below.
|
||||||
type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex';
|
type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex' | 'recent';
|
||||||
const [mainPaneLeft, setMainPaneLeft] = useState<MainPaneKind>('map1');
|
const [mainPaneLeft, setMainPaneLeft] = useState<MainPaneKind>('map1');
|
||||||
const [mainPaneRight, setMainPaneRight] = useState<MainPaneKind>('map2');
|
const [mainPaneRight, setMainPaneRight] = useState<MainPaneKind>('map2');
|
||||||
const loadMainPanes = useCallback(async () => {
|
const loadMainPanes = useCallback(async () => {
|
||||||
const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked' || v === 'flex';
|
const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked' || v === 'flex' || v === 'recent';
|
||||||
const [l, r] = await Promise.all([
|
const [l, r] = await Promise.all([
|
||||||
GetUIPref('mainPaneLeft').catch(() => ''),
|
GetUIPref('mainPaneLeft').catch(() => ''),
|
||||||
GetUIPref('mainPaneRight').catch(() => ''),
|
GetUIPref('mainPaneRight').catch(() => ''),
|
||||||
@@ -1431,6 +1431,19 @@ export default function App() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// FlexRadio: apply the per-band RX/TX antennas whenever the band changes (rig
|
||||||
|
// QSY, spot click, manual band) — so the right antennas follow the frequency.
|
||||||
|
// Skipped while the band is LOCKED (entering an old QSO off-air) and for non-Flex
|
||||||
|
// backends. The backend no-ops if the band has no configured mapping.
|
||||||
|
const lastAntBandRef = useRef('');
|
||||||
|
useEffect(() => {
|
||||||
|
if (catState.backend !== 'flex' || locks.band) return;
|
||||||
|
const b = band.trim();
|
||||||
|
if (!b || b === lastAntBandRef.current) return;
|
||||||
|
lastAntBandRef.current = b;
|
||||||
|
FlexApplyBandAntenna(b).catch(() => {});
|
||||||
|
}, [band, catState.backend, locks.band]);
|
||||||
|
|
||||||
// Cluster live wiring: hydrate per-server status + saved server list,
|
// Cluster live wiring: hydrate per-server status + saved server list,
|
||||||
// then subscribe to push events.
|
// then subscribe to push events.
|
||||||
async function reloadClusterMeta() {
|
async function reloadClusterMeta() {
|
||||||
@@ -2065,8 +2078,14 @@ export default function App() {
|
|||||||
// applyUdpCall saw current != lastUdpCall and refused every later UDP call.
|
// applyUdpCall saw current != lastUdpCall and refused every later UDP call.
|
||||||
if (opts?.force) lastUdpCallRef.current = v.trim().toUpperCase();
|
if (opts?.force) lastUdpCallRef.current = v.trim().toUpperCase();
|
||||||
// A callsign appeared (someone answered the CQ, or a spot was clicked) →
|
// A callsign appeared (someone answered the CQ, or a spot was clicked) →
|
||||||
// stop auto-calling so we don't key over the contact.
|
// stop auto-calling so we don't key over the contact. If a CQ was actually
|
||||||
if (v.trim() !== '') stopAutoCall();
|
// in flight, abort the current transmission too so it stops IMMEDIATELY
|
||||||
|
// rather than finishing the buffered call. (autoCallMacroRef flips to -1 on
|
||||||
|
// the first keystroke, so we only abort once.)
|
||||||
|
if (v.trim() !== '') {
|
||||||
|
if (autoCallMacroRef.current !== -1) WinkeyerStop().catch(() => {});
|
||||||
|
stopAutoCall();
|
||||||
|
}
|
||||||
// No-op guard: external apps (MSHV/WSJT-X) re-broadcast the same DX call
|
// No-op guard: external apps (MSHV/WSJT-X) re-broadcast the same DX call
|
||||||
// on every status packet. If it matches what's already in the entry,
|
// on every status packet. If it matches what's already in the entry,
|
||||||
// do nothing — otherwise we'd re-run the QRZ lookup, hit the cache and
|
// do nothing — otherwise we'd re-run the QRZ lookup, hit the cache and
|
||||||
@@ -2837,6 +2856,28 @@ export default function App() {
|
|||||||
<FlexPanel />
|
<FlexPanel />
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
|
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
|
||||||
FlexMox, FlexAmpOperate,
|
FlexMox, FlexAmpOperate,
|
||||||
GetPGXLStatus, PGXLSetFanMode,
|
GetPGXLStatus, PGXLSetFanMode,
|
||||||
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute,
|
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna,
|
||||||
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
|
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
|
||||||
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
|
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
|
||||||
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
|
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
|
||||||
@@ -20,6 +20,7 @@ type FlexState = {
|
|||||||
mon: boolean; mon_level: number; mic_level: number;
|
mon: boolean; mon_level: number; mic_level: number;
|
||||||
atu_status?: string; atu_memories: boolean;
|
atu_status?: string; atu_memories: boolean;
|
||||||
rx_avail: boolean; agc_mode?: string; agc_threshold: number; audio_level: number; mute: boolean;
|
rx_avail: boolean; agc_mode?: string; agc_threshold: number; audio_level: number; mute: boolean;
|
||||||
|
rx_ant?: string; tx_ant?: string; ant_list?: string[]; tx_ant_list?: string[];
|
||||||
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number;
|
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number;
|
||||||
mode?: string;
|
mode?: string;
|
||||||
cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number;
|
cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number;
|
||||||
@@ -110,8 +111,11 @@ function Chip({ on, onClick, label, disabled, accent = 'emerald' }: {
|
|||||||
}[accent];
|
}[accent];
|
||||||
return (
|
return (
|
||||||
<button type="button" onClick={onClick} disabled={disabled}
|
<button type="button" onClick={onClick} disabled={disabled}
|
||||||
className={cn('w-14 shrink-0 px-2 py-1 rounded-md text-[11px] font-bold border transition-colors disabled:opacity-30',
|
className={cn(
|
||||||
on ? onCls : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
// min width (not fixed) so a longer label like STONE keeps symmetric
|
||||||
|
// padding instead of overflowing; short labels (NB/NR/APF) stay aligned.
|
||||||
|
'min-w-[3.5rem] shrink-0 px-2.5 py-1 rounded-md text-[11px] font-bold border text-center tracking-wide transition-all disabled:opacity-30',
|
||||||
|
on ? cn(onCls, 'shadow-sm') : 'bg-card text-muted-foreground border-border hover:bg-muted hover:border-muted-foreground/30')}>
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -357,6 +361,25 @@ export function FlexPanel() {
|
|||||||
|
|
||||||
{/* RECEIVE */}
|
{/* RECEIVE */}
|
||||||
<Card icon={AudioLines} title="Receive (active slice)" accent="#0891b2">
|
<Card icon={AudioLines} title="Receive (active slice)" accent="#0891b2">
|
||||||
|
{((st.ant_list?.length ?? 0) > 0 || (st.tx_ant_list?.length ?? 0) > 0) && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Ant</span>
|
||||||
|
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
||||||
|
<span className="text-[10px] text-muted-foreground">RX</span>
|
||||||
|
<select disabled={rxOff} value={st.rx_ant ?? ''}
|
||||||
|
onChange={(e) => change('rx_ant', e.target.value, () => FlexSetRXAntenna(e.target.value))}
|
||||||
|
className="h-7 flex-1 min-w-0 rounded-md border border-input bg-background px-1.5 text-xs font-mono disabled:opacity-40">
|
||||||
|
{(st.ant_list ?? []).map((a) => <option key={a} value={a}>{a}</option>)}
|
||||||
|
</select>
|
||||||
|
<span className="text-[10px] text-muted-foreground">TX</span>
|
||||||
|
<select disabled={rxOff} value={st.tx_ant ?? ''}
|
||||||
|
onChange={(e) => change('tx_ant', e.target.value, () => FlexSetTXAntenna(e.target.value))}
|
||||||
|
className="h-7 flex-1 min-w-0 rounded-md border border-input bg-background px-1.5 text-xs font-mono disabled:opacity-40">
|
||||||
|
{((st.tx_ant_list?.length ? st.tx_ant_list : st.ant_list) ?? []).map((a) => <option key={a} value={a}>{a}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC</span>
|
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC</span>
|
||||||
<Segmented value={(st.agc_mode || 'med').toLowerCase()} options={AGC} disabled={rxOff}
|
<Segmented value={(st.agc_mode || 'med').toLowerCase()} options={AGC} disabled={rxOff}
|
||||||
|
|||||||
@@ -164,15 +164,16 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
|||||||
return out;
|
return out;
|
||||||
};
|
};
|
||||||
for (const az of beamAzimuths) {
|
for (const az of beamAzimuths) {
|
||||||
// Draw the lobe as a single FILLED shape that HUGS the great-circle
|
// Draw the lobe as a DENSE fan of translucent great-circle radials, not
|
||||||
// curve: bound it by the two edge radials (az ± half-beamwidth), each a
|
// a filled polygon: a polygon smears badly near the poles on Mercator
|
||||||
// great-circle line, joined by a short far cap. Filling between the two
|
// (a poleward beam degenerates into a giant triangle), while each radial
|
||||||
// curved edges follows the arc instead of making a straight triangle.
|
// LINE stays clean at any azimuth. A small angular step makes the lines
|
||||||
const edgeL = radial(az - half);
|
// overlap into a solid-looking lobe (no separate strokes), darker near
|
||||||
const edgeR = radial(az + half);
|
// the antenna and fanning out toward the front.
|
||||||
const lobe = [[from.lat, from.lon], ...edgeL, ...edgeR.slice().reverse()] as [number, number][];
|
for (let b = az - half; b <= az + half + 0.001; b += 0.5) {
|
||||||
L.polygon(unwrapLon(lobe) as L.LatLngExpression[],
|
const line = unwrapLon([[from.lat, from.lon], ...radial(b)]);
|
||||||
{ stroke: false, fillColor: '#ff2d2d', fillOpacity: 0.18, smoothFactor: 0 }).addTo(wo);
|
L.polyline(line as L.LatLngExpression[], { color: '#ff2d2d', weight: 6, opacity: 0.10, smoothFactor: 0 }).addTo(wo);
|
||||||
|
}
|
||||||
|
|
||||||
const cl = unwrapLon([[from.lat, from.lon], ...radial(az)]);
|
const cl = unwrapLon([[from.lat, from.lon], ...radial(az)]);
|
||||||
// Dark casing under the boresight so the bright dashed line stays
|
// Dark casing under the boresight so the bright dashed line stays
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
TestLoTWUpload, ListTQSLStationLocations,
|
TestLoTWUpload, ListTQSLStationLocations,
|
||||||
ComputeStationInfo,
|
ComputeStationInfo,
|
||||||
GetUIPref, SetUIPref,
|
GetUIPref, SetUIPref,
|
||||||
|
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
import type { profile as profileModels } from '../../wailsjs/go/models';
|
import type { profile as profileModels } from '../../wailsjs/go/models';
|
||||||
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||||
@@ -174,13 +175,27 @@ type SectionId =
|
|||||||
| 'antenna'
|
| 'antenna'
|
||||||
| 'antgenius'
|
| 'antgenius'
|
||||||
| 'pgxl'
|
| 'pgxl'
|
||||||
|
| 'flex'
|
||||||
| 'audio';
|
| 'audio';
|
||||||
|
|
||||||
type TreeNode =
|
type TreeNode =
|
||||||
| { kind: 'group'; label: string; icon?: any; defaultOpen?: boolean; children: TreeNode[] }
|
| { kind: 'group'; label: string; icon?: any; defaultOpen?: boolean; children: TreeNode[] }
|
||||||
| { kind: 'item'; label: string; id: SectionId; disabled?: boolean };
|
| { kind: 'item'; label: string; id: SectionId; disabled?: boolean };
|
||||||
|
|
||||||
const TREE: TreeNode[] = [
|
// buildTree returns the settings sidebar. The FlexRadio item only appears when
|
||||||
|
// the active CAT backend is a Flex (per-band antenna config is Flex-specific).
|
||||||
|
function buildTree(flexAvailable: boolean): TreeNode[] {
|
||||||
|
const hardware: TreeNode[] = [
|
||||||
|
{ kind: 'item', label: 'CAT interface', id: 'cat' },
|
||||||
|
{ kind: 'item', label: 'PstRotator', id: 'rotator' },
|
||||||
|
{ kind: 'item', label: 'CW Keyer', id: 'winkeyer' },
|
||||||
|
{ kind: 'item', label: 'UltraBeam', id: 'antenna' },
|
||||||
|
{ kind: 'item', label: 'Antenna Genius', id: 'antgenius' },
|
||||||
|
{ kind: 'item', label: 'Power Genius', id: 'pgxl' },
|
||||||
|
...(flexAvailable ? [{ kind: 'item', label: 'FlexRadio', id: 'flex' } as TreeNode] : []),
|
||||||
|
{ kind: 'item', label: 'Audio devices', id: 'audio' },
|
||||||
|
];
|
||||||
|
return [
|
||||||
{
|
{
|
||||||
kind: 'group', label: 'User Configuration', icon: User, defaultOpen: true, children: [
|
kind: 'group', label: 'User Configuration', icon: User, defaultOpen: true, children: [
|
||||||
{ kind: 'item', label: 'Station Information', id: 'station' },
|
{ kind: 'item', label: 'Station Information', id: 'station' },
|
||||||
@@ -206,17 +221,10 @@ const TREE: TreeNode[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
kind: 'group', label: 'Hardware Configuration', icon: Server, defaultOpen: true, children: [
|
kind: 'group', label: 'Hardware Configuration', icon: Server, defaultOpen: true, children: hardware,
|
||||||
{ kind: 'item', label: 'CAT interface', id: 'cat' },
|
|
||||||
{ kind: 'item', label: 'PstRotator', id: 'rotator' },
|
|
||||||
{ kind: 'item', label: 'CW Keyer', id: 'winkeyer' },
|
|
||||||
{ kind: 'item', label: 'UltraBeam', id: 'antenna' },
|
|
||||||
{ kind: 'item', label: 'Antenna Genius', id: 'antgenius' },
|
|
||||||
{ kind: 'item', label: 'Power Genius', id: 'pgxl' },
|
|
||||||
{ kind: 'item', label: 'Audio devices', id: 'audio' },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
}
|
||||||
|
|
||||||
// Map section id → friendly name (used in breadcrumb / placeholders).
|
// Map section id → friendly name (used in breadcrumb / placeholders).
|
||||||
const SECTION_LABELS: Partial<Record<SectionId, string>> = {
|
const SECTION_LABELS: Partial<Record<SectionId, string>> = {
|
||||||
@@ -240,6 +248,7 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
|
|||||||
antenna: 'UltraBeam',
|
antenna: 'UltraBeam',
|
||||||
antgenius: 'Antenna Genius',
|
antgenius: 'Antenna Genius',
|
||||||
pgxl: 'Power Genius',
|
pgxl: 'Power Genius',
|
||||||
|
flex: 'FlexRadio',
|
||||||
audio: 'Audio devices',
|
audio: 'Audio devices',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -248,12 +257,13 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
|
|||||||
interface TreeProps {
|
interface TreeProps {
|
||||||
selected: SectionId;
|
selected: SectionId;
|
||||||
onSelect: (id: SectionId) => void;
|
onSelect: (id: SectionId) => void;
|
||||||
|
flexAvailable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Tree({ selected, onSelect }: TreeProps) {
|
function Tree({ selected, onSelect, flexAvailable }: TreeProps) {
|
||||||
return (
|
return (
|
||||||
<nav className="text-sm">
|
<nav className="text-sm">
|
||||||
{TREE.map((node, i) => (
|
{buildTree(!!flexAvailable).map((node, i) => (
|
||||||
<TreeNodeView key={i} node={node} depth={0} selected={selected} onSelect={onSelect} />
|
<TreeNodeView key={i} node={node} depth={0} selected={selected} onSelect={onSelect} />
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -485,14 +495,16 @@ const MAIN_PANE_OPTIONS: { value: string; label: string }[] = [
|
|||||||
{ value: 'map2', label: 'Map — locator (street)' },
|
{ value: 'map2', label: 'Map — locator (street)' },
|
||||||
{ value: 'cluster', label: 'Cluster spots' },
|
{ value: 'cluster', label: 'Cluster spots' },
|
||||||
{ value: 'worked', label: 'Worked before' },
|
{ value: 'worked', label: 'Worked before' },
|
||||||
|
{ value: 'recent', label: 'Recent QSOs' },
|
||||||
];
|
];
|
||||||
function MainViewPanes({ onChanged, flexAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean }) {
|
function MainViewPanes({ onChanged, flexAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean }) {
|
||||||
const [left, setLeft] = useState('map1');
|
const [left, setLeft] = useState('map1');
|
||||||
const [right, setRight] = useState('map2');
|
const [right, setRight] = useState('map2');
|
||||||
// FlexRadio is only offered when the CAT backend is a Flex.
|
// FlexRadio is only offered when the CAT backend is a Flex. Sorted A→Z.
|
||||||
const options = flexAvailable
|
const options = (flexAvailable
|
||||||
? [...MAIN_PANE_OPTIONS, { value: 'flex', label: 'FlexRadio controls' }]
|
? [...MAIN_PANE_OPTIONS, { value: 'flex', label: 'FlexRadio controls' }]
|
||||||
: MAIN_PANE_OPTIONS;
|
: [...MAIN_PANE_OPTIONS]
|
||||||
|
).sort((a, b) => a.label.localeCompare(b.label));
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const valid = (v: string) => v === 'flex' || MAIN_PANE_OPTIONS.some((o) => o.value === v);
|
const valid = (v: string) => v === 'flex' || MAIN_PANE_OPTIONS.some((o) => o.value === v);
|
||||||
Promise.all([GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => '')])
|
Promise.all([GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => '')])
|
||||||
@@ -583,6 +595,90 @@ function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FlexBandAntennasPanel — pick the RX/TX antenna per band. Applied automatically
|
||||||
|
// when the band changes (frequency change / spot click). Antennas come live from
|
||||||
|
// the connected FlexRadio; bands come from Lists → Bands.
|
||||||
|
function FlexBandAntennasPanel({ bands }: { bands: string[] }) {
|
||||||
|
const [rxList, setRxList] = useState<string[]>([]);
|
||||||
|
const [txList, setTxList] = useState<string[]>([]);
|
||||||
|
const [map, setMap] = useState<Record<string, { rx: string; tx: string }>>({});
|
||||||
|
const [msg, setMsg] = useState('');
|
||||||
|
useEffect(() => {
|
||||||
|
GetFlexState().then((s: any) => {
|
||||||
|
setRxList((s?.ant_list ?? []) as string[]);
|
||||||
|
setTxList(((s?.tx_ant_list?.length ? s.tx_ant_list : s?.ant_list) ?? []) as string[]);
|
||||||
|
}).catch(() => {});
|
||||||
|
GetFlexBandAntennas().then((m: any) => setMap(m ?? {})).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
const set = (band: string, side: 'rx' | 'tx', v: string) => {
|
||||||
|
const key = band.toUpperCase();
|
||||||
|
setMap((m) => {
|
||||||
|
const cur = m[key] ?? { rx: '', tx: '' };
|
||||||
|
const next = { ...m, [key]: { ...cur, [side]: v } };
|
||||||
|
SaveFlexBandAntennas(next as any)
|
||||||
|
.then(() => { setMsg('Saved'); window.setTimeout(() => setMsg(''), 1200); })
|
||||||
|
.catch((e: any) => setMsg(String(e?.message ?? e)));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold text-foreground">FlexRadio — per-band antennas</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Choose the RX and TX antenna for each band. They're applied automatically when the band
|
||||||
|
changes (frequency change or clicking a spot).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{rxList.length === 0 && (
|
||||||
|
<div className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-md px-3 py-2 max-w-xl">
|
||||||
|
No antennas reported yet — make sure the FlexRadio is connected (CAT interface), then reopen this panel.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{bands.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground">No bands configured — add them in Lists → Bands.</p>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border border-border overflow-hidden max-w-xl">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted/40 text-muted-foreground text-xs">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-3 py-1.5 font-semibold">Band</th>
|
||||||
|
<th className="text-left px-3 py-1.5 font-semibold">RX antenna</th>
|
||||||
|
<th className="text-left px-3 py-1.5 font-semibold">TX antenna</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{bands.map((b) => {
|
||||||
|
const e = map[b.toUpperCase()] ?? { rx: '', tx: '' };
|
||||||
|
return (
|
||||||
|
<tr key={b} className="border-t border-border/50">
|
||||||
|
<td className="px-3 py-1.5 font-mono font-semibold">{b}</td>
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
<select value={e.rx ?? ''} onChange={(ev) => set(b, 'rx', ev.target.value)}
|
||||||
|
className="h-8 w-32 rounded-md border border-input bg-background px-1.5 text-xs font-mono">
|
||||||
|
<option value="">— none —</option>
|
||||||
|
{rxList.map((a) => <option key={a} value={a}>{a}</option>)}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
<select value={e.tx ?? ''} onChange={(ev) => set(b, 'tx', ev.target.value)}
|
||||||
|
className="h-8 w-32 rounded-md border border-input bg-background px-1.5 text-xs font-mono">
|
||||||
|
<option value="">— none —</option>
|
||||||
|
{txList.map((a) => <option key={a} value={a}>{a}</option>)}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{msg && <span className="text-[11px] text-muted-foreground">{msg}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable }: Props) {
|
export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable }: Props) {
|
||||||
const [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station');
|
const [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -3846,6 +3942,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
antenna: UltrabeamPanel,
|
antenna: UltrabeamPanel,
|
||||||
antgenius: AntGeniusPanelSettings,
|
antgenius: AntGeniusPanelSettings,
|
||||||
pgxl: PGXLPanelSettings,
|
pgxl: PGXLPanelSettings,
|
||||||
|
flex: () => <FlexBandAntennasPanel bands={lists.bands ?? []} />,
|
||||||
audio: AudioPanel,
|
audio: AudioPanel,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3863,7 +3960,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<div className="grid grid-cols-[320px_1fr] min-h-0 overflow-hidden">
|
<div className="grid grid-cols-[320px_1fr] min-h-0 overflow-hidden">
|
||||||
{/* Left sidebar tree */}
|
{/* Left sidebar tree */}
|
||||||
<aside className="border-r border-border bg-muted/30 overflow-y-auto p-2">
|
<aside className="border-r border-border bg-muted/30 overflow-y-auto p-2">
|
||||||
<Tree selected={selected} onSelect={setSelected} />
|
<Tree selected={selected} onSelect={setSelected} flexAvailable={flexAvailable} />
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Right content pane */}
|
{/* Right content pane */}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Single source of truth for the app version shown in the UI (header + About).
|
// Single source of truth for the app version shown in the UI (header + About).
|
||||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||||
export const APP_VERSION = '0.14.1';
|
export const APP_VERSION = '0.15';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+10
@@ -138,6 +138,8 @@ export function FlexATUStart():Promise<void>;
|
|||||||
|
|
||||||
export function FlexAmpOperate(arg1:boolean):Promise<void>;
|
export function FlexAmpOperate(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexApplyBandAntenna(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function FlexMox(arg1:boolean):Promise<void>;
|
export function FlexMox(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetAGCMode(arg1:string):Promise<void>;
|
export function FlexSetAGCMode(arg1:string):Promise<void>;
|
||||||
@@ -190,8 +192,12 @@ export function FlexSetProcessor(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function FlexSetProcessorLevel(arg1:number):Promise<void>;
|
export function FlexSetProcessorLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetRXAntenna(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetSidetoneLevel(arg1:number):Promise<void>;
|
export function FlexSetSidetoneLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetTXAntenna(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetTunePower(arg1:number):Promise<void>;
|
export function FlexSetTunePower(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetVox(arg1:boolean):Promise<void>;
|
export function FlexSetVox(arg1:boolean):Promise<void>;
|
||||||
@@ -258,6 +264,8 @@ export function GetEmailSettings():Promise<main.EmailSettings>;
|
|||||||
|
|
||||||
export function GetExternalServices():Promise<extsvc.ExternalServices>;
|
export function GetExternalServices():Promise<extsvc.ExternalServices>;
|
||||||
|
|
||||||
|
export function GetFlexBandAntennas():Promise<Record<string, main.FlexBandAnt>>;
|
||||||
|
|
||||||
export function GetFlexState():Promise<cat.FlexTXState>;
|
export function GetFlexState():Promise<cat.FlexTXState>;
|
||||||
|
|
||||||
export function GetIcomState():Promise<cat.IcomTXState>;
|
export function GetIcomState():Promise<cat.IcomTXState>;
|
||||||
@@ -522,6 +530,8 @@ export function SaveEmailSettings(arg1:main.EmailSettings):Promise<void>;
|
|||||||
|
|
||||||
export function SaveExternalServices(arg1:extsvc.ExternalServices):Promise<void>;
|
export function SaveExternalServices(arg1:extsvc.ExternalServices):Promise<void>;
|
||||||
|
|
||||||
|
export function SaveFlexBandAntennas(arg1:Record<string, main.FlexBandAnt>):Promise<void>;
|
||||||
|
|
||||||
export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
|
export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
|
export function SaveLookupSettings(arg1:main.LookupSettings):Promise<void>;
|
||||||
|
|||||||
@@ -242,6 +242,10 @@ export function FlexAmpOperate(arg1) {
|
|||||||
return window['go']['main']['App']['FlexAmpOperate'](arg1);
|
return window['go']['main']['App']['FlexAmpOperate'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexApplyBandAntenna(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexApplyBandAntenna'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexMox(arg1) {
|
export function FlexMox(arg1) {
|
||||||
return window['go']['main']['App']['FlexMox'](arg1);
|
return window['go']['main']['App']['FlexMox'](arg1);
|
||||||
}
|
}
|
||||||
@@ -346,10 +350,18 @@ export function FlexSetProcessorLevel(arg1) {
|
|||||||
return window['go']['main']['App']['FlexSetProcessorLevel'](arg1);
|
return window['go']['main']['App']['FlexSetProcessorLevel'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSetRXAntenna(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetRXAntenna'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetSidetoneLevel(arg1) {
|
export function FlexSetSidetoneLevel(arg1) {
|
||||||
return window['go']['main']['App']['FlexSetSidetoneLevel'](arg1);
|
return window['go']['main']['App']['FlexSetSidetoneLevel'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSetTXAntenna(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetTXAntenna'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetTunePower(arg1) {
|
export function FlexSetTunePower(arg1) {
|
||||||
return window['go']['main']['App']['FlexSetTunePower'](arg1);
|
return window['go']['main']['App']['FlexSetTunePower'](arg1);
|
||||||
}
|
}
|
||||||
@@ -482,6 +494,10 @@ export function GetExternalServices() {
|
|||||||
return window['go']['main']['App']['GetExternalServices']();
|
return window['go']['main']['App']['GetExternalServices']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetFlexBandAntennas() {
|
||||||
|
return window['go']['main']['App']['GetFlexBandAntennas']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetFlexState() {
|
export function GetFlexState() {
|
||||||
return window['go']['main']['App']['GetFlexState']();
|
return window['go']['main']['App']['GetFlexState']();
|
||||||
}
|
}
|
||||||
@@ -1010,6 +1026,10 @@ export function SaveExternalServices(arg1) {
|
|||||||
return window['go']['main']['App']['SaveExternalServices'](arg1);
|
return window['go']['main']['App']['SaveExternalServices'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveFlexBandAntennas(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveFlexBandAntennas'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveListsSettings(arg1) {
|
export function SaveListsSettings(arg1) {
|
||||||
return window['go']['main']['App']['SaveListsSettings'](arg1);
|
return window['go']['main']['App']['SaveListsSettings'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -516,6 +516,10 @@ export namespace cat {
|
|||||||
agc_threshold: number;
|
agc_threshold: number;
|
||||||
audio_level: number;
|
audio_level: number;
|
||||||
mute: boolean;
|
mute: boolean;
|
||||||
|
rx_ant?: string;
|
||||||
|
tx_ant?: string;
|
||||||
|
ant_list?: string[];
|
||||||
|
tx_ant_list?: string[];
|
||||||
nb: boolean;
|
nb: boolean;
|
||||||
nb_level: number;
|
nb_level: number;
|
||||||
nr: boolean;
|
nr: boolean;
|
||||||
@@ -565,6 +569,10 @@ export namespace cat {
|
|||||||
this.agc_threshold = source["agc_threshold"];
|
this.agc_threshold = source["agc_threshold"];
|
||||||
this.audio_level = source["audio_level"];
|
this.audio_level = source["audio_level"];
|
||||||
this.mute = source["mute"];
|
this.mute = source["mute"];
|
||||||
|
this.rx_ant = source["rx_ant"];
|
||||||
|
this.tx_ant = source["tx_ant"];
|
||||||
|
this.ant_list = source["ant_list"];
|
||||||
|
this.tx_ant_list = source["tx_ant_list"];
|
||||||
this.nb = source["nb"];
|
this.nb = source["nb"];
|
||||||
this.nb_level = source["nb_level"];
|
this.nb_level = source["nb_level"];
|
||||||
this.nr = source["nr"];
|
this.nr = source["nr"];
|
||||||
|
|||||||
+30
-24
@@ -39,18 +39,18 @@ type Backend interface {
|
|||||||
// and RxFreqHz is the active VFO (where they listen). When not split,
|
// and RxFreqHz is the active VFO (where they listen). When not split,
|
||||||
// RxFreqHz is 0 — the UI shouldn't show a redundant RX field.
|
// RxFreqHz is 0 — the UI shouldn't show a redundant RX field.
|
||||||
type RigState struct {
|
type RigState struct {
|
||||||
Enabled bool `json:"enabled"` // user toggled CAT on
|
Enabled bool `json:"enabled"` // user toggled CAT on
|
||||||
Connected bool `json:"connected"` // backend says rig is online
|
Connected bool `json:"connected"` // backend says rig is online
|
||||||
Backend string `json:"backend,omitempty"` // active backend name
|
Backend string `json:"backend,omitempty"` // active backend name
|
||||||
RigNum int `json:"rig_num,omitempty"` // OmniRig slot 1 or 2 (when applicable)
|
RigNum int `json:"rig_num,omitempty"` // OmniRig slot 1 or 2 (when applicable)
|
||||||
Rig string `json:"rig,omitempty"` // rig model (best-effort)
|
Rig string `json:"rig,omitempty"` // rig model (best-effort)
|
||||||
FreqHz int64 `json:"freq_hz,omitempty"` // TX freq (= active VFO when not split)
|
FreqHz int64 `json:"freq_hz,omitempty"` // TX freq (= active VFO when not split)
|
||||||
RxFreqHz int64 `json:"freq_rx_hz,omitempty"` // RX freq, only set when Split
|
RxFreqHz int64 `json:"freq_rx_hz,omitempty"` // RX freq, only set when Split
|
||||||
Split bool `json:"split,omitempty"` // rig is in split mode
|
Split bool `json:"split,omitempty"` // rig is in split mode
|
||||||
Mode string `json:"mode,omitempty"` // ADIF mode (SSB/CW/DATA/AM/FM/RTTY)
|
Mode string `json:"mode,omitempty"` // ADIF mode (SSB/CW/DATA/AM/FM/RTTY)
|
||||||
Band string `json:"band,omitempty"` // computed from FreqHz
|
Band string `json:"band,omitempty"` // computed from FreqHz
|
||||||
Vfo string `json:"vfo,omitempty"` // "A" | "B" | "AA" | "AB" | "BA" | "BB"
|
Vfo string `json:"vfo,omitempty"` // "A" | "B" | "AA" | "AB" | "BA" | "BB"
|
||||||
Error string `json:"error,omitempty"` // last connect/poll error if any
|
Error string `json:"error,omitempty"` // last connect/poll error if any
|
||||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,7 +230,7 @@ func (m *Manager) SendSpot(s SpotInfo) {
|
|||||||
// FlexRadio control tab. Levels are 0-100. (Phase 1: controls + state pushed by
|
// FlexRadio control tab. Levels are 0-100. (Phase 1: controls + state pushed by
|
||||||
// the radio over TCP; live meters arrive over a separate UDP stream later.)
|
// the radio over TCP; live meters arrive over a separate UDP stream later.)
|
||||||
type FlexTXState struct {
|
type FlexTXState struct {
|
||||||
Available bool `json:"available"` // backend is Flex and handshaked
|
Available bool `json:"available"` // backend is Flex and handshaked
|
||||||
Model string `json:"model,omitempty"`
|
Model string `json:"model,omitempty"`
|
||||||
RFPower int `json:"rf_power"`
|
RFPower int `json:"rf_power"`
|
||||||
TunePower int `json:"tune_power"`
|
TunePower int `json:"tune_power"`
|
||||||
@@ -247,17 +247,21 @@ type FlexTXState struct {
|
|||||||
ATUStatus string `json:"atu_status,omitempty"`
|
ATUStatus string `json:"atu_status,omitempty"`
|
||||||
ATUMemories bool `json:"atu_memories"`
|
ATUMemories bool `json:"atu_memories"`
|
||||||
// Active RX slice DSP controls.
|
// Active RX slice DSP controls.
|
||||||
RXAvail bool `json:"rx_avail"` // an RX slice exists
|
RXAvail bool `json:"rx_avail"` // an RX slice exists
|
||||||
AGCMode string `json:"agc_mode,omitempty"`
|
AGCMode string `json:"agc_mode,omitempty"`
|
||||||
AGCThreshold int `json:"agc_threshold"`
|
AGCThreshold int `json:"agc_threshold"`
|
||||||
AudioLevel int `json:"audio_level"`
|
AudioLevel int `json:"audio_level"`
|
||||||
Mute bool `json:"mute"`
|
Mute bool `json:"mute"`
|
||||||
NB bool `json:"nb"`
|
RXAnt string `json:"rx_ant,omitempty"` // selected RX antenna
|
||||||
NBLevel int `json:"nb_level"`
|
TXAnt string `json:"tx_ant,omitempty"` // selected TX antenna
|
||||||
NR bool `json:"nr"`
|
AntList []string `json:"ant_list,omitempty"` // antennas selectable for RX
|
||||||
NRLevel int `json:"nr_level"`
|
TXAntList []string `json:"tx_ant_list,omitempty"` // antennas selectable for TX
|
||||||
ANF bool `json:"anf"`
|
NB bool `json:"nb"`
|
||||||
ANFLevel int `json:"anf_level"`
|
NBLevel int `json:"nb_level"`
|
||||||
|
NR bool `json:"nr"`
|
||||||
|
NRLevel int `json:"nr_level"`
|
||||||
|
ANF bool `json:"anf"`
|
||||||
|
ANFLevel int `json:"anf_level"`
|
||||||
// CW / mode-specific controls.
|
// CW / mode-specific controls.
|
||||||
Mode string `json:"mode,omitempty"` // active slice mode (CW/USB/LSB/DIGU…)
|
Mode string `json:"mode,omitempty"` // active slice mode (CW/USB/LSB/DIGU…)
|
||||||
CWSpeed int `json:"cw_speed"`
|
CWSpeed int `json:"cw_speed"`
|
||||||
@@ -314,6 +318,8 @@ type FlexController interface {
|
|||||||
SetAGCThreshold(int) error
|
SetAGCThreshold(int) error
|
||||||
SetAudioLevel(int) error
|
SetAudioLevel(int) error
|
||||||
SetMute(bool) error
|
SetMute(bool) error
|
||||||
|
SetRXAntenna(string) error
|
||||||
|
SetTXAntenna(string) error
|
||||||
SetNB(bool) error
|
SetNB(bool) error
|
||||||
SetNBLevel(int) error
|
SetNBLevel(int) error
|
||||||
SetNR(bool) error
|
SetNR(bool) error
|
||||||
|
|||||||
+58
-21
@@ -33,23 +33,23 @@ type Flex struct {
|
|||||||
model string
|
model string
|
||||||
gotHandle bool
|
gotHandle bool
|
||||||
|
|
||||||
slices map[int]*flexSlice
|
slices map[int]*flexSlice
|
||||||
tx flexTX // transmit/ATU state pushed by the radio (FlexRadio tab)
|
tx flexTX // transmit/ATU state pushed by the radio (FlexRadio tab)
|
||||||
amp flexAmp // external amplifier (PowerGenius XL) state
|
amp flexAmp // external amplifier (PowerGenius XL) state
|
||||||
txSetAt map[string]time.Time // status field → when WE last set it (ignore the radio's lagging echo briefly)
|
txSetAt map[string]time.Time // status field → when WE last set it (ignore the radio's lagging echo briefly)
|
||||||
lastStateSig string // last logged derived-state signature (log only on change)
|
lastStateSig string // last logged derived-state signature (log only on change)
|
||||||
boundClientID string // GUI client (SmartSDR) we bound to; "" until bound. Binding lets this non-GUI client receive GUI-tied data (CW pitch/speed, break-in delay, RF power).
|
boundClientID string // GUI client (SmartSDR) we bound to; "" until bound. Binding lets this non-GUI client receive GUI-tied data (CW pitch/speed, break-in delay, RF power).
|
||||||
|
|
||||||
// Live meters streamed over UDP (VITA-49). meterMeta is the definitions
|
// Live meters streamed over UDP (VITA-49). meterMeta is the definitions
|
||||||
// pushed over TCP; meterVal the latest scaled values keyed by meter id.
|
// pushed over TCP; meterVal the latest scaled values keyed by meter id.
|
||||||
udpConn *net.UDPConn
|
udpConn *net.UDPConn
|
||||||
meterMeta map[int]meterInfo
|
meterMeta map[int]meterInfo
|
||||||
meterVal map[int]float64
|
meterVal map[int]float64
|
||||||
meterSub map[int]bool // ids we've already sent "sub meter <id>" for
|
meterSub map[int]bool // ids we've already sent "sub meter <id>" for
|
||||||
meterLogAt time.Time // throttle for value logging
|
meterLogAt time.Time // throttle for value logging
|
||||||
vitaSeen int // count of UDP datagrams (first few logged for diag)
|
vitaSeen int // count of UDP datagrams (first few logged for diag)
|
||||||
meterRawLogged bool // log the first raw meter-definition status once
|
meterRawLogged bool // log the first raw meter-definition status once
|
||||||
txRawLogged bool // log the first raw transmit status once (field-name audit)
|
txRawLogged bool // log the first raw transmit status once (field-name audit)
|
||||||
|
|
||||||
spotsEnabled bool // push cluster spots + manage the panadapter overlay
|
spotsEnabled bool // push cluster spots + manage the panadapter overlay
|
||||||
spotIdx map[int]bool // panadapter spot indices currently known to the radio
|
spotIdx map[int]bool // panadapter spot indices currently known to the radio
|
||||||
@@ -76,14 +76,18 @@ type flexSlice struct {
|
|||||||
mute bool // RX audio muted
|
mute bool // RX audio muted
|
||||||
nb bool // noise blanker
|
nb bool // noise blanker
|
||||||
nbLevel int
|
nbLevel int
|
||||||
nr bool // noise reduction
|
nr bool // noise reduction
|
||||||
nrLevel int
|
nrLevel int
|
||||||
anf bool // auto notch filter
|
anf bool // auto notch filter
|
||||||
anfLevel int
|
anfLevel int
|
||||||
apf bool // CW audio peaking filter
|
apf bool // CW audio peaking filter
|
||||||
apfLevel int
|
apfLevel int
|
||||||
filterLo int // slice filter low cut (Hz)
|
filterLo int // slice filter low cut (Hz)
|
||||||
filterHi int // slice filter high cut (Hz)
|
filterHi int // slice filter high cut (Hz)
|
||||||
|
rxAnt string // selected RX antenna (e.g. ANT1, ANT2, RX_A)
|
||||||
|
txAnt string // selected TX antenna
|
||||||
|
antList []string // antennas valid for this slice (RX side)
|
||||||
|
txAntList []string // antennas valid for TX
|
||||||
}
|
}
|
||||||
|
|
||||||
// flexTX mirrors the radio's transmit/ATU/interlock objects (the SmartSDR-style
|
// flexTX mirrors the radio's transmit/ATU/interlock objects (the SmartSDR-style
|
||||||
@@ -184,8 +188,8 @@ func (f *Flex) Connect() error {
|
|||||||
// Identify ourselves in SmartSDR's client list, then stream slice + transmit
|
// Identify ourselves in SmartSDR's client list, then stream slice + transmit
|
||||||
// (TX/split) status. Command names per the SmartSDR TCP/IP API docs.
|
// (TX/split) status. Command names per the SmartSDR TCP/IP API docs.
|
||||||
f.send("client program OpsLog")
|
f.send("client program OpsLog")
|
||||||
f.send("sub slice all") // slice/receiver: freq, mode, AGC, NB/NR/ANF, audio…
|
f.send("sub slice all") // slice/receiver: freq, mode, AGC, NB/NR/ANF, audio…
|
||||||
f.send("sub tx all") // transmit: rfpower, tunepower, vox, processor, mon, mic
|
f.send("sub tx all") // transmit: rfpower, tunepower, vox, processor, mon, mic
|
||||||
f.send("sub atu all") // antenna-tuner status + memories
|
f.send("sub atu all") // antenna-tuner status + memories
|
||||||
f.send("sub amplifier all") // external amplifier (PowerGenius XL) operate/standby
|
f.send("sub amplifier all") // external amplifier (PowerGenius XL) operate/standby
|
||||||
f.send("sub radio all") // radio-wide incl. interlock (TX/RX state)
|
f.send("sub radio all") // radio-wide incl. interlock (TX/RX state)
|
||||||
@@ -673,6 +677,14 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
s.audioLevel = atoiDefault(val, s.audioLevel)
|
s.audioLevel = atoiDefault(val, s.audioLevel)
|
||||||
case "audio_mute", "mute":
|
case "audio_mute", "mute":
|
||||||
s.mute = val == "1"
|
s.mute = val == "1"
|
||||||
|
case "rxant":
|
||||||
|
s.rxAnt = val
|
||||||
|
case "txant":
|
||||||
|
s.txAnt = val
|
||||||
|
case "ant_list":
|
||||||
|
s.antList = splitCSV(val)
|
||||||
|
case "tx_ant_list":
|
||||||
|
s.txAntList = splitCSV(val)
|
||||||
case "nb":
|
case "nb":
|
||||||
s.nb = val == "1"
|
s.nb = val == "1"
|
||||||
case "nb_level":
|
case "nb_level":
|
||||||
@@ -960,6 +972,18 @@ func splitKV(kv string) (key, val string, ok bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// atoiDefault parses an int (or a float like "20.0", truncated), else def.
|
// atoiDefault parses an int (or a float like "20.0", truncated), else def.
|
||||||
|
// splitCSV splits a comma-separated antenna list (e.g. "ANT1,ANT2,RX_A") into a
|
||||||
|
// trimmed slice, dropping empties.
|
||||||
|
func splitCSV(s string) []string {
|
||||||
|
out := []string{}
|
||||||
|
for _, p := range strings.Split(s, ",") {
|
||||||
|
if p = strings.TrimSpace(p); p != "" {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func atoiDefault(s string, def int) int {
|
func atoiDefault(s string, def int) int {
|
||||||
s = strings.TrimSpace(s)
|
s = strings.TrimSpace(s)
|
||||||
if n, err := strconv.Atoi(s); err == nil {
|
if n, err := strconv.Atoi(s); err == nil {
|
||||||
@@ -1043,6 +1067,13 @@ func (f *Flex) FlexState() FlexTXState {
|
|||||||
st.APFLevel = rx.apfLevel
|
st.APFLevel = rx.apfLevel
|
||||||
st.FilterLo = rx.filterLo
|
st.FilterLo = rx.filterLo
|
||||||
st.FilterHi = rx.filterHi
|
st.FilterHi = rx.filterHi
|
||||||
|
st.RXAnt = rx.rxAnt
|
||||||
|
st.TXAnt = rx.txAnt
|
||||||
|
st.AntList = rx.antList
|
||||||
|
st.TXAntList = rx.txAntList
|
||||||
|
if len(st.TXAntList) == 0 {
|
||||||
|
st.TXAntList = rx.antList // many configs share one antenna list
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if f.amp.handle != "" {
|
if f.amp.handle != "" {
|
||||||
st.AmpAvailable = true
|
st.AmpAvailable = true
|
||||||
@@ -1098,6 +1129,10 @@ func (f *Flex) sendSlice(param string, val any) error {
|
|||||||
rx.apf = val == "1"
|
rx.apf = val == "1"
|
||||||
case "apf_level":
|
case "apf_level":
|
||||||
rx.apfLevel = toInt(val)
|
rx.apfLevel = toInt(val)
|
||||||
|
case "rxant":
|
||||||
|
rx.rxAnt = fmt.Sprint(val)
|
||||||
|
case "txant":
|
||||||
|
rx.txAnt = fmt.Sprint(val)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
@@ -1133,6 +1168,8 @@ func (f *Flex) SetAGCMode(m string) error {
|
|||||||
func (f *Flex) SetAGCThreshold(l int) error { return f.sendSlice("agc_threshold", clampLevel(l)) }
|
func (f *Flex) SetAGCThreshold(l int) error { return f.sendSlice("agc_threshold", clampLevel(l)) }
|
||||||
func (f *Flex) SetAudioLevel(l int) error { return f.sendSlice("audio_level", clampLevel(l)) }
|
func (f *Flex) SetAudioLevel(l int) error { return f.sendSlice("audio_level", clampLevel(l)) }
|
||||||
func (f *Flex) SetMute(on bool) error { return f.sendSlice("audio_mute", boolFlex(on)) }
|
func (f *Flex) SetMute(on bool) error { return f.sendSlice("audio_mute", boolFlex(on)) }
|
||||||
|
func (f *Flex) 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) 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) 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) SetNR(on bool) error { return f.sendSlice("nr", boolFlex(on)) }
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||||
appVersion = "0.14.1"
|
appVersion = "0.15"
|
||||||
|
|
||||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||||
// to https://us.i.posthog.com for a US project.
|
// to https://us.i.posthog.com for a US project.
|
||||||
|
|||||||
Reference in New Issue
Block a user