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:
@@ -11214,7 +11214,14 @@ func (a *App) ActivateProfile(id int64) error {
|
|||||||
// target (local SQLite or its own MySQL) so QSOs go to the right logbook.
|
// target (local SQLite or its own MySQL) so QSOs go to the right logbook.
|
||||||
if p, err := a.profiles.Get(a.ctx, id); err == nil {
|
if p, err := a.profiles.Get(a.ctx, id); err == nil {
|
||||||
if err := a.switchLogbook(p); err != nil {
|
if err := a.switchLogbook(p); err != nil {
|
||||||
|
// The switch failed (typically: this profile's MySQL is unreachable right
|
||||||
|
// now) and the PREVIOUS logbook stays live. Silently staying on the old
|
||||||
|
// database is how QSOs end up in the wrong log — tell the operator.
|
||||||
applog.Printf("activate profile %d: logbook switch failed: %v", id, err)
|
applog.Printf("activate profile %d: logbook switch failed: %v", id, err)
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "toast", fmt.Sprintf(
|
||||||
|
"Profil %q : connexion à sa base impossible — l'ancienne base reste active ! (%v)", p.Name, err))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Re-apply every settings-dependent subsystem for the new profile.
|
// Re-apply every settings-dependent subsystem for the new profile.
|
||||||
|
|||||||
+55
-10
@@ -43,6 +43,7 @@ import {
|
|||||||
GetAwardDefs,
|
GetAwardDefs,
|
||||||
GetUIPref, GetActiveProfile, QuitApp,
|
GetUIPref, GetActiveProfile, QuitApp,
|
||||||
ReportLiveActivity, LiveLastQSOAgeSec,
|
ReportLiveActivity, LiveLastQSOAgeSec,
|
||||||
|
GetPGXLSettings, GetPGXLStatus, GetSPEStatus, GetACOMStatus,
|
||||||
} from '../wailsjs/go/main/App';
|
} from '../wailsjs/go/main/App';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { applyAwardRefs } from '@/lib/awardRefs';
|
import { applyAwardRefs } from '@/lib/awardRefs';
|
||||||
@@ -436,6 +437,27 @@ export default function App() {
|
|||||||
// click reverts the UI and the click looks like it did nothing.
|
// click reverts the UI and the click looks like it did nothing.
|
||||||
const agPending = useRef<{ a?: { v: number; t: number }; b?: { v: number; t: number } }>({});
|
const agPending = useRef<{ a?: { v: number; t: number }; b?: { v: number; t: number } }>({});
|
||||||
const [dbConn, setDbConn] = useState<{ backend: string; label: string } | null>(null);
|
const [dbConn, setDbConn] = useState<{ backend: string; label: string } | null>(null);
|
||||||
|
// Amplifier chip in the status bar: name + green OPERATE / orange STANDBY /
|
||||||
|
// red offline. Config re-read every 5s (so enabling the amp in Settings makes
|
||||||
|
// the chip appear); status polled every 2s while enabled.
|
||||||
|
const [ampCfg, setAmpCfg] = useState<{ enabled: boolean; type: string }>({ enabled: false, type: 'pgxl' });
|
||||||
|
const [ampSt, setAmpSt] = useState<any>({ connected: false });
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const load = () => GetPGXLSettings().then((s: any) => { if (alive) setAmpCfg({ enabled: !!s?.enabled, type: s?.type || 'pgxl' }); }).catch(() => {});
|
||||||
|
load();
|
||||||
|
const id = window.setInterval(load, 5000);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ampCfg.enabled) { setAmpSt({ connected: false }); return; }
|
||||||
|
let alive = true;
|
||||||
|
const get = ampCfg.type === 'pgxl' ? GetPGXLStatus : ampCfg.type.startsWith('acom') ? GetACOMStatus : GetSPEStatus;
|
||||||
|
const tick = () => get().then((s: any) => alive && setAmpSt(s || { connected: false })).catch(() => {});
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 2000);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, [ampCfg.enabled, ampCfg.type]);
|
||||||
// Multi-op "who's on air" widget: every operator's live status from the shared
|
// 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.
|
// 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 };
|
type LiveStation = { operator: string; station: string; freq_hz: number; band: string; mode: string; online: boolean; version: string; age_sec: number };
|
||||||
@@ -5122,16 +5144,39 @@ export default function App() {
|
|||||||
disabled={!rotatorHeading.enabled}
|
disabled={!rotatorHeading.enabled}
|
||||||
onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }}
|
onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }}
|
||||||
/>
|
/>
|
||||||
{liveStationsOn && (
|
{/* Amplifier chip: green = OPERATE, orange = STANDBY, red = offline.
|
||||||
<div
|
(PGXL has no standby notion here — green when connected.) */}
|
||||||
className={cn('inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-[11px] font-bold uppercase tracking-wider shrink-0 transition-colors',
|
{ampCfg.enabled && (() => {
|
||||||
onAir ? 'border-danger-border bg-danger-muted text-danger-muted-foreground' : 'border-border text-muted-foreground')}
|
const isPGXL = ampCfg.type === 'pgxl';
|
||||||
title={onAir ? t('live.onAirTip') : t('live.offlineTip')}
|
const name = isPGXL ? 'PGXL'
|
||||||
>
|
: ampCfg.type.startsWith('acom') ? `ACOM ${ampSt.model || ''}`.trim()
|
||||||
<span className={cn('size-2 rounded-full', onAir ? 'bg-danger animate-pulse' : 'bg-muted-foreground/40')} />
|
: `SPE ${ampSt.model || ''}`.trim();
|
||||||
{onAir ? t('live.onAir') : t('live.offline')}
|
const dot = !ampSt.connected ? 'bg-danger' : (isPGXL || ampSt.operate) ? 'bg-success' : 'bg-warning';
|
||||||
</div>
|
const state = !ampSt.connected ? (ampSt.last_error || 'offline') : (isPGXL ? (ampSt.state || 'connected') : ampSt.operate ? 'OPERATE' : 'STANDBY');
|
||||||
)}
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={`${name}: ${state}`}
|
||||||
|
onClick={() => { setSettingsSection('pgxl'); setShowSettings(true); }}
|
||||||
|
className="inline-flex items-center gap-1.5 px-2 h-5 rounded border text-[11px] transition-colors border-border hover:bg-muted cursor-pointer"
|
||||||
|
>
|
||||||
|
<span className={cn('size-2 rounded-full', dot)} />
|
||||||
|
{name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
{/* ON AIR badge: "did I log a QSO in the last 5 min" — meaningful on ANY
|
||||||
|
logbook backend (only the live_status PUBLISHING is MySQL-specific),
|
||||||
|
so it is always shown. Gating it on MySQL made it vanish for
|
||||||
|
local-SQLite operators when the Settings toggle was removed. */}
|
||||||
|
<div
|
||||||
|
className={cn('inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-[11px] font-bold uppercase tracking-wider shrink-0 transition-colors',
|
||||||
|
onAir ? 'border-danger-border bg-danger-muted text-danger-muted-foreground' : 'border-border text-muted-foreground')}
|
||||||
|
title={onAir ? t('live.onAirTip') : t('live.offlineTip')}
|
||||||
|
>
|
||||||
|
<span className={cn('size-2 rounded-full', onAir ? 'bg-danger animate-pulse' : 'bg-muted-foreground/40')} />
|
||||||
|
{onAir ? t('live.onAir') : t('live.offline')}
|
||||||
|
</div>
|
||||||
{/* Toasts / errors: the status bar's free space is far wider than the
|
{/* Toasts / errors: the status bar's free space is far wider than the
|
||||||
header band they used to sit in. Still one line (the bar is 28px),
|
header band they used to sit in. Still one line (the bar is 28px),
|
||||||
but CLICK opens the full text wrapped — long messages (a TQSL or
|
but CLICK opens the full text wrapped — long messages (a TQSL or
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -13,6 +13,9 @@ import {
|
|||||||
GetRotatorHeading, RotatorGoTo, RotatorStop,
|
GetRotatorHeading, RotatorGoTo, RotatorStop,
|
||||||
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
||||||
ListDenkoviDevices, ListSerialPorts, TestStationDevice,
|
ListDenkoviDevices, ListSerialPorts, TestStationDevice,
|
||||||
|
GetPGXLSettings, GetPGXLStatus, PGXLSetFanMode,
|
||||||
|
GetSPEStatus, SPESetOperate, SPESetPower, SPESetPowerLevel,
|
||||||
|
GetACOMStatus, ACOMSetOperate, ACOMSetPower,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
|
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) {
|
export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorProps) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [devices, setDevices] = useState<Device[]>([]);
|
const [devices, setDevices] = useState<Device[]>([]);
|
||||||
@@ -260,6 +367,17 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
|||||||
});
|
});
|
||||||
const dragId = useRef<string | null>(null);
|
const dragId = useRef<string | null>(null);
|
||||||
const [cols, setCols] = useState<string>(() => localStorage.getItem('opslog.stationCols') || 'auto');
|
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 () => {
|
const loadDevices = useCallback(async () => {
|
||||||
try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ }
|
try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ }
|
||||||
@@ -380,13 +498,16 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
|||||||
if (ant.enabled) {
|
if (ant.enabled) {
|
||||||
widgets.push({ id: 'antenna', node: <MotorAntennaWidget ant={ant} refetch={pollAnt} t={t} /> });
|
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) });
|
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 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 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 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
|
// 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
|
// 2×2 — which linear drag-reorder alone can't do, since the grid auto-flows to
|
||||||
|
|||||||
Reference in New Issue
Block a user