fix: PGXL meters/auth/fan, TCI spots+click, PTT-over-CAT, CW <LOGQSO>

- PGXL: remote AUTH password; direct-link meter fallback (VITA-first) with
  250 ms poll + peak-hold; fan mode uses bare "fanmode=" (was ignored).
- TCI: spot colour sent as decimal ARGB (ExpertSDR dropped the hex string);
  spot click handled via CLICKED_ON_SPOT / RX_CLICKED_ON_SPOT to fill the entry.
- FlexRadio: clicking an OpsLog spot on the panadapter now applies the mode too.
- Filter presets: the trash icon deletes again (Radix pointer-down intercept).
- PTT hotkey: keys over CAT when a backend is active (was hijacked by a stale
  Audio-tab RTS/DTR serial setting); AltGr keys allowed; presses logged.
- CW macro <LOGQSO>: logs after the preceding CW is sent, not on the first letter.
This commit is contained in:
2026-08-04 15:55:10 +02:00
parent b3fdd0b7fa
commit 18f9b915c5
12 changed files with 277 additions and 81 deletions
+9 -2
View File
@@ -189,13 +189,20 @@ function AmpBlock({ amp, flex, showName, t }: {
const txing = typeof flex?.transmitting === 'boolean'
? flex.transmitting
: fwd ? flexW >= 1 : /TRANSMIT/i.test(pg.state || '');
// Prefer the radio's VITA meter stream when it is actually flowing (local or
// over a VPN) — it is fast and matches SmartSDR. Fall back to the amp's OWN
// link only when the radio isn't streaming its meters (a remote link without
// the VITA path), so the reading survives but is naturally slower.
const liveW = flexW > 0 ? flexW : (pg.connected ? Number(pg.fwd_w) || 0 : 0);
const fwdW = hold('fwd', txing ? liveW : 0, txing);
const id = meter(/^ID$|current/i);
// Live drain current, for the same reason: peak_id latches like peak_w.
// Live drain current, same source preference. peak_id latches like peak_w, so
// use the instantaneous 'id' and gate it on the radio's transmit flag.
const idA = id ? Number(id.value) : (txing ? Number(pg.id) || undefined : 0);
const temp = meter(/temp/i);
const tempC = pg.temperature > 0 ? pg.temperature : (temp ? temp.value : undefined);
// VITA temp matches SmartSDR (the amp's direct link exposes a different sensor
// that can read ~10°C higher); fall back to the direct temp when no VITA.
const tempC = temp ? temp.value : (pg.temperature > 0 ? pg.temperature : undefined);
return (
<div className="h-full flex flex-col gap-1.5">
+8 -1
View File
@@ -234,7 +234,14 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
<SelectItem key={n} value={n}>
<span className="inline-flex items-center gap-2">
{n}
<Trash2 className="size-3 text-muted-foreground hover:text-destructive" onClick={(e) => { e.stopPropagation(); deletePreset(n); }} />
{/* Radix SelectItem selects on pointer-up, so an onClick here
fires too late — the menu has already loaded the preset and
closed. Intercept pointer-down (preventDefault stops Radix
claiming it) and delete there instead. */}
<Trash2
className="size-3 text-muted-foreground hover:text-destructive"
onPointerDown={(e) => { e.preventDefault(); e.stopPropagation(); deletePreset(n); }}
/>
</span>
</SelectItem>
))}
+31 -8
View File
@@ -328,7 +328,10 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
// longer and the meters visibly trail the end of a transmission, which reads
// as the app lagging rather than as a peak being held.
const peak = useRef<Record<string, { v: number; t: number }>>({});
const peakHold = (key: string, val: number, windowMs = 1000) => {
const peakHold = (key: string, val: number, windowMs = 1000, live = true) => {
// live=false throws the held value away at once (e.g. the carrier dropped),
// so the meter falls immediately instead of coasting for the window.
if (!live) { delete peak.current[key]; return val; }
const now = Date.now();
const p = peak.current[key];
if (!p || val >= p.v || now - p.t > windowMs) { peak.current[key] = { v: val, t: now }; return val; }
@@ -362,7 +365,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
let alive = true;
const tick = async () => { try { const s: any = await GetPGXLStatus(); if (alive) setPg(s || { connected: false }); } catch {} };
tick();
const id = window.setInterval(tick, 2000);
const id = window.setInterval(tick, 250); // match the amp's 250 ms poll so the card catches every envelope peak
return () => { alive = false; window.clearInterval(id); };
}, []);
@@ -1114,11 +1117,32 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
{/* Amplifier meters (FWD / ID / TEMP) — moved here from the Meters card
and shown compact so they sit with the amp controls. */}
{(() => {
// Prefer the radio's VITA amp meters when they are flowing (local or
// over a VPN) — fast, and the same values SmartSDR shows (incl. the
// right temperature sensor). Fall back to the amp's OWN link only when
// the radio isn't streaming them (a remote link without VITA).
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;
if (off || amp.length === 0) {
const P = pg as any;
if (!P.connected) return null;
// Direct-link fallback: a 500 ms poll samples the SSB envelope at
// random points, so peak-hold it (live=txing drops it the instant
// PTT releases). The LDMOS pair can pull well past 25 A → 50 A scale.
const txing = typeof st.transmitting === 'boolean' ? st.transmitting : /TRANSMIT/i.test(P.state || '');
const w = txing ? Math.round(Number(P.fwd_w) || 0) : 0;
const idA = txing ? Number(P.id) || 0 : 0;
const temp = Number(P.temperature) || 0;
return (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
<MeterBar label={t('ampw.pwr')} value={peakHold('pgw', w, 2500, txing)} unit="W" lo={0} hi={2000} accent="#dc2626" />
<MeterBar label={t('ampw.id')} value={peakHold('pgid', idA, 2500, txing)} unit="A" lo={0} hi={50} accent="#16a34a" />
<MeterBar label={t('ampw.temp')} value={temp} unit="°C" lo={0} hi={80} accent="#ea580c" />
</div>
);
}
// Power comes from the radio's meter stream and nothing else. The
// amplifier also reports a "peakfwd", and using it was a mistake
// twice over: it is a latched maximum that is never reset, and it
@@ -1133,14 +1157,13 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
}
const acc = /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
// Drain current (ID): the PGXL reports a full-scale far too small
// for this meter (~3 A), so 1.5 A pinned the bar near half. The
// value itself is fine — only the bar's range was wrong. Give it a
// fixed 25 A scale (the PGXL's LDMOS pair tops out around 20 A), and
// only override an unusable reported range, not a sane one.
// for this meter, so it pinned the bar. Give it a fixed 50 A scale
// (the LDMOS pair can pull ~45-50 A near full output), overriding an
// unusable reported range but keeping a sane one.
let lo = m.lo, hi = m.hi;
if (/amp/i.test(m.unit || '') || /^ID$|current/i.test(m.name || '')) {
lo = 0;
hi = m.hi >= 25 ? m.hi : 25;
hi = m.hi >= 50 ? m.hi : 50;
}
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={lo} hi={hi} accent={acc} />;
})}
+12
View File
@@ -3273,6 +3273,18 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
</div>
)}
{/* PowerGenius XL asks for a remote-access code when reached from
outside the LAN (banner "V… AUTH"), like the Tuner/Antenna Genius.
Blank on the LAN. */}
{isPGXL && (
<div className="space-y-1 max-w-xs">
<Label>{t('amp.password')}</Label>
<Input type="password" value={(amp as any).password ?? ''}
onChange={(e) => patchAmp(i, { password: e.target.value } as any)}
placeholder={t('amp.passwordPh')} className="font-mono" />
<p className="text-[11px] text-muted-foreground">{t('amp.passwordHint')}</p>
</div>
)}
{/* Band-follow, for any amp that takes its band from a transceiver
CAT link (ACOM and SPE both do) never PowerGenius, which is