chore: release v0.22.9
This commit is contained in:
+82
-4
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Activity, AlertCircle, Antenna, Bell, Check, CheckCircle2, ChevronDown, Clock, CloudOff, Compass, Database, Ear, Eraser, Gauge, Hash, Loader2, Lock,
|
||||
Activity, AlertCircle, Antenna, Bell, Check, CheckCircle2, ChevronDown, Clock, CloudOff, Compass, Database, Ear, Eraser, Flame, Gauge, Hash, Loader2, Lock,
|
||||
Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, SpellCheck, Square, Terminal, Trash2, Unlock, X, Zap,
|
||||
} from 'lucide-react';
|
||||
|
||||
@@ -77,6 +77,7 @@ import { IcomPanel } from '@/components/IcomPanel';
|
||||
import { YaesuPanel } from '@/components/YaesuPanel';
|
||||
import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel';
|
||||
import { TunerGeniusPanel, type TGStatus } from '@/components/TunerGeniusPanel';
|
||||
import { AmpWidget } from '@/components/AmpWidget';
|
||||
import { ScpPanel, type ScpResult } from '@/components/ScpPanel';
|
||||
import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder';
|
||||
import { AwardsPanel } from '@/components/AwardsPanel';
|
||||
@@ -570,6 +571,16 @@ export default function App() {
|
||||
// PGXL's OPERATE state comes from the radio when it reports the amp.
|
||||
const [ampSts, setAmpSts] = useState<any[]>([]);
|
||||
const [flexAmp, setFlexAmp] = useState<any>(null);
|
||||
// Amplifier widget: whether it is shown, and which amp — "all", or one amp id.
|
||||
// Several amps side by side make a wide widget, so an operator running two
|
||||
// picks the one he watches while transmitting. Declared here because the poll
|
||||
// below runs faster while the widget is open.
|
||||
const [showAmpWidget, setShowAmpWidget] = useState(() => localStorage.getItem('opslog.showAmpWidget') !== '0');
|
||||
const [ampWidgetSel, setAmpWidgetSel] = useState(() => localStorage.getItem('opslog.ampSel.widget') || 'all');
|
||||
// Poll fast only while the amplifier widget is open: its meters must track TX
|
||||
// the way the FlexRadio panel's do (400 ms), or the power reads seconds behind
|
||||
// the transmission and looks like the app is struggling. The status-bar chips
|
||||
// alone need nothing like that rate.
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const tick = () => Promise.all([
|
||||
@@ -577,9 +588,9 @@ export default function App() {
|
||||
GetFlexState().catch(() => null),
|
||||
]).then(([l, fx]: any[]) => { if (alive) { setAmpSts((l ?? []) as any[]); setFlexAmp(fx); } });
|
||||
tick();
|
||||
const id = window.setInterval(tick, 2000);
|
||||
const id = window.setInterval(tick, showAmpWidget ? 400 : 2000);
|
||||
return () => { alive = false; window.clearInterval(id); };
|
||||
}, []);
|
||||
}, [showAmpWidget]);
|
||||
// Multi-op "who's on air" widget: every operator's live status from the shared
|
||||
// MySQL logbook (freq/mode/version). Only polled on a MySQL logbook.
|
||||
type LiveStation = { operator: string; station: string; freq_hz: number; band: string; mode: string; online: boolean; version: string; age_sec: number };
|
||||
@@ -4849,6 +4860,60 @@ export default function App() {
|
||||
{showTuner && tgStatus.connected && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success" />}
|
||||
</button>
|
||||
)}
|
||||
{/* Amplifier widget. With one amp the icon is a plain toggle; with
|
||||
several it also carries a dropdown to choose which one the widget
|
||||
shows (or All), since two amps stacked make a tall widget. */}
|
||||
{ampSts.length > 0 && (
|
||||
<div className="relative inline-flex">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { const v = !showAmpWidget; setShowAmpWidget(v); writeUiPref('opslog.showAmpWidget', v ? '1' : '0'); }}
|
||||
title={showAmpWidget ? t('ampw.hideHint') : t('ampw.showHint')}
|
||||
className={cn(
|
||||
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
|
||||
ampSts.length > 1 && 'rounded-r-none border-r-0',
|
||||
showAmpWidget ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
|
||||
: 'border-border text-muted-foreground hover:bg-muted',
|
||||
)}
|
||||
>
|
||||
<Flame className="size-4" />
|
||||
{showAmpWidget && ampSts.some((a: any) => a.spe?.connected || a.acom?.connected || a.pgxl?.connected || flexAmp?.amp_available) && (
|
||||
<span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success" />
|
||||
)}
|
||||
</button>
|
||||
{ampSts.length > 1 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" title={t('ampw.pick')}
|
||||
className={cn('inline-flex items-center justify-center h-7 w-4 rounded-md rounded-l-none border transition-colors',
|
||||
showAmpWidget ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
|
||||
: 'border-border text-muted-foreground hover:bg-muted')}>
|
||||
<ChevronDown className="size-3" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-44">
|
||||
{[{ id: 'all', name: t('ampw.all') }, ...ampSts.map((a: any) => ({ id: a.id, name: a.name || a.id }))].map((o) => (
|
||||
<DropdownMenuItem key={o.id}
|
||||
onSelect={() => {
|
||||
setAmpWidgetSel(o.id);
|
||||
writeUiPref('opslog.ampSel.widget', o.id);
|
||||
// Picking an amp means "show me this one" — opening the
|
||||
// widget too saves a second click on a hidden one.
|
||||
if (!showAmpWidget) { setShowAmpWidget(true); writeUiPref('opslog.showAmpWidget', '1'); }
|
||||
}}>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
{ampWidgetSel === o.id
|
||||
? <Check className="size-3.5 shrink-0 text-primary" />
|
||||
: <span className="size-3.5 shrink-0" />}
|
||||
<span className="truncate">{o.name}</span>
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{scpEnabled && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -5365,7 +5430,7 @@ export default function App() {
|
||||
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the
|
||||
Digital Voice Keyer take this slot when enabled (Log4OM-style);
|
||||
otherwise it shows the QRZ profile photo. */}
|
||||
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled) || (showTuner && tgEnabled) || (showScp && scpEnabled) || (showLiveStations && dbConn?.backend === 'mysql')) && (
|
||||
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled) || (showTuner && tgEnabled) || (showAmpWidget && ampSts.length > 0) || (showScp && scpEnabled) || (showLiveStations && dbConn?.backend === 'mysql')) && (
|
||||
// relative + absolute inner (like the F1-F5 panel): a taller widget (e.g.
|
||||
// the DVK with Auto CQ) can't grow the row — the row height stays set by
|
||||
// the entry strip and each widget fills that height, scrolling inside.
|
||||
@@ -5452,6 +5517,19 @@ export default function App() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showAmpWidget && ampSts.length > 0 && (
|
||||
// One column per amplifier shown, so two amps stand side by side
|
||||
// rather than making the widget twice as tall as the dock row.
|
||||
<div className="shrink-0 min-h-0"
|
||||
style={{ width: `${Math.min(ampWidgetSel === 'all' ? ampSts.length : 1, 3) * 250 + 20}px` }}>
|
||||
<AmpWidget
|
||||
amps={ampSts}
|
||||
flex={flexAmp}
|
||||
sel={ampWidgetSel}
|
||||
onClose={() => { setShowAmpWidget(false); writeUiPref('opslog.showAmpWidget', '0'); }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showTuner && tgEnabled && (
|
||||
<div className="w-[230px] shrink-0 min-h-0">
|
||||
<TunerGeniusPanel
|
||||
|
||||
@@ -51,11 +51,14 @@ type Amp = { id: string; name: string; type?: string; spe?: any; acom?: any; pgx
|
||||
|
||||
export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string, v?: any) => string }) {
|
||||
// Peak-hold so the jittery VITA-49 meters read steadily (own ref per card).
|
||||
// One second: the same window as the docked widget, so the two never show
|
||||
// different figures for the same amplifier, and neither trails the end of a
|
||||
// transmission long enough to look like lag.
|
||||
const peak = useRef<Record<string, { v: number; t: number }>>({});
|
||||
const peakHold = (key: string, val: number) => {
|
||||
const now = Date.now();
|
||||
const p = peak.current[key];
|
||||
if (!p || val >= p.v || now - p.t > 2000) { peak.current[key] = { v: val, t: now }; return val; }
|
||||
if (!p || val >= p.v || now - p.t > 1000) { peak.current[key] = { v: val, t: now }; return val; }
|
||||
return p.v;
|
||||
};
|
||||
|
||||
@@ -73,9 +76,12 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
|
||||
spe.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||
{spe.operate ? 'OPERATE' : 'STANDBY'}
|
||||
</button>
|
||||
{/* Power ON pulses RTS then DTR — it must stay clickable while the amp
|
||||
is off (no status = not "connected"), and serial only. */}
|
||||
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
|
||||
<button type="button" disabled={!spe.connected}
|
||||
<button type="button" disabled={!(spe.connected || spe.transport === 'serial')}
|
||||
onClick={() => AmpPower(amp.id, true).catch(() => {})}
|
||||
title={spe.transport === 'serial' ? 'Power on (RTS then DTR pulse)' : 'Power-on needs the serial RTS/DTR lines — not available over a network bridge'}
|
||||
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
|
||||
<button type="button" disabled={!spe.connected}
|
||||
onClick={() => AmpPower(amp.id, false).catch(() => {})}
|
||||
@@ -177,9 +183,6 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
|
||||
operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||
{operate ? 'OPERATE' : 'STANDBY'}
|
||||
</button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{operate ? t('flxp.ampInLine') : t('flxp.ampBypassed')}
|
||||
</span>
|
||||
{(pg.host || pg.connected) && (
|
||||
<label className="flex items-center gap-1.5 text-xs" title={pg.connected ? t('flxp.pgConnected') : (pg.last_error || t('flxp.pgOffline'))}>
|
||||
<span className={cn('size-1.5 rounded-full', pg.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
|
||||
@@ -208,6 +211,11 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
|
||||
const amps = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
||||
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
||||
if (amps.length === 0) return null;
|
||||
// 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 survives in
|
||||
// the last-known status after the amp disconnects — so it claimed
|
||||
// 1350 W from an old transmission while the radio was putting out 10.
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
|
||||
{amps.map((m) => {
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import { useRef } from 'react';
|
||||
import { Flame, X, Power } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { MeterBar } from '@/components/MeterBar';
|
||||
import { AmpOperate, AmpPower, AmpPowerLevel, AmpFanMode, FlexAmpOperate } from '../../wailsjs/go/main/App';
|
||||
|
||||
// AmpWidget — the docked amplifier widget, the compact counterpart of AmpCard
|
||||
// (which is the full-size card shown in the FlexRadio panel and Station
|
||||
// Control). It covers all three families, since an operator running an SPE and
|
||||
// a PowerGenius wants one widget, not two:
|
||||
// • SPE Expert — OPERATE, power ON/OFF, L/M/H, live power / SWR / temp
|
||||
// • ACOM — OPERATE, power ON/OFF, live power / SWR / temp
|
||||
// • PowerGenius — OPERATE and meters ride on the FlexRadio, fan mode on GSCP
|
||||
// With several amplifiers configured the caller passes a selection ("all" or an
|
||||
// amp id) chosen from the toolbar icon's dropdown.
|
||||
|
||||
type Amp = { id: string; name: string; type?: string; spe?: any; acom?: any; pgxl?: any };
|
||||
|
||||
// Full-scale watts for the output bar, from the model string.
|
||||
function maxW(model?: string, fallback = 1300): number {
|
||||
const m = (model || '').toUpperCase();
|
||||
if (m.includes('20K') || m.includes('2K')) return 2000;
|
||||
if (m.includes('15K')) return 1500;
|
||||
if (m.includes('13K')) return 1300;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function swrColour(swr?: number): string {
|
||||
if (!swr || swr <= 0) return 'text-muted-foreground';
|
||||
if (swr <= 1.5) return 'text-success';
|
||||
if (swr <= 2.0) return 'text-warning';
|
||||
return 'text-danger';
|
||||
}
|
||||
|
||||
// PowerMeter — the output-power LED bar that closes each column, straight under
|
||||
// the controls (no filler above it: pushed to the bottom it left a band of dead
|
||||
// space in the middle of the column). Uses the app-wide MeterBar, compact size,
|
||||
// so it reads exactly like the meters on the rig panels.
|
||||
function PowerMeter({ label, watts, maxWatts }: { label: string; watts: number; maxWatts: number }) {
|
||||
return (
|
||||
<MeterBar compact label={label} value={watts} unit="W" lo={0} hi={maxWatts}
|
||||
display={`${Math.round(watts)} W`}
|
||||
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
|
||||
);
|
||||
}
|
||||
|
||||
// usePeakHold keeps the highest reading seen for a short window, so a reading
|
||||
// sampled between two syllables doesn't collapse the meter. `live` is the key
|
||||
// part: pass false the moment the carrier drops and the held value is thrown
|
||||
// away at once — holding it there made releasing the PTT look like a five
|
||||
// second lag, which read as the whole app being slow.
|
||||
function usePeakHold(windowMs = 1000) {
|
||||
const peak = useRef<Record<string, { v: number; t: number }>>({});
|
||||
return (key: string, val: number, live = true) => {
|
||||
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; }
|
||||
return p.v;
|
||||
};
|
||||
}
|
||||
|
||||
// Stat — one small labelled figure (power, SWR, temperature).
|
||||
function Stat({ label, value, className }: { label: string; value: string; className?: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card/70 px-1.5 py-1 text-center">
|
||||
<div className="text-[9px] uppercase tracking-wider text-muted-foreground leading-tight">{label}</div>
|
||||
<div className={cn('text-sm font-bold font-mono leading-tight', className ?? 'text-foreground/80')}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// OperateButton — the STANDBY/OPERATE toggle, identical in behaviour across the
|
||||
// three families (only where the state comes from differs).
|
||||
function OperateButton({ operate, disabled, onClick, t }: {
|
||||
operate: boolean; disabled: boolean; onClick: () => void; t: (k: string, v?: any) => string;
|
||||
}) {
|
||||
return (
|
||||
<button type="button" disabled={disabled} onClick={onClick}
|
||||
className={cn('w-full inline-flex items-center justify-center gap-1.5 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-[0.98] disabled:opacity-40',
|
||||
operate
|
||||
? 'bg-gradient-to-b from-amber-400 to-amber-600 text-white border-amber-300/60 shadow-[0_0_9px_rgba(245,158,11,0.45)]'
|
||||
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground')}>
|
||||
<Power className="size-3.5" />
|
||||
{operate ? t('ampw.operate') : t('ampw.standby')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// AmpBlock — one amplifier inside the widget.
|
||||
function AmpBlock({ amp, flex, showName, t }: {
|
||||
amp: Amp; flex: any; showName: boolean; t: (k: string, v?: any) => string;
|
||||
}) {
|
||||
const spe = amp.spe, acom = amp.acom;
|
||||
const hold = usePeakHold();
|
||||
|
||||
if (spe || acom) {
|
||||
const s = spe || acom;
|
||||
// The amp reports zero watts on receive, so its TX flag clears the meter as
|
||||
// soon as the operator lets go — and the radio's own flag, when we have one,
|
||||
// gets there first (the amp is polled on its own slower cycle).
|
||||
const txing = typeof flex?.transmitting === 'boolean' ? flex.transmitting : s.tx !== false;
|
||||
const w = hold('w', Number(spe ? s.output_w : s.fwd_w) || 0, txing);
|
||||
const swr = Number(spe ? s.swr_ant : s.swr) || 0;
|
||||
const hi = spe ? maxW(s.model) : (Number(s.max_w) || 800);
|
||||
// Power ON drives the remote-on control lines, so it stays available while
|
||||
// the amplifier is off and reporting nothing — but only over a serial link.
|
||||
const canPowerOn = spe ? (s.connected || s.transport === 'serial') : (s.port_open && s.transport === 'serial');
|
||||
return (
|
||||
<div className="h-full flex flex-col gap-1.5">
|
||||
{showName && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={cn('size-1.5 rounded-full shrink-0', s.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider truncate">{amp.name || (spe ? 'SPE' : 'ACOM')}</span>
|
||||
{s.connected && s.band && <span className="ml-auto text-[9px] text-muted-foreground shrink-0">{s.band}</span>}
|
||||
</div>
|
||||
)}
|
||||
<OperateButton operate={!!s.operate} disabled={!s.connected} t={t}
|
||||
onClick={() => AmpOperate(amp.id, !s.operate).catch(() => {})} />
|
||||
<div className="flex gap-1.5">
|
||||
<button type="button" disabled={!canPowerOn}
|
||||
onClick={() => AmpPower(amp.id, true).catch(() => {})}
|
||||
className="flex-1 rounded-lg border border-success/60 bg-card py-1 text-[11px] font-bold text-success hover:bg-success/15 disabled:opacity-30">
|
||||
{t('ampw.on')}
|
||||
</button>
|
||||
<button type="button" disabled={!s.connected}
|
||||
onClick={() => AmpPower(amp.id, false).catch(() => {})}
|
||||
className="flex-1 rounded-lg border border-danger/60 bg-card py-1 text-[11px] font-bold text-danger hover:bg-danger/15 disabled:opacity-30">
|
||||
{t('ampw.off')}
|
||||
</button>
|
||||
</div>
|
||||
{/* Output level — SPE only; an ACOM has no L/M/H over CAT. */}
|
||||
{spe && (
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{(['L', 'M', 'H'] as const).map((lvl) => {
|
||||
const active = (s.power_level || '').trim().toUpperCase() === lvl;
|
||||
return (
|
||||
<button key={lvl} type="button" disabled={!s.connected}
|
||||
onClick={() => AmpPowerLevel(amp.id, lvl).catch(() => {})}
|
||||
className={cn('rounded-md border py-0.5 text-[10px] font-bold transition-colors disabled:opacity-30',
|
||||
active ? 'bg-primary text-primary-foreground border-primary' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||
{t(`ampw.lvl${lvl}`)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{s.connected ? (
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
<Stat label={t('ampw.pwr')} value={`${Math.round(w)}`} />
|
||||
<Stat label={t('ampw.swr')} value={swr > 0 ? swr.toFixed(1) : '—'} className={swrColour(swr)} />
|
||||
<Stat label={t('ampw.temp')} value={`${Number(s.temp_c) || 0}°`} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[10px] text-center text-muted-foreground italic py-1">{t('ampw.offline')}</div>
|
||||
)}
|
||||
{(s.warnings || s.alarms || s.err_text) && (
|
||||
<div className="rounded-md border border-danger-border bg-danger-muted text-danger-muted-foreground text-[9px] px-1.5 py-0.5 text-center break-words">
|
||||
{s.err_text || `${s.warnings || ''} ${s.alarms || ''}`.trim()}
|
||||
</div>
|
||||
)}
|
||||
<PowerMeter label={t('flxp.outputPower')} watts={s.connected ? w : 0} maxWatts={hi} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// PowerGenius XL: OPERATE and the meters come from the radio when the Flex
|
||||
// reports the amp; the fan mode rides on our own GSCP link.
|
||||
const pg = amp.pgxl || {};
|
||||
const viaFlex = !!flex?.amp_available;
|
||||
const operate = viaFlex ? !!flex?.amp_operate : !!pg.operate;
|
||||
const connected = !!pg.connected || viaFlex;
|
||||
const fault = flex?.amp_fault;
|
||||
const meters = ((flex?.meters as any[]) || []).filter((m) => (m.src || '').toUpperCase().includes('AMP'));
|
||||
const meter = (re: RegExp) => meters.find((m) => re.test((m.name || '').trim()));
|
||||
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
||||
const fwd = meter(/^fwd|pwr/i);
|
||||
const flexW = fwd ? (/dbm/i.test(fwd.unit || '') ? dbmToW(fwd.value) : fwd.value) : 0;
|
||||
// Whether there is power is the RADIO's transmit flag: it flips the instant
|
||||
// the PTT does, where the amplifier's own status poll answers a second and a
|
||||
// half late and holds TRANSMIT past the carrier.
|
||||
//
|
||||
// How MUCH is the live reading — the radio's meter stream, or the amp's own
|
||||
// forward figure when there is no Flex. NOT the amp's "peakfwd": that is a
|
||||
// latched maximum which is never reset AND survives in the last-known status
|
||||
// after the amp disconnects, so it reported 1350 W from an old transmission
|
||||
// while the radio was putting out 10 W into a disconnected amplifier.
|
||||
const txing = typeof flex?.transmitting === 'boolean'
|
||||
? flex.transmitting
|
||||
: fwd ? flexW >= 1 : /TRANSMIT/i.test(pg.state || '');
|
||||
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.
|
||||
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);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col gap-1.5">
|
||||
{showName && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={cn('size-1.5 rounded-full shrink-0', connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider truncate">{amp.name || 'PGXL'}</span>
|
||||
</div>
|
||||
)}
|
||||
<OperateButton operate={operate} disabled={!connected} t={t}
|
||||
onClick={() => (viaFlex ? FlexAmpOperate(!operate) : AmpOperate(amp.id, !operate)).catch(() => {})} />
|
||||
{connected ? (
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
<Stat label={t('ampw.pwr')} value={`${Math.round(fwdW)}`} />
|
||||
<Stat label={t('ampw.id')} value={idA != null ? idA.toFixed(1) : '—'} />
|
||||
<Stat label={t('ampw.temp')} value={tempC != null ? `${Math.round(tempC)}°` : '—'} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[10px] text-center text-muted-foreground italic py-1">{t('ampw.offline')}</div>
|
||||
)}
|
||||
{(pg.host || pg.connected) && (
|
||||
<select
|
||||
disabled={!pg.connected}
|
||||
value={(pg.fan_mode || 'CONTEST').toUpperCase()}
|
||||
onChange={(e) => AmpFanMode(amp.id, e.target.value).catch(() => {})}
|
||||
title={t('ampw.fan')}
|
||||
className="w-full h-7 rounded-md border border-border bg-card px-1.5 text-[10px] font-semibold outline-none focus:border-warning disabled:opacity-40">
|
||||
<option value="STANDARD">{t('flxp.fanStandard')}</option>
|
||||
<option value="CONTEST">{t('flxp.fanContest')}</option>
|
||||
<option value="BROADCAST">{t('flxp.fanBroadcast')}</option>
|
||||
</select>
|
||||
)}
|
||||
{fault && fault !== 'NONE' && (
|
||||
<div className="rounded-md border border-danger-border bg-danger-muted text-danger-muted-foreground text-[9px] px-1.5 py-0.5 text-center break-words">
|
||||
{t('flxp.fault')}: {fault}
|
||||
</div>
|
||||
)}
|
||||
<PowerMeter label={t('flxp.outputPower')} watts={connected ? fwdW : 0} maxWatts={2000} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AmpWidget({ amps, flex, sel, onClose }: {
|
||||
amps: Amp[];
|
||||
flex: any;
|
||||
sel: string; // "all" or an amp id
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
// An id that no longer matches (the amp was removed in Settings) falls back to
|
||||
// showing everything rather than an empty widget.
|
||||
const shown = sel === 'all' ? amps : (amps.filter((a) => a.id === sel).length ? amps.filter((a) => a.id === sel) : amps);
|
||||
const anyOnline = shown.some((a) => a.spe?.connected || a.acom?.connected || a.pgxl?.connected || flex?.amp_available);
|
||||
const title = shown.length === 1 ? (shown[0].name || t('ampw.title')) : t('ampw.title');
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/40 shrink-0">
|
||||
<Flame className={cn('size-4', anyOnline ? 'text-warning drop-shadow-[0_0_3px_rgba(245,158,11,0.55)]' : 'text-muted-foreground')} />
|
||||
<span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80 truncate">{title}</span>
|
||||
<span className="flex-1" />
|
||||
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground transition-colors" title={t('ampw.close')}>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{/* Several amplifiers sit SIDE BY SIDE, one column each: stacked, two amps
|
||||
made a widget taller than the dock row and both ended up half hidden. */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-2.5">
|
||||
{shown.length === 0 ? (
|
||||
<div className="text-[11px] text-center text-muted-foreground italic py-2">{t('ampw.none')}</div>
|
||||
) : (
|
||||
<div className="grid gap-2.5 h-full" style={{ gridTemplateColumns: `repeat(${shown.length}, minmax(0, 1fr))` }}>
|
||||
{shown.map((a, i) => (
|
||||
<div key={a.id} className={cn(i > 0 && 'pl-2.5 border-l border-border/50')}>
|
||||
<AmpBlock amp={a} flex={flex} showName={shown.length > 1} t={t} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -86,13 +86,6 @@ function Slider({ value, onChange, disabled, accent = '#16a34a', step = 1, max =
|
||||
if (!el) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (disRef.current) return;
|
||||
// The wheel only acts on a slider the operator has DELIBERATELY taken
|
||||
// hold of — one that has focus. Hovering was enough before, so scrolling
|
||||
// the panel with the pointer anywhere over these controls silently moved
|
||||
// whatever sat under it: an IC-7800 owner watched his transmit power
|
||||
// change on its own. Without focus the event is left alone and the panel
|
||||
// scrolls, which is what the gesture was for.
|
||||
if (document.activeElement !== el) return;
|
||||
e.preventDefault();
|
||||
const d = e.deltaY < 0 ? stepRef.current : -stepRef.current;
|
||||
const nv = Math.max(0, Math.min(maxRef.current, valRef.current + d));
|
||||
@@ -330,13 +323,15 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [st.cw_speed]);
|
||||
// Peak-hold: keep the highest reading for ~2 s so the jittery VITA-49 meters
|
||||
// read steadily instead of jumping every poll.
|
||||
// Peak-hold: keep the highest reading for a second so the jittery VITA-49
|
||||
// meters read steadily instead of jumping every poll. One second, not two —
|
||||
// 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) => {
|
||||
const peakHold = (key: string, val: number, windowMs = 1000) => {
|
||||
const now = Date.now();
|
||||
const p = peak.current[key];
|
||||
if (!p || val >= p.v || now - p.t > 2000) { peak.current[key] = { v: val, t: now }; return val; }
|
||||
if (!p || val >= p.v || now - p.t > windowMs) { peak.current[key] = { v: val, t: now }; return val; }
|
||||
return p.v;
|
||||
};
|
||||
|
||||
@@ -362,7 +357,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
}, []);
|
||||
|
||||
// PowerGenius XL direct connection (fan mode), independent of the Flex link.
|
||||
const [pg, setPg] = useState<{ connected: boolean; fan_mode?: string; host?: string; last_error?: string }>({ connected: false });
|
||||
const [pg, setPg] = useState<{ connected: boolean; fan_mode?: string; host?: string; last_error?: string; state?: string; peak_w?: number }>({ connected: false });
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const tick = async () => { try { const s: any = await GetPGXLStatus(); if (alive) setPg(s || { connected: false }); } catch {} };
|
||||
@@ -985,10 +980,12 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
spe.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||
{spe.operate ? 'OPERATE' : 'STANDBY'}
|
||||
</button>
|
||||
{/* Power ON / OFF (best-guess keystrokes from the APG — verify on hw). */}
|
||||
{/* Power ON pulses RTS then DTR — it must stay clickable while the amp
|
||||
is off (no status = not "connected"), and serial only. */}
|
||||
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
|
||||
<button type="button" disabled={!spe.connected}
|
||||
<button type="button" disabled={!(spe.connected || spe.transport === 'serial')}
|
||||
onClick={() => AmpPower(selAmp.id, true).catch(() => {})}
|
||||
title={spe.transport === 'serial' ? 'Power on (RTS then DTR pulse)' : 'Power-on needs the serial RTS/DTR lines — not available over a network bridge'}
|
||||
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
|
||||
<button type="button" disabled={!spe.connected}
|
||||
onClick={() => AmpPower(selAmp.id, false).catch(() => {})}
|
||||
@@ -1090,9 +1087,6 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
st.amp_operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||
{st.amp_operate ? 'OPERATE' : 'STANDBY'}
|
||||
</button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{st.amp_operate ? t('flxp.ampInLine') : t('flxp.ampBypassed')}
|
||||
</span>
|
||||
{/* Fan mode — shown when the PowerGenius is configured (Settings →
|
||||
PowerGenius). The dot shows the direct-connection state; the
|
||||
selector is disabled until connected (hover it for the error). */}
|
||||
@@ -1125,6 +1119,12 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
||||
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
||||
if (off || amp.length === 0) return null;
|
||||
// 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
|
||||
// survives in the last-known status after the amp disconnects — so
|
||||
// it claimed 1350 W from an old transmission while the radio was
|
||||
// putting out 10.
|
||||
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) => {
|
||||
|
||||
@@ -125,13 +125,6 @@ function Slider({ value, onChange, disabled, accent = '#2563eb', step = 1 }: {
|
||||
if (!el) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (disRef.current) return;
|
||||
// The wheel only acts on a slider the operator has DELIBERATELY taken
|
||||
// hold of — one that has focus. Hovering was enough before, so scrolling
|
||||
// the panel with the pointer anywhere over these controls silently moved
|
||||
// whatever sat under it: an IC-7800 owner watched his transmit power
|
||||
// change on its own. Without focus the event is left alone and the panel
|
||||
// scrolls, which is what the gesture was for.
|
||||
if (document.activeElement !== el) return;
|
||||
e.preventDefault();
|
||||
const d = e.deltaY < 0 ? stepRef.current : -stepRef.current;
|
||||
const nv = Math.max(0, Math.min(100, valRef.current + d));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, GripVertical } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -26,6 +26,13 @@ type Device = { id: string; type: string; name: string; host: string; user?: str
|
||||
type Relay = { number: number; label: string; on: boolean };
|
||||
type DevStatus = { id: string; name: string; type: string; connected: boolean; error?: string; relays: Relay[] };
|
||||
|
||||
// Dashboard geometry: a grid of EQUAL columns whose cards also share a common
|
||||
// HEIGHT per row — every card stretches to the tallest one beside it. Masonry
|
||||
// packed tighter but left the row edges ragged, which read as untidy on a wide
|
||||
// screen; aligned rows are what an operator expects from a dashboard.
|
||||
const CARD_MIN = 430; // narrowest a card may get before "Auto" drops a column
|
||||
const GRID_GAP = 16; // px between cards, both axes
|
||||
|
||||
const RELAY_COUNT: Record<string, number> = { webswitch: 5, kmtronic: 8, denkovi: 8, usbrelay: 8 };
|
||||
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay', denkovi: 'Denkovi USB (FT245)', usbrelay: 'Denkovi USB (serial)' };
|
||||
|
||||
@@ -269,6 +276,23 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
// Max columns per row (persisted). "Auto" fills the window; a number caps the
|
||||
// row so cards wrap onto further lines even when there's horizontal room.
|
||||
const [cols, setCols] = useState<string>(() => localStorage.getItem('opslog.stationCols') || 'auto');
|
||||
|
||||
// How many columns the dashboard shows. "Auto" fits as many CARD_MIN-wide
|
||||
// cards as the window allows; an explicit count is honoured as asked, even
|
||||
// past that width — the operator can see his own screen.
|
||||
const gridRef = useRef<HTMLDivElement | null>(null);
|
||||
const [colCount, setColCount] = useState(1);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (cols !== 'auto') { setColCount(Number(cols) || 1); return; }
|
||||
const el = gridRef.current;
|
||||
if (!el) return;
|
||||
const measure = () => setColCount(Math.max(1, Math.floor((el.clientWidth + GRID_GAP) / (CARD_MIN + GRID_GAP))));
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [cols]);
|
||||
// Amplifiers (Settings → Amplifier): shown here so operators without a
|
||||
// FlexRadio panel still get the controls. EVERY configured amp gets its own
|
||||
// card (identical to the Flex panel's) — no dropdown. Polled fast (1.5s) so the
|
||||
@@ -415,10 +439,10 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
);
|
||||
};
|
||||
|
||||
// `wide` cards span the whole dashboard instead of sitting in one masonry
|
||||
// column. The amplifier and tuner carry meter rows and a channel selector that
|
||||
// are unreadable squeezed into a ~430px column — they are the same cards the
|
||||
// FlexRadio panel shows full-width, and they need that room here too.
|
||||
// `wide` cards take two grid columns instead of one. The amplifier and tuner
|
||||
// carry meter rows and a channel selector that are unreadable squeezed into a
|
||||
// single ~430px column — they are the same cards the FlexRadio panel shows
|
||||
// full-width, and they need that room here too.
|
||||
const widgets: { id: string; node: React.ReactNode; wide?: boolean }[] = [];
|
||||
if (rot.enabled) {
|
||||
widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pollRot} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
|
||||
@@ -446,7 +470,7 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
{/* Max columns per row: Auto fills the window; 1/2/3/4 cap the row so a
|
||||
card can sit on a further line even with horizontal room to spare. */}
|
||||
<div className="flex items-center rounded-md border border-border overflow-hidden text-[11px]">
|
||||
{(['auto', '1', '2', '3', '4'] as const).map((c) => (
|
||||
{(['auto', '1', '2', '3', '4', '5', '6'] as const).map((c) => (
|
||||
<button key={c} type="button"
|
||||
onClick={() => { setCols(c); writeUiPref('opslog.stationCols', c); }}
|
||||
className={cn('px-2 py-1 font-medium', cols === c ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
|
||||
@@ -466,31 +490,36 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Masonry dashboard: fixed-width cards flow into balanced CSS columns so
|
||||
they pack tightly by height (no ragged gaps under short cards). "Auto"
|
||||
fits as many ~430px columns as the window allows; a fixed count caps the
|
||||
container width to that many columns. Each card has a grip handle (left
|
||||
rail) as the drag initiator (the body is full of buttons). */}
|
||||
<div style={{ columnWidth: '430px', columnGap: '1rem', columnFill: 'balance',
|
||||
...(cols !== 'auto' ? { maxWidth: `${Number(cols) * 446}px` } : {}) }}>
|
||||
{/* Dashboard grid: equal columns, and every card stretches to the height of
|
||||
the tallest one on its row, so the rows line up instead of ending
|
||||
ragged. One column each, two for the amplifier/tuner whose meter rows
|
||||
need the room. Each card has a grip handle (left rail) as the drag
|
||||
initiator, since the body is full of buttons. */}
|
||||
<div ref={gridRef} style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))`,
|
||||
gap: `${GRID_GAP}px`,
|
||||
}}>
|
||||
{ordered.map((w) => (
|
||||
// column-span:all lifts a wide card out of the columns and across the
|
||||
// full container, while keeping it in document order — so drag-reorder
|
||||
// still works between wide and normal cards. Capped so it stays a card
|
||||
// and not a banner on an ultra-wide window.
|
||||
<div key={w.id} className="flex items-stretch break-inside-avoid mb-4"
|
||||
style={w.wide ? { columnSpan: 'all', maxWidth: '900px' } : undefined}
|
||||
<div key={w.id}
|
||||
// A wide card takes two columns — but never more than there are, or
|
||||
// the grid would grow a phantom column on a narrow window.
|
||||
style={{ gridColumn: w.wide ? `span ${Math.min(2, colCount)}` : undefined }}
|
||||
onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }}
|
||||
onDrop={(e) => { if (dragId.current) { e.preventDefault(); onDrop(w.id); } }}>
|
||||
<div draggable
|
||||
onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }}
|
||||
onDragEnd={() => { dragId.current = null; }}
|
||||
title={t('station.dragMove')}
|
||||
className={cn('flex items-center shrink-0 px-0.5 rounded-l-xl cursor-grab active:cursor-grabbing text-muted-foreground/30 hover:text-muted-foreground hover:bg-muted/40 transition-colors',
|
||||
dragId.current === w.id && 'opacity-60')}>
|
||||
<GripVertical className="size-4" />
|
||||
{/* h-full down the chain is what makes the card fill the row height
|
||||
rather than sit at its natural size in a taller cell. */}
|
||||
<div className="flex items-stretch h-full">
|
||||
<div draggable
|
||||
onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }}
|
||||
onDragEnd={() => { dragId.current = null; }}
|
||||
title={t('station.dragMove')}
|
||||
className={cn('flex items-center shrink-0 px-0.5 rounded-l-xl cursor-grab active:cursor-grabbing text-muted-foreground/30 hover:text-muted-foreground hover:bg-muted/40 transition-colors',
|
||||
dragId.current === w.id && 'opacity-60')}>
|
||||
<GripVertical className="size-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 [&>*]:h-full">{w.node}</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">{w.node}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -76,13 +76,16 @@ export function TunerCard({ status, t }: { status: TGStatus; t: (k: string, v?:
|
||||
const peakHold = (key: string, val: number) => {
|
||||
const now = Date.now();
|
||||
const p = peak.current[key];
|
||||
if (!p || val >= p.v || now - p.t > 2000) { peak.current[key] = { v: val, t: now }; return val; }
|
||||
if (!p || val >= p.v || now - p.t > 1000) { peak.current[key] = { v: val, t: now }; return val; }
|
||||
return p.v;
|
||||
};
|
||||
|
||||
const connected = !!status.connected;
|
||||
const rawVswr = status.vswr && status.vswr > 0 ? status.vswr : undefined;
|
||||
const rawFwdW = status.fwd_w && status.fwd_w >= 1 ? status.fwd_w : 0;
|
||||
// Prefer the device's own peak detector over the sampled reading; the JS hold
|
||||
// below still smooths what is left between polls.
|
||||
const rawPeak = Math.max(status.peak_w ?? 0, status.fwd_w ?? 0);
|
||||
const rawFwdW = rawPeak >= 1 ? rawPeak : 0;
|
||||
const fwdW = peakHold('fwd', rawFwdW);
|
||||
|
||||
// SWR needs RF to mean anything: sampled on receive the device reports a
|
||||
@@ -93,13 +96,14 @@ export function TunerCard({ status, t }: { status: TGStatus; t: (k: string, v?:
|
||||
// That expiry is the point: the first version of this held the last TX value
|
||||
// with no timeout at all, so the meter sat frozen on the previous transmission
|
||||
// indefinitely — reading 1.39:1 with the radio plainly in RX and 0 W forward.
|
||||
// Same 2 s window as the power peak above, so the two meters clear together.
|
||||
// Same one-second window as the power peak above, so the two meters clear
|
||||
// together.
|
||||
const swrHold = useRef<{ v: number; t: number } | null>(null);
|
||||
if (rawFwdW >= 1 && rawVswr) swrHold.current = { v: rawVswr, t: Date.now() };
|
||||
const heldSwr = swrHold.current;
|
||||
const vswr = rawFwdW >= 1
|
||||
? rawVswr
|
||||
: heldSwr && Date.now() - heldSwr.t < 2000
|
||||
: heldSwr && Date.now() - heldSwr.t < 1000
|
||||
? heldSwr.v
|
||||
: undefined;
|
||||
const active = status.active ?? 1;
|
||||
|
||||
@@ -8,7 +8,7 @@ export type TGChannel = {
|
||||
};
|
||||
export type TGStatus = {
|
||||
connected: boolean; host?: string; last_error?: string;
|
||||
fwd_dbm?: number; fwd_w?: number; swr_db?: number; vswr?: number; freq_mhz?: number;
|
||||
fwd_dbm?: number; fwd_w?: number; peak_w?: number; swr_db?: number; vswr?: number; freq_mhz?: number;
|
||||
operate?: boolean; bypass?: boolean; tuning?: boolean; active?: number;
|
||||
antenna?: number; three_way?: boolean; message?: string;
|
||||
a?: TGChannel; b?: TGChannel;
|
||||
@@ -69,6 +69,10 @@ export function TunerGeniusPanel({ status, onTune, onBypass, onOperate, onActiva
|
||||
const { t } = useI18n();
|
||||
const vswr = status.vswr && status.vswr > 0 ? status.vswr : undefined;
|
||||
const active = status.active ?? 1;
|
||||
// The device's own peak, not the instantaneous reading: a 400 ms poll lands
|
||||
// between syllables as often as on a peak, so the plain figure showed a few
|
||||
// hundred watts on a kilowatt transmission.
|
||||
const fwdW = Math.max(status.peak_w ?? 0, status.fwd_w ?? 0);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
|
||||
@@ -103,7 +107,7 @@ export function TunerGeniusPanel({ status, onTune, onBypass, onOperate, onActiva
|
||||
<div className="rounded-lg border border-border bg-card/70 px-2 py-1.5 text-center">
|
||||
<div className="text-[9px] uppercase tracking-wider text-muted-foreground">{t('tgp.power')}</div>
|
||||
<div className="text-lg font-bold font-mono leading-tight text-foreground/80">
|
||||
{status.fwd_w && status.fwd_w >= 1 ? `${Math.round(status.fwd_w)} W` : '—'}
|
||||
{fwdW >= 1 ? `${Math.round(fwdW)} W` : '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -149,13 +149,6 @@ function Slider({ value, onChange, disabled, accent = 'var(--primary)', min = 0,
|
||||
if (!el) return;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (disRef.current) return;
|
||||
// The wheel only acts on a slider the operator has DELIBERATELY taken
|
||||
// hold of — one that has focus. Hovering was enough before, so scrolling
|
||||
// the panel with the pointer anywhere over these controls silently moved
|
||||
// whatever sat under it: an IC-7800 owner watched his transmit power
|
||||
// change on its own. Without focus the event is left alone and the panel
|
||||
// scrolls, which is what the gesture was for.
|
||||
if (document.activeElement !== el) return;
|
||||
e.preventDefault();
|
||||
const nv = Math.max(minRef.current, Math.min(maxRef.current, valRef.current + (e.deltaY < 0 ? 1 : -1)));
|
||||
if (nv !== valRef.current) cbRef.current(nv);
|
||||
|
||||
@@ -352,6 +352,12 @@ const en: Dict = {
|
||||
'wkp.autoCallHint': 'Click a CQ macro (one whose text contains CQ) to resend it on a loop — message, gap, repeat — until you send another macro (e.g. a report), press Stop, or hit ESC. Non-CQ macros send once.', 'wkp.autoCall': 'Auto-call', 'wkp.gap': 'gap', 'wkp.gapHint': 'Seconds to wait after the message before resending', 'wkp.loopHint': 'click a CQ macro to loop it', 'wkp.macroN': 'Macro {n}',
|
||||
'dvkp.voiceKeyer': 'Voice keyer', 'dvkp.autoCq': 'Auto CQ', 'dvkp.autoCqHint': 'Repeat a CQ-labelled message on a timer until you stop it or play another slot', 'dvkp.gap': 'Gap', 'dvkp.notPhone': 'The voice keyer only transmits on a phone mode (SSB/AM/FM)', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Disable voice keyer', 'dvkp.noMsgPre': 'No messages recorded yet. Open', 'dvkp.settingsPath': 'Settings → Audio devices & voice keyer', 'dvkp.noMsgPost': 'to record F1–F6.', 'dvkp.transmit': 'Transmit F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — empty', 'dvkp.message': 'message',
|
||||
'agp.portDeselect': 'Port {letter} — click to deselect', 'agp.portSelect': 'Select on port {letter}', 'agp.online': 'online', 'agp.offline': 'offline', 'agp.close': 'Close', 'agp.connecting': 'Connecting…', 'agp.noAntennas': 'No antennas configured.', 'agp.filterOnHint': 'Showing antennas for {band} only — click to show all bands', 'agp.filterOffHint': 'Showing all antennas — click to show only the current band',
|
||||
'ampw.title': 'Amplifier', 'ampw.all': 'All amplifiers', 'ampw.pick': 'Which amplifier the widget shows',
|
||||
'ampw.showHint': 'Amplifier · click to show', 'ampw.hideHint': 'Amplifier — shown · click to hide',
|
||||
'ampw.close': 'Close', 'ampw.offline': 'offline', 'ampw.none': 'No amplifier configured',
|
||||
'ampw.operate': 'Operate', 'ampw.standby': 'Standby', 'ampw.on': 'ON', 'ampw.off': 'OFF',
|
||||
'ampw.lvlL': 'Low', 'ampw.lvlM': 'Mid', 'ampw.lvlH': 'High',
|
||||
'ampw.pwr': 'Watts', 'ampw.swr': 'SWR', 'ampw.temp': 'Temp', 'ampw.id': 'Id (A)', 'ampw.fan': 'Fan mode',
|
||||
'tgp.online': 'online', 'tgp.offline': 'offline', 'tgp.close': 'Close', 'tgp.connecting': 'Connecting…', 'tgp.swr': 'SWR', 'tgp.power': 'Fwd power', 'tgp.tune': 'Tune', 'tgp.tuning': 'Tuning…', 'tgp.tuneHint': 'Start an automatic tuning cycle on the active channel — key the rig into a carrier so the tuner can measure SWR', 'tgp.bypass': 'Bypass', 'tgp.bypassHint': 'Toggle global bypass — route the antenna straight through, tuner out of line', 'tgp.operate': 'Operate', 'tgp.standby': 'Standby', 'tgp.operateHint': 'Toggle Operate / Standby',
|
||||
'tgp.title': 'Tuner Genius', 'tgp.chActive': 'Channel {letter} — active', 'tgp.chSelect': 'Make channel {letter} active', 'tgp.chActiveTag': 'active', 'tgp.ant': 'Ant', 'tgp.bypassed': 'Bypassed', 'tgp.inLine': 'In line',
|
||||
'flxp.ritHint': 'RIT — shifts your RECEIVE frequency only. Wheel, ± or arrow keys to adjust (Ctrl = 100 Hz). The offset is kept when you switch it off.', 'flxp.xitHint': 'XIT — shifts your TRANSMIT frequency only. Wheel, ± or arrow keys to adjust (Ctrl = 100 Hz). The offset is kept when you switch it off.',
|
||||
@@ -747,6 +753,12 @@ const fr: Dict = {
|
||||
'wkp.autoCallHint': "Clique une macro CQ (dont le texte contient CQ) pour la réémettre en boucle — message, pause, répétition — jusqu'à envoyer une autre macro (ex. un report), appuyer sur Stop ou ESC. Les macros non-CQ ne sont émises qu'une fois.", 'wkp.autoCall': 'Appel auto', 'wkp.gap': 'pause', 'wkp.gapHint': 'Secondes à attendre après le message avant de réémettre', 'wkp.loopHint': 'clique une macro CQ pour la boucler', 'wkp.macroN': 'Macro {n}',
|
||||
'dvkp.voiceKeyer': 'Manipulateur vocal', 'dvkp.autoCq': 'Auto CQ', 'dvkp.autoCqHint': 'Répète un message libellé CQ à intervalle régulier jusqu\'à l\'arrêt ou la lecture d\'un autre slot', 'dvkp.gap': 'Intervalle', 'dvkp.notPhone': 'Le manipulateur vocal n\'émet qu\'en mode phonie (SSB/AM/FM)', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Désactiver le manipulateur vocal', 'dvkp.noMsgPre': 'Aucun message enregistré. Ouvre', 'dvkp.settingsPath': 'Réglages → Périphériques audio & manipulateur vocal', 'dvkp.noMsgPost': 'pour enregistrer F1–F6.', 'dvkp.transmit': 'Émettre F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — vide', 'dvkp.message': 'message',
|
||||
'agp.portDeselect': 'Port {letter} — clic pour désélectionner', 'agp.portSelect': 'Sélectionner sur le port {letter}', 'agp.online': 'en ligne', 'agp.offline': 'hors ligne', 'agp.close': 'Fermer', 'agp.connecting': 'Connexion…', 'agp.noAntennas': 'Aucune antenne configurée.', 'agp.filterOnHint': 'Antennes du {band} uniquement — clic pour afficher toutes les bandes', 'agp.filterOffHint': 'Toutes les antennes affichées — clic pour n’afficher que la bande courante',
|
||||
'ampw.title': 'Amplificateur', 'ampw.all': 'Tous les amplis', 'ampw.pick': 'Ampli affiché par le widget',
|
||||
'ampw.showHint': 'Amplificateur · cliquer pour afficher', 'ampw.hideHint': 'Amplificateur — affiché · cliquer pour masquer',
|
||||
'ampw.close': 'Fermer', 'ampw.offline': 'hors ligne', 'ampw.none': 'Aucun amplificateur configuré',
|
||||
'ampw.operate': 'Operate', 'ampw.standby': 'Standby', 'ampw.on': 'ON', 'ampw.off': 'OFF',
|
||||
'ampw.lvlL': 'Low', 'ampw.lvlM': 'Mid', 'ampw.lvlH': 'High',
|
||||
'ampw.pwr': 'Watts', 'ampw.swr': 'ROS', 'ampw.temp': 'Temp', 'ampw.id': 'Id (A)', 'ampw.fan': 'Mode ventil.',
|
||||
'tgp.online': 'en ligne', 'tgp.offline': 'hors ligne', 'tgp.close': 'Fermer', 'tgp.connecting': 'Connexion…', 'tgp.swr': 'ROS', 'tgp.power': 'Puiss. directe', 'tgp.tune': 'Accord', 'tgp.tuning': 'Accord…', 'tgp.tuneHint': "Lancer un cycle d'accord automatique sur le canal actif — passe la radio en porteuse pour que le coupleur mesure le ROS", 'tgp.bypass': 'Bypass', 'tgp.bypassHint': "Basculer le bypass global — antenne en direct, coupleur hors ligne", 'tgp.operate': 'Operate', 'tgp.standby': 'Standby', 'tgp.operateHint': 'Basculer Operate / Standby',
|
||||
'tgp.title': 'Tuner Genius', 'tgp.chActive': 'Canal {letter} — actif', 'tgp.chSelect': 'Activer le canal {letter}', 'tgp.chActiveTag': 'actif', 'tgp.ant': 'Ant', 'tgp.bypassed': 'Bypass', 'tgp.inLine': 'En ligne',
|
||||
'flxp.ritHint': "RIT — décale uniquement ta fréquence de RÉCEPTION. Molette, ± ou flèches pour ajuster (Ctrl = 100 Hz). Le décalage est conservé quand tu l'éteins.", 'flxp.xitHint': "XIT — décale uniquement ta fréquence d'ÉMISSION. Molette, ± ou flèches pour ajuster (Ctrl = 100 Hz). Le décalage est conservé quand tu l'éteins.",
|
||||
|
||||
@@ -17,6 +17,8 @@ const PORTABLE_KEYS = [
|
||||
'opslog.autofocusWB', // auto-focus Worked-before
|
||||
'hamlog.filterPresets', // Filter Builder saved presets
|
||||
'opslog.showRotor', // rotor compass shown next to the keyers
|
||||
'opslog.showAmpWidget', // amplifier widget shown next to the keyers
|
||||
'opslog.ampSel.widget', // which amplifier that widget shows ("all" or an amp id)
|
||||
'opslog.showBeamOnMap', // antenna beam lobe drawn on the Main map
|
||||
'opslog.startEqualsEnd',// log TIME_ON = TIME_OFF (QSO time = completion time)
|
||||
'opslog.showQsoRate', // QSO-rate meter (10/60 min) shown in the header
|
||||
|
||||
@@ -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.22.8';
|
||||
export const APP_VERSION = '0.22.9';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
@@ -3501,6 +3501,11 @@ export namespace powergenius {
|
||||
fan_mode?: string;
|
||||
temperature: number;
|
||||
operate: boolean;
|
||||
fwd_w: number;
|
||||
peak_w: number;
|
||||
vswr: number;
|
||||
id: number;
|
||||
peak_id: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Status(source);
|
||||
@@ -3515,6 +3520,11 @@ export namespace powergenius {
|
||||
this.fan_mode = source["fan_mode"];
|
||||
this.temperature = source["temperature"];
|
||||
this.operate = source["operate"];
|
||||
this.fwd_w = source["fwd_w"];
|
||||
this.peak_w = source["peak_w"];
|
||||
this.vswr = source["vswr"];
|
||||
this.id = source["id"];
|
||||
this.peak_id = source["peak_id"];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4560,6 +4570,7 @@ export namespace spe {
|
||||
|
||||
export class Status {
|
||||
connected: boolean;
|
||||
transport: string;
|
||||
last_error?: string;
|
||||
model?: string;
|
||||
operate: boolean;
|
||||
@@ -4583,6 +4594,7 @@ export namespace spe {
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.connected = source["connected"];
|
||||
this.transport = source["transport"];
|
||||
this.last_error = source["last_error"];
|
||||
this.model = source["model"];
|
||||
this.operate = source["operate"];
|
||||
@@ -4637,6 +4649,7 @@ export namespace tunergenius {
|
||||
last_error?: string;
|
||||
fwd_dbm: number;
|
||||
fwd_w: number;
|
||||
peak_w: number;
|
||||
swr_db: number;
|
||||
vswr: number;
|
||||
operate: boolean;
|
||||
@@ -4664,6 +4677,7 @@ export namespace tunergenius {
|
||||
this.last_error = source["last_error"];
|
||||
this.fwd_dbm = source["fwd_dbm"];
|
||||
this.fwd_w = source["fwd_w"];
|
||||
this.peak_w = source["peak_w"];
|
||||
this.swr_db = source["swr_db"];
|
||||
this.vswr = source["vswr"];
|
||||
this.operate = source["operate"];
|
||||
|
||||
Reference in New Issue
Block a user