chore: release v0.19.2

This commit is contained in:
2026-07-09 16:51:57 +02:00
parent 5ae2bad549
commit 19c2de5f61
37 changed files with 1395 additions and 73 deletions
+145 -69
View File
@@ -7,6 +7,7 @@ import {
GetPGXLStatus, PGXLSetFanMode,
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice,
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
FlexSetWNB, FlexSetWNBLevel, FlexSetTXFilter, FlexSetMicProfile,
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
} from '../../wailsjs/go/main/App';
@@ -25,6 +26,8 @@ type FlexState = {
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;
wnb: boolean; wnb_level: number;
tx_filter_low: number; tx_filter_high: number; mic_profile?: string; mic_profiles?: string[];
mode?: string;
cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number;
apf: boolean; apf_level: number; filter_lo: number; filter_hi: number;
@@ -42,6 +45,7 @@ const ZERO: FlexState = {
mon: false, mon_level: 0, mic_level: 0, atu_memories: 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,
wnb: false, wnb_level: 0, tx_filter_low: 0, tx_filter_high: 0,
cw_speed: 25, cw_pitch: 600, cw_break_in_delay: 30, cw_sidetone: true, cw_mon_level: 0,
apf: false, apf_level: 0, filter_lo: 0, filter_hi: 0,
amp_available: false, amp_operate: false,
@@ -143,27 +147,28 @@ function LevelRow({ label, on, onToggle, value, onLevel, disabled, accent, slide
// `display` overrides the numeric readout; `segColor` colours segments by their
// 0..1 position (zones); the top ~18% light red by default (overload/peak).
const METER_SEGMENTS = 26;
function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, display, segColor, onClick, title }: {
function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, display, segColor, onClick, title, compact }: {
label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; extra?: string; display?: string;
segColor?: (frac: number) => string; onClick?: () => void; title?: string;
segColor?: (frac: number) => string; onClick?: () => void; title?: string; compact?: boolean;
}) {
const span = hi - lo;
const pct = span > 0 ? Math.max(0, Math.min(100, ((value - lo) / span) * 100)) : 0;
const lit = Math.round((pct / 100) * METER_SEGMENTS);
return (
<div onClick={onClick} title={title}
className={cn('rounded-lg border border-border/70 px-2.5 py-2 bg-gradient-to-b from-card to-muted/40 shadow-sm min-w-0',
className={cn('rounded-lg border border-border/70 bg-gradient-to-b from-card to-muted/40 shadow-sm min-w-0',
compact ? 'px-2 py-1' : 'px-2.5 py-2',
onClick && 'cursor-pointer hover:border-primary/60 hover:from-muted/40')}>
<div className="flex items-baseline justify-between gap-1 mb-1.5">
<div className={cn('flex items-baseline justify-between gap-1', compact ? 'mb-1' : 'mb-1.5')}>
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground truncate">{label}</span>
<span className="text-sm font-mono font-bold tabular-nums whitespace-nowrap text-foreground/90">
<span className={cn('font-mono font-bold tabular-nums whitespace-nowrap text-foreground/90', compact ? 'text-xs' : 'text-sm')}>
{display !== undefined ? display : (
<>{Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(1)}<span className="text-muted-foreground text-[10px] ml-0.5">{unit}</span></>
)}
</span>
</div>
{/* LED bar — recessed track + gradient segments for a cleaner instrument look. */}
<div className="flex gap-[2px] h-3 items-stretch rounded-[3px] bg-black/10 p-[2px]">
<div className={cn('flex gap-[2px] items-stretch rounded-[3px] bg-black/10 p-[2px]', compact ? 'h-2' : 'h-3')}>
{Array.from({ length: METER_SEGMENTS }).map((_, i) => {
const on = i < lit;
const frac = i / METER_SEGMENTS;
@@ -176,7 +181,7 @@ function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, displ
);
})}
</div>
{extra && <div className="text-[10px] text-muted-foreground/70 mt-1 text-right font-mono">{extra}</div>}
{extra && !compact && <div className="text-[10px] text-muted-foreground/70 mt-1 text-right font-mono">{extra}</div>}
</div>
);
}
@@ -262,6 +267,13 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
? SSB_BW.reduce((best, bw) => (Math.abs(bw - curBW) < Math.abs(best - curBW) ? bw : best), SSB_BW[0])
: -1;
// Compact header readouts for PA voltage + temperature (numbers, not bars) — the
// user wanted these in the header to save vertical space in the Meters card.
const hdrMeters = st.meters || [];
const hdrFind = (name: string) => hdrMeters.find((m) => (m.name || '').toUpperCase().includes(name) && !(m.src || '').toUpperCase().includes('AMP'));
const voltM = hdrFind('13.8') || hdrFind('VOLT');
const patempM = hdrFind('PATEMP') || hdrFind('PA TEMP');
return (
<div className="h-full min-h-0 overflow-auto bg-background">
<div className="max-w-5xl mx-auto p-3 space-y-3">
@@ -274,6 +286,23 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
<span className="text-[11px] text-slate-400">{t('flxp.smartsdrRemote')}</span>
</div>
<div className="flex-1" />
{/* Compact PA voltage + temperature readouts (numbers) to save space. */}
{!off && (voltM || patempM) && (
<div className="flex items-center gap-4 mr-1 font-mono tabular-nums">
{voltM && (
<span className="flex flex-col items-end leading-none">
<span className="text-[9px] uppercase tracking-wider text-slate-400 font-sans">{t('flxp.voltage')}</span>
<span className="text-sm font-bold">{voltM.value.toFixed(1)}<span className="text-[9px] text-slate-400 ml-0.5">V</span></span>
</span>
)}
{patempM && (
<span className="flex flex-col items-end leading-none">
<span className="text-[9px] uppercase tracking-wider text-slate-400 font-sans">{t('flxp.paTemp')}</span>
<span className={cn('text-sm font-bold', patempM.value >= 70 ? 'text-red-400' : patempM.value >= 55 ? 'text-amber-400' : 'text-slate-100')}>{patempM.value.toFixed(0)}<span className="text-[9px] text-slate-400 ml-0.5">°C</span></span>
</span>
)}
</div>
)}
<span className={cn('inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-extrabold tracking-wider',
!st.available ? 'bg-muted text-muted-foreground'
: st.transmitting ? 'bg-danger text-danger-foreground shadow-[0_0_16px] shadow-danger/60' : 'bg-success text-success-foreground')}>
@@ -315,6 +344,58 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
</div>
)}
{/* Live meters (UDP VITA-49 stream) — placed right under the slices. */}
<Card icon={Gauge} title={t('flxp.meters')}>
{(() => {
const meters = st.meters || [];
if (off || meters.length === 0) {
return <p className="text-[11px] text-muted-foreground text-center py-2">{t('flxp.noMeters')}</p>;
}
const isDbm = (m?: Meter) => !!m && /dbm/i.test(m.unit || '');
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
// Radio meters (exclude the amplifier's, which we show separately).
const radio = (name: string) => meters.find((m) =>
(m.name || '').toUpperCase().includes(name) && !(m.src || '').toUpperCase().includes('AMP'));
const sig = radio('LEVEL') || radio('SIGNAL');
const fwd = radio('FWDPWR');
const swr = radio('SWR');
// Mic input level + speech-compression (voltage & PA temp live in the
// header now). All already in the meter stream.
const mic = meters.find((m) => /MIC/i.test(m.name || '') && !(m.src || '').toUpperCase().includes('AMP'));
const comp = radio('COMPPEAK') || radio('COMP');
// S-meter: dBm → S-units (S9 = -73 dBm on HF, 6 dB per unit).
const sUnit = (dbm: number) => {
const sv = (dbm + 127) / 6; // S0 = -127 dBm
if (sv >= 9) {
const over = Math.max(0, Math.round(dbm + 73)); // dB over S9
return { display: over > 0 ? `S9+${over}` : 'S9', bar: sv, s: 9, over };
}
return { display: `S${Math.max(0, Math.round(sv))}`, bar: Math.max(0, sv), s: Math.max(0, sv), over: 0 };
};
const cur = [
sig && (() => { const dbm = peakHold('s', sig.value); const s = sUnit(dbm); return (
<MeterBar key="s" label="S-METER" value={s.bar} lo={0} hi={19} accent="#16a34a" display={s.display} extra={`${dbm.toFixed(1)} dBm`}
title={onReportRST ? t('rst.clickToFill') : undefined}
onClick={onReportRST ? () => onReportRST(sMeterRST(s.s, s.over, st.mode)) : undefined}
segColor={(fr) => { const sval = fr * 19; return sval < 9 ? '#16a34a' : sval < 12.33 ? '#f59e0b' : '#dc2626'; }} />
); })(),
fwd && (() => { const w = peakHold('p', isDbm(fwd) ? dbmToW(fwd.value) : fwd.value); return (
<MeterBar key="p" label="PWR" unit="W" lo={0} hi={120} accent="#dc2626"
value={w} extra={isDbm(fwd) ? `${fwd.value.toFixed(1)} dBm` : undefined} />
); })(),
swr && <MeterBar key="w" label="SWR" value={peakHold('w', swr.value)} unit="" lo={1} hi={3} accent="#d97706" />,
// Mic input level: fixed -40..+10 dB scale, RED from 0 dB up (overdrive).
mic && <MeterBar key="mic" label="MIC" value={peakHold('mic', mic.value)} unit={mic.unit || 'dB'} lo={-40} hi={10} accent="#16a34a"
segColor={(fr) => (fr >= 0.8 ? '#dc2626' : fr >= 0.7 ? '#f59e0b' : '#16a34a')} />,
// Speech compression (dB of gain reduction).
comp && <MeterBar key="comp" label="COMP" value={peakHold('comp', comp.value)} unit={comp.unit || 'dB'} lo={0} hi={(comp.hi && comp.hi > 0) ? comp.hi : 25} accent="#0891b2" />,
].filter(Boolean);
return (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">{cur}</div>
);
})()}
</Card>
{off && (
<div className="text-center text-sm text-muted-foreground py-6">
{t('flxp.waiting')}
@@ -391,6 +472,39 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
<Slider value={st.mic_level} disabled={off} accent="#2563eb" onChange={(v) => change('mic_level', v, () => FlexSetMic(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mic_level}</span>
</div>
{/* TX audio bandwidth — low/high cut (Hz). Typed locally, committed on
blur/Enter so we don't spam a command per keystroke; the hold guard
keeps the poll from clobbering the field mid-edit. */}
<div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">{t('flxp.txFilter')}</span>
{(() => {
const editTX = (patch: Partial<FlexState>) => { hold.current['tx_filter_low'] = Date.now() + 8000; hold.current['tx_filter_high'] = Date.now() + 8000; setSt((p) => ({ ...p, ...patch })); };
const commitTX = () => { hold.current['tx_filter_low'] = Date.now() + 900; hold.current['tx_filter_high'] = Date.now() + 900; FlexSetTXFilter(st.tx_filter_low || 0, st.tx_filter_high || 0).catch(() => {}); };
const inp = 'w-[70px] h-8 rounded-md border border-input bg-background px-2 text-xs font-mono tabular-nums disabled:opacity-40';
return (<>
<input type="number" step={50} min={0} disabled={off} value={st.tx_filter_low || 0} className={inp}
onChange={(e) => editTX({ tx_filter_low: parseInt(e.target.value, 10) || 0 })}
onBlur={commitTX} onKeyDown={(e) => { if (e.key === 'Enter') { (e.target as HTMLInputElement).blur(); } }} />
<span className="text-muted-foreground text-xs"></span>
<input type="number" step={50} min={0} disabled={off} value={st.tx_filter_high || 0} className={inp}
onChange={(e) => editTX({ tx_filter_high: parseInt(e.target.value, 10) || 0 })}
onBlur={commitTX} onKeyDown={(e) => { if (e.key === 'Enter') { (e.target as HTMLInputElement).blur(); } }} />
<span className="text-[10px] text-muted-foreground/70 font-mono">Hz</span>
</>);
})()}
</div>
{/* Mic profile — SmartSDR named mic-EQ/processor presets. */}
{!!st.mic_profiles && st.mic_profiles.length > 0 && (
<div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">{t('flxp.micProfile')}</span>
<select disabled={off} value={st.mic_profile || ''}
onChange={(e) => { const v = e.target.value; hold.current['mic_profile'] = Date.now() + 2000; setSt((p) => ({ ...p, mic_profile: v })); FlexSetMicProfile(v).catch(() => {}); }}
className="flex-1 min-w-0 h-8 rounded-md border border-input bg-background px-2 text-xs">
{!st.mic_profiles.includes(st.mic_profile || '') && <option value=""></option>}
{st.mic_profiles.map((p) => <option key={p} value={p}>{p}</option>)}
</select>
</div>
)}
</div>
) : (
/* CW keyer controls (replace VOX/PROC/MIC when the slice is in CW). */
@@ -464,6 +578,10 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
<LevelRow label="NB" on={st.nb} disabled={rxOff} value={st.nb_level} accent="amber" sliderAccent="#d97706"
onToggle={() => change('nb', !st.nb, () => FlexSetNB(!st.nb))}
onLevel={(v) => change('nb_level', v, () => FlexSetNBLevel(v))} />
{/* WNB — wideband noise blanker (the extra hardware NR the Flex has). */}
<LevelRow label="WNB" on={st.wnb} disabled={rxOff} value={st.wnb_level} accent="amber" sliderAccent="#f59e0b"
onToggle={() => change('wnb', !st.wnb, () => FlexSetWNB(!st.wnb))}
onLevel={(v) => change('wnb_level', v, () => FlexSetWNBLevel(v))} />
<LevelRow label="NR" on={st.nr} disabled={rxOff} value={st.nr_level} accent="cyan" sliderAccent="#0891b2"
onToggle={() => change('nr', !st.nr, () => FlexSetNR(!st.nr))}
onLevel={(v) => change('nr_level', v, () => FlexSetNRLevel(v))} />
@@ -560,71 +678,29 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">{t('flxp.fault')}: {st.amp_fault}</span>
)}
</div>
{/* Amplifier meters (FWD / ID / TEMP) — moved here from the Meters card
and shown compact so they sit with the amp controls. */}
{(() => {
const meters = st.meters || [];
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
if (off || amp.length === 0) return null;
return (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
{amp.map((m) => {
if (/fwd|pwr/i.test(m.name || '') && /dbm/i.test(m.unit || '')) {
return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" />;
}
const acc = /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={m.lo} hi={m.hi} accent={acc} />;
})}
</div>
);
})()}
</Card>
)}
{/* Live meters (UDP VITA-49 stream) */}
<Card icon={Gauge} title={t('flxp.meters')}>
{(() => {
const meters = st.meters || [];
if (off || meters.length === 0) {
return <p className="text-[11px] text-muted-foreground text-center py-2">{t('flxp.noMeters')}</p>;
}
const isDbm = (m?: Meter) => !!m && /dbm/i.test(m.unit || '');
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
// Radio meters (exclude the amplifier's, which we show separately).
const radio = (name: string) => meters.find((m) =>
(m.name || '').toUpperCase().includes(name) && !(m.src || '').toUpperCase().includes('AMP'));
const sig = radio('LEVEL') || radio('SIGNAL');
const fwd = radio('FWDPWR');
const swr = radio('SWR');
const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
const accentFor = (m: Meter) => /swr/i.test(`${m.unit}${m.name}`) ? '#dc2626'
: /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c'
: /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
// S-meter: dBm → S-units (S9 = -73 dBm on HF, 6 dB per unit).
const sUnit = (dbm: number) => {
const sv = (dbm + 127) / 6; // S0 = -127 dBm
if (sv >= 9) {
const over = Math.max(0, Math.round(dbm + 73)); // dB over S9
return { display: over > 0 ? `S9+${over}` : 'S9', bar: sv, s: 9, over };
}
return { display: `S${Math.max(0, Math.round(sv))}`, bar: Math.max(0, sv), s: Math.max(0, sv), over: 0 };
};
const cur = [
sig && (() => { const dbm = peakHold('s', sig.value); const s = sUnit(dbm); return (
<MeterBar key="s" label="S-METER" value={s.bar} lo={0} hi={19} accent="#16a34a" display={s.display} extra={`${dbm.toFixed(1)} dBm`}
title={onReportRST ? t('rst.clickToFill') : undefined}
onClick={onReportRST ? () => onReportRST(sMeterRST(s.s, s.over, st.mode)) : undefined}
segColor={(fr) => { const sval = fr * 19; return sval < 9 ? '#16a34a' : sval < 12.33 ? '#f59e0b' : '#dc2626'; }} />
); })(),
fwd && (() => { const w = peakHold('p', isDbm(fwd) ? dbmToW(fwd.value) : fwd.value); return (
<MeterBar key="p" label="PWR" unit="W" lo={0} hi={120} accent="#dc2626"
value={w} extra={isDbm(fwd) ? `${fwd.value.toFixed(1)} dBm` : undefined} />
); })(),
swr && <MeterBar key="w" label="SWR" value={peakHold('w', swr.value)} unit="" lo={1} hi={3} accent="#d97706" />,
].filter(Boolean);
return (
<>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">{cur}</div>
{amp.length > 0 && (
<div className="border-t border-border/60 pt-2 mt-1">
<div className="text-[10px] font-bold tracking-wider text-muted-foreground mb-1.5">{t('flxp.amplifierHdr')}</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{amp.map((m) => {
if (/fwd|pwr/i.test(m.name || '') && isDbm(m)) {
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" extra={`${m.value.toFixed(1)} dBm`} />;
}
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={m.lo} hi={m.hi} accent={accentFor(m)} />;
})}
</div>
</div>
)}
</>
);
})()}
</Card>
</div>
</div>
);