feat: amplifier controls without a Flex/Icom panel — Station Control widget (SPE/ACOM full control, PGXL fan) + status-bar chip (green OPERATE / orange STANDBY / red offline, click opens Settings); warn with a toast when activating a profile whose MySQL is unreachable instead of silently staying on the old logbook

This commit is contained in:
2026-07-21 18:47:31 +02:00
parent 24a5a0480d
commit 0385aed760
3 changed files with 185 additions and 12 deletions
+123 -2
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw } from 'lucide-react';
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, Flame } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -13,6 +13,9 @@ import {
GetRotatorHeading, RotatorGoTo, RotatorStop,
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
ListDenkoviDevices, ListSerialPorts, TestStationDevice,
GetPGXLSettings, GetPGXLStatus, PGXLSetFanMode,
GetSPEStatus, SPESetOperate, SPESetPower, SPESetPowerLevel,
GetACOMStatus, ACOMSetOperate, ACOMSetPower,
} from '../../wailsjs/go/main/App';
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
@@ -246,6 +249,110 @@ function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () =
);
}
// AmplifierWidget brings the amplifier controls (Settings → Amplifier) into the
// Station Control tab, so an operator WITHOUT a FlexRadio/Icom panel still has
// them (the FlexPanel card only exists when a Flex is the rig). Same backends:
// SPE Expert / ACOM (full control) and PowerGenius XL (fan mode + state).
function AmplifierWidget({ ampType, t }: { ampType: string; t: (k: string, v?: any) => string }) {
const isACOM = ampType.startsWith('acom');
const isPGXL = ampType === 'pgxl';
const [st, setSt] = useState<any>({ connected: false });
useEffect(() => {
let alive = true;
const get = isPGXL ? GetPGXLStatus : isACOM ? GetACOMStatus : GetSPEStatus;
const tick = () => get().then((s: any) => alive && setSt(s || { connected: false })).catch(() => {});
tick();
const id = window.setInterval(tick, 1500);
return () => { alive = false; window.clearInterval(id); };
}, [ampType, isACOM, isPGXL]);
const title = isPGXL ? 'PowerGenius XL' : isACOM ? `ACOM ${st.model || ''}` : `SPE ${st.model || 'Expert'}`;
const maxW = isACOM ? (Number(st.max_w) || 800) : ({ '13K': 1300, '15K': 1500, '2K': 2000 } as Record<string, number>)[st.model] || 1500;
const outW = Number(isACOM ? st.fwd_w : st.output_w) || 0;
const frac = Math.min(1, outW / maxW);
return (
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
<Flame className="size-4 text-primary" />
<div className="min-w-0">
<div className="text-sm font-semibold truncate">{t('flxp.amplifier')}</div>
<div className="text-[10px] text-muted-foreground font-mono truncate">{title}</div>
</div>
<span className={cn('ml-auto size-2 rounded-full shrink-0', st.connected ? 'bg-success' : 'bg-muted-foreground/40')}
title={st.connected ? t('station.online') : (st.last_error || t('station.offline'))} />
</div>
<div className="p-3 space-y-2">
{isPGXL ? (
<div className="flex items-center gap-2 flex-wrap">
{(['STANDARD', 'CONTEST', 'BROADCAST'] as const).map((m) => (
<button key={m} type="button" disabled={!st.connected}
onClick={() => PGXLSetFanMode(m).catch(() => {})}
className={cn('px-2.5 py-1.5 rounded-md border text-xs font-semibold disabled:opacity-40',
st.fan_mode === m ? 'bg-primary text-primary-foreground border-primary' : 'bg-muted/30 border-border hover:bg-muted')}>
{m === 'STANDARD' ? t('flxp.fanStandard') : m === 'CONTEST' ? t('flxp.fanContest') : t('flxp.fanBroadcast')}
</button>
))}
<span className="text-xs text-muted-foreground font-mono">{st.state || ''}{st.temperature ? ` · ${Math.round(st.temperature)}°C` : ''}</span>
</div>
) : (
<>
<div className="flex items-center gap-2 flex-wrap">
<button type="button" disabled={!st.connected}
onClick={() => (isACOM ? ACOMSetOperate(!st.operate) : SPESetOperate(!st.operate)).catch(() => {})}
className={cn('px-3 py-1.5 rounded-lg text-xs font-extrabold tracking-wide border-2 transition-all disabled:opacity-40',
st.operate ? 'bg-warning text-warning-foreground border-warning' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
{st.operate ? 'OPERATE' : 'STANDBY'}
</button>
<div className="inline-flex rounded-lg overflow-hidden border border-success/70">
<button type="button"
disabled={isACOM ? !(st.port_open && st.transport === 'serial') : !st.connected}
onClick={() => (isACOM ? ACOMSetPower(true) : SPESetPower(true)).catch(() => {})}
className="px-2.5 py-1.5 text-xs font-bold bg-card text-success hover:bg-success/15 disabled:opacity-40">ON</button>
<button type="button" disabled={!st.connected}
onClick={() => (isACOM ? ACOMSetPower(false) : SPESetPower(false)).catch(() => {})}
className="px-2.5 py-1.5 text-xs font-bold bg-card text-danger border-l border-success/70 hover:bg-danger/15 disabled:opacity-40">OFF</button>
</div>
{!isACOM && (
<div className="inline-flex rounded-lg overflow-hidden border border-border">
{(['L', 'M', 'H'] as const).map((lvl, i) => (
<button key={lvl} type="button" disabled={!st.connected}
onClick={() => SPESetPowerLevel(lvl).catch(() => {})}
className={cn('px-2.5 py-1.5 text-xs font-bold disabled:opacity-40', i > 0 && 'border-l border-border',
(st.power_level || '').trim().toUpperCase() === lvl ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
{lvl}
</button>
))}
</div>
)}
</div>
<div className="text-xs font-mono text-muted-foreground tabular-nums">
{st.connected
? <>
{isACOM ? (st.state || '') : (st.tx ? 'TX' : 'RX')}
{st.band ? ` · ${st.band}` : ''} · {outW}W · SWR {Number((isACOM ? st.swr : st.swr_ant) ?? 0).toFixed(1)} · {st.temp_c}°C
{isACOM && st.fan ? ` · Fan ${st.fan}` : ''}
</>
: (isACOM ? t('flxp.acomOffline') : t('flxp.speOffline'))}
</div>
{st.connected && (
<div>
<div className="h-2 rounded bg-muted overflow-hidden">
<div className={cn('h-full transition-all', frac > 0.9 ? 'bg-danger' : frac > 0.75 ? 'bg-warning' : 'bg-primary')}
style={{ width: `${Math.round(frac * 100)}%` }} />
</div>
<div className="text-[10px] text-muted-foreground mt-0.5">{t('flxp.outputPower')} {outW} W / {maxW} W</div>
</div>
)}
{(st.err_text || st.warnings || st.alarms) && (
<div className="text-[11px] font-bold text-danger"> {st.err_text || `${st.warnings || ''} ${st.alarms || ''}`}</div>
)}
</>
)}
</div>
</div>
);
}
export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorProps) {
const { t } = useI18n();
const [devices, setDevices] = useState<Device[]>([]);
@@ -260,6 +367,17 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
});
const dragId = useRef<string | null>(null);
const [cols, setCols] = useState<string>(() => localStorage.getItem('opslog.stationCols') || 'auto');
// Amplifier (Settings → Amplifier): shown here so operators without a
// FlexRadio panel still get the controls. Re-read every 5s so enabling the
// amp in Settings makes the widget appear without reopening the tab.
const [amp, setAmp] = useState<{ enabled: boolean; type: string }>({ enabled: false, type: 'pgxl' });
useEffect(() => {
let alive = true;
const load = () => GetPGXLSettings().then((s: any) => { if (alive) setAmp({ enabled: !!s?.enabled, type: s?.type || 'pgxl' }); }).catch(() => {});
load();
const id = window.setInterval(load, 5000);
return () => { alive = false; window.clearInterval(id); };
}, []);
const loadDevices = useCallback(async () => {
try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ }
@@ -380,13 +498,16 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
if (ant.enabled) {
widgets.push({ id: 'antenna', node: <MotorAntennaWidget ant={ant} refetch={pollAnt} t={t} /> });
}
if (amp.enabled) {
widgets.push({ id: 'amplifier', node: <AmplifierWidget ampType={amp.type} t={t} /> });
}
for (const dev of devices) widgets.push({ id: dev.id, node: deviceCard(dev) });
const rank = (id: string) => { const i = order.indexOf(id); return i < 0 ? 1e6 : i; };
const ordered = widgets.map((w, i) => ({ ...w, i })).sort((a, b) => (rank(a.id) - rank(b.id)) || (a.i - b.i));
const widgetIds = ordered.map((w) => w.id);
const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled;
const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled && !amp.enabled;
// Column count controls the layout: with 4 widgets, choosing "2" lays them out
// 2×2 — which linear drag-reorder alone can't do, since the grid auto-flows to