diff --git a/app.go b/app.go index 83d71fa..694210c 100644 --- a/app.go +++ b/app.go @@ -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. if p, err := a.profiles.Get(a.ctx, id); 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) + 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. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2fc77c4..32264ad 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -43,6 +43,7 @@ import { GetAwardDefs, GetUIPref, GetActiveProfile, QuitApp, ReportLiveActivity, LiveLastQSOAgeSec, + GetPGXLSettings, GetPGXLStatus, GetSPEStatus, GetACOMStatus, } from '../wailsjs/go/main/App'; import { Combobox } from '@/components/ui/combobox'; 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. const agPending = useRef<{ a?: { v: number; t: number }; b?: { v: number; t: number } }>({}); 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({ 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 // 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 }; @@ -5122,16 +5144,39 @@ export default function App() { disabled={!rotatorHeading.enabled} onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }} /> - {liveStationsOn && ( -
- - {onAir ? t('live.onAir') : t('live.offline')} -
- )} + {/* Amplifier chip: green = OPERATE, orange = STANDBY, red = offline. + (PGXL has no standby notion here — green when connected.) */} + {ampCfg.enabled && (() => { + const isPGXL = ampCfg.type === 'pgxl'; + const name = isPGXL ? 'PGXL' + : ampCfg.type.startsWith('acom') ? `ACOM ${ampSt.model || ''}`.trim() + : `SPE ${ampSt.model || ''}`.trim(); + const dot = !ampSt.connected ? 'bg-danger' : (isPGXL || ampSt.operate) ? 'bg-success' : 'bg-warning'; + const state = !ampSt.connected ? (ampSt.last_error || 'offline') : (isPGXL ? (ampSt.state || 'connected') : ampSt.operate ? 'OPERATE' : 'STANDBY'); + return ( + + ); + })()} + {/* 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. */} +
+ + {onAir ? t('live.onAir') : t('live.offline')} +
{/* 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), but CLICK opens the full text wrapped — long messages (a TQSL or diff --git a/frontend/src/components/StationControlPanel.tsx b/frontend/src/components/StationControlPanel.tsx index 7956b40..058aa7f 100644 --- a/frontend/src/components/StationControlPanel.tsx +++ b/frontend/src/components/StationControlPanel.tsx @@ -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({ 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)[st.model] || 1500; + const outW = Number(isACOM ? st.fwd_w : st.output_w) || 0; + const frac = Math.min(1, outW / maxW); + return ( +
+
+ +
+
{t('flxp.amplifier')}
+
{title}
+
+ +
+
+ {isPGXL ? ( +
+ {(['STANDARD', 'CONTEST', 'BROADCAST'] as const).map((m) => ( + + ))} + {st.state || ''}{st.temperature ? ` · ${Math.round(st.temperature)}°C` : ''} +
+ ) : ( + <> +
+ +
+ + +
+ {!isACOM && ( +
+ {(['L', 'M', 'H'] as const).map((lvl, i) => ( + + ))} +
+ )} +
+
+ {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'))} +
+ {st.connected && ( +
+
+
0.9 ? 'bg-danger' : frac > 0.75 ? 'bg-warning' : 'bg-primary')} + style={{ width: `${Math.round(frac * 100)}%` }} /> +
+
{t('flxp.outputPower')} — {outW} W / {maxW} W
+
+ )} + {(st.err_text || st.warnings || st.alarms) && ( +
⚠ {st.err_text || `${st.warnings || ''} ${st.alarms || ''}`}
+ )} + + )} +
+
+ ); +} + export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorProps) { const { t } = useI18n(); const [devices, setDevices] = useState([]); @@ -260,6 +367,17 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr }); const dragId = useRef(null); const [cols, setCols] = useState(() => 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: }); } + if (amp.enabled) { + widgets.push({ id: 'amplifier', node: }); + } 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